fix(git-sync): run auto-pull as the admin who enabled it (#11121)

* fix(git-sync): run auto-pull as the admin who enabled it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(git-sync): audit the admin grant fork pulls make

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: bump ee ref for the post-commit fork grant audit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(git-sync): address review nits on the auto-pull stamp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: update ee-repo-ref to ccada062c072d7b74894b63863728fd1ef9bdffd

This commit updates the EE repository reference after PR #799 was merged in windmill-ee-private.

Previous ee-repo-ref: 7cee30f0cf12721cba551cd754dc817444810470

New ee-repo-ref: ccada062c072d7b74894b63863728fd1ef9bdffd

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-09-15 10:34:57 +02:00
committed by GitHub
co-authored by Claude Opus 5 windmill-internal-app[bot]
parent 082d897328
commit 69e6efd875
20 changed files with 481 additions and 43 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username, email FROM usr WHERE workspace_id = $1 AND is_admin = true AND operator = false AND disabled = false ORDER BY username LIMIT 1",
"query": "SELECT u.username, u.email FROM usr u WHERE u.workspace_id = $1 AND u.is_admin AND NOT u.operator AND NOT u.disabled AND NOT EXISTS (SELECT 1 FROM password p WHERE p.email = u.email AND p.disabled) ORDER BY u.username LIMIT 1",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
false
]
},
"hash": "3202bed875693ae923f496272cd8ad89b2f17a9d3ef4659c2d2284415177b32c"
"hash": "0c4dc0e9dc159fac7e41492c78a4e4e0b12b105d4475d7eba2d3a9573b93e388"
}
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(username, split_part(email, '@', 1)) AS \"username!\", email FROM password WHERE super_admin = true AND disabled = false ORDER BY email LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
false
]
},
"hash": "17cdf02b4912078459526205849246fd2bdf9e3fce89852120aa4ad4ad6abfc3"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT super_admin AS \"super_admin!\" FROM password WHERE email = $1 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "super_admin!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "3fe41e2a72d02613a2b1c1c44fb0a7b681d101d286adfc6ff4548d1b9fdbab8c"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT r->'auto_pull'->>'enabled_by' AS \"enabled_by\"\n FROM workspace_settings, jsonb_array_elements(git_sync->'repositories') r\n WHERE workspace_id = $1 AND r->>'git_repo_resource_path' = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "enabled_by",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "6dc8032100a28c4a6e843370038dfec43dc8a042f9d95e63bf84b2dc15a72165"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT u.username, u.is_admin, u.operator, u.disabled, EXISTS (SELECT 1 FROM password p WHERE p.email = u.email AND p.disabled) AS \"instance_disabled!\" FROM usr u WHERE u.workspace_id = $1 AND u.email = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "instance_disabled!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
null
]
},
"hash": "8bdfc02e7be54c2b610fed11cce75f8d2f1665d329b8adea424b3a6f1e5c7013"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, operator)\n SELECT $1::varchar, u.username, u.email, true, false FROM usr u\n WHERE u.workspace_id = $2 AND u.email = $3 AND u.is_admin AND NOT u.operator AND NOT u.disabled\n AND NOT EXISTS (SELECT 1 FROM password p WHERE p.email = u.email AND p.disabled)\n AND NOT EXISTS (SELECT 1 FROM usr f WHERE f.workspace_id = $1::varchar AND f.email = $3)\n ON CONFLICT DO NOTHING\n RETURNING username",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "a80a18774baf36d09b07da1e4e30baab26b8160929aea6d8f5f226e6ec4f8bd8"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM usr WHERE workspace_id = $1 AND username = $2) AS \"claimed!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "claimed!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "b675c20bb7a15bec5e9a34d7ddf347e22196e46727487f35634af079c71c2bef"
}
+1
View File
@@ -15727,6 +15727,7 @@ dependencies = [
"tokio",
"tracing",
"uuid",
"windmill-audit",
"windmill-common",
"windmill-dep-map",
"windmill-queue",
+1 -1
View File
@@ -1 +1 @@
0780955effb657807d14f0eb503cba1d49cee007
ccada062c072d7b74894b63863728fd1ef9bdffd
+46
View File
@@ -0,0 +1,46 @@
-- A parent workspace whose auto-pulled repository was last saved by alice, and a fork of
-- it holding only carol, the non-admin who created it. aaron is an admin who sorts before
-- alice; bob is no longer an admin; dora is a workspace admin deactivated on the instance.
-- sam and sue are instance superadmins who are not members: sam's instance username is
-- carol's, sue's is unclaimed.
INSERT INTO workspace (id, name, owner) VALUES ('ap-parent', 'ap-parent', 'alice@windmill.dev');
INSERT INTO workspace (id, name, owner, parent_workspace_id)
VALUES ('wm-fork-feat', 'feat', 'carol@windmill.dev', 'ap-parent');
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
('ap-parent', 'all', 'All users', '{}'),
('wm-fork-feat', 'all', 'All users', '{}');
INSERT INTO password (email, password_hash, login_type, super_admin, verified, name, disabled) VALUES
('aaron@windmill.dev', 'not-a-real-hash', 'password', false, true, 'aaron', false),
('alice@windmill.dev', 'not-a-real-hash', 'password', false, true, 'alice', false),
('bob@windmill.dev', 'not-a-real-hash', 'password', false, true, 'bob', false),
('carol@windmill.dev', 'not-a-real-hash', 'password', false, true, 'carol', false),
('dora@windmill.dev', 'not-a-real-hash', 'password', false, true, 'dora', true);
INSERT INTO password (email, password_hash, login_type, super_admin, verified, name, disabled, username) VALUES
('sam@windmill.dev', 'not-a-real-hash', 'password', true, true, 'sam', false, 'carol'),
('sue@windmill.dev', 'not-a-real-hash', 'password', true, true, 'sue', false, 'sue');
INSERT INTO usr (workspace_id, email, username, is_admin) VALUES
('ap-parent', 'aaron@windmill.dev', 'aaron', true),
('ap-parent', 'alice@windmill.dev', 'alice', true),
('ap-parent', 'bob@windmill.dev', 'bob', false),
('ap-parent', 'carol@windmill.dev', 'carol', false),
('ap-parent', 'dora@windmill.dev', 'dora', true),
('wm-fork-feat', 'carol@windmill.dev', 'carol', false);
INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) VALUES
('ap-parent', 'u/alice/repo', '{"url": "https://github.com/test/repo.git", "branch": "main"}',
'git_repository', '{}', 'alice'),
('wm-fork-feat', 'u/alice/repo', '{"url": "https://github.com/test/repo.git", "branch": "main"}',
'git_repository', '{}', 'alice');
INSERT INTO workspace_settings (workspace_id, git_sync) VALUES
('ap-parent', '{"repositories":[{"git_repo_resource_path":"$res:u/alice/repo",
"use_individual_branch":false,"group_by_folder":false,
"auto_pull":{"enabled":true,"mode":"polling","sync_forks":true,
"enabled_by":"alice@windmill.dev"}}]}'),
('wm-fork-feat', '{"repositories":[{"git_repo_resource_path":"$res:u/alice/repo",
"use_individual_branch":false,"group_by_folder":false}]}');
+194
View File
@@ -0,0 +1,194 @@
//! An automatic pull runs as the admin stamped on the repository's settings, never as
//! someone picked from the workspace, and stops once that admin is revoked. A fork's
//! pull runs as the parent's pull identity, added to the fork first.
#![cfg(all(feature = "enterprise", feature = "private"))]
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::GitRepositorySettings;
use windmill_git_sync::{reconcile_and_enqueue_pull, reconcile_fork_branch_pull};
const PARENT: &str = "ap-parent";
const FORK: &str = "wm-fork-feat";
const REPO: &str = "$res:u/alice/repo";
fn repo_enabled_by(email: &str) -> GitRepositorySettings {
serde_json::from_value(serde_json::json!({
"git_repo_resource_path": REPO,
"use_individual_branch": false,
"group_by_folder": false,
"auto_pull": { "enabled": true, "enabled_by": email }
}))
.expect("repository settings")
}
/// `(created_by, permissioned_as, permissioned_as_email)` of every pull job in `w_id`.
async fn pull_identities(
db: &Pool<Postgres>,
w_id: &str,
) -> anyhow::Result<Vec<(String, String, Option<String>)>> {
Ok(sqlx::query_as(
"SELECT created_by, permissioned_as, permissioned_as_email FROM v2_job \
WHERE workspace_id = $1 AND kind = 'deploymentcallback'",
)
.bind(w_id)
.fetch_all(db)
.await?)
}
fn identity(username: &str) -> (String, String, Option<String>) {
(
username.to_string(),
format!("u/{username}"),
Some(format!("{username}@windmill.dev")),
)
}
async fn recorded_pull_error(db: &Pool<Postgres>, w_id: &str) -> anyhow::Result<String> {
let git_sync: serde_json::Value =
sqlx::query_scalar("SELECT git_sync FROM workspace_settings WHERE workspace_id = $1")
.bind(w_id)
.fetch_one(db)
.await?;
Ok(
git_sync["repositories"][0]["auto_pull"]["last_pull_status"]["error"]
.as_str()
.unwrap_or_default()
.to_string(),
)
}
#[sqlx::test(fixtures("git_sync_autopull_identity"))]
async fn pull_runs_as_the_admin_who_enabled_it(db: Pool<Postgres>) -> anyhow::Result<()> {
let job = reconcile_and_enqueue_pull(
&db,
PARENT,
&repo_enabled_by("alice@windmill.dev"),
"main",
"abc123",
None,
)
.await?;
assert!(job.is_some());
assert_eq!(pull_identities(&db, PARENT).await?, vec![identity("alice")]);
Ok(())
}
/// bob was demoted in the workspace; dora is still a workspace admin but deactivated on
/// the instance. Neither may run the pull, and the failure lands on the status rather
/// than as an error, which a webhook delivery would turn into a failed response.
#[sqlx::test(fixtures("git_sync_autopull_identity"))]
async fn pull_fails_on_the_status_once_the_enabling_admin_is_revoked(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
for email in ["bob@windmill.dev", "dora@windmill.dev"] {
let job = reconcile_and_enqueue_pull(
&db,
PARENT,
&repo_enabled_by(email),
"main",
"abc123",
None,
)
.await?;
assert!(job.is_none(), "{email} must not run the pull");
let error = recorded_pull_error(&db, PARENT).await?;
assert!(error.contains(email), "{error}");
}
assert!(pull_identities(&db, PARENT).await?.is_empty());
Ok(())
}
#[sqlx::test(fixtures("git_sync_autopull_identity"))]
async fn fork_pull_runs_as_the_parent_admin_added_to_the_fork(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let job = reconcile_fork_branch_pull(&db, PARENT, REPO, "wm-fork/main/feat", "main", "abc123")
.await?;
assert!(
job.is_some(),
"the fork branch must route to the fork and enqueue"
);
let (is_admin, in_all): (bool, bool) = sqlx::query_as(
"SELECT u.is_admin, EXISTS (SELECT 1 FROM usr_to_group g \
WHERE g.workspace_id = u.workspace_id AND g.usr = u.username AND g.group_ = 'all') \
FROM usr u WHERE u.workspace_id = $1 AND u.email = 'alice@windmill.dev'",
)
.bind(FORK)
.fetch_one(&db)
.await?;
assert!(
is_admin && in_all,
"alice must be an admin member of the fork"
);
assert_eq!(pull_identities(&db, FORK).await?, vec![identity("alice")]);
let grants: i64 = sqlx::query_scalar(
"SELECT count(*) FROM audit_partitioned WHERE workspace_id = $1 \
AND operation = 'users.git_sync_fork_add' AND resource = 'alice@windmill.dev'",
)
.bind(FORK)
.fetch_one(&db)
.await?;
assert_eq!(grants, 1, "adding alice to the fork must be audited");
Ok(())
}
/// A superadmin who is not a member runs the pull under their instance username, and
/// `u/<username>` resolves through the workspace's members first. sam's instance username
/// is carol's, so sam's stamp must not run the pull as carol; sue's is unclaimed.
#[sqlx::test(fixtures("git_sync_autopull_identity"))]
async fn a_non_member_superadmin_runs_the_pull_only_under_an_unclaimed_username(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let job = reconcile_and_enqueue_pull(
&db,
PARENT,
&repo_enabled_by("sam@windmill.dev"),
"main",
"abc123",
None,
)
.await?;
assert!(job.is_none(), "sam's username belongs to carol");
assert!(pull_identities(&db, PARENT).await?.is_empty());
let job = reconcile_and_enqueue_pull(
&db,
PARENT,
&repo_enabled_by("sue@windmill.dev"),
"main",
"abc123",
None,
)
.await?;
assert!(job.is_some());
assert_eq!(pull_identities(&db, PARENT).await?, vec![identity("sue")]);
Ok(())
}
/// With no stamp on the parent, the fork pull still runs as the parent's first active
/// admin: the fork holds only its non-admin creator, so no identity resolved in the fork
/// could run it.
#[sqlx::test(fixtures("git_sync_autopull_identity"))]
async fn unstamped_fork_pull_runs_as_the_parents_first_admin(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
sqlx::query(
"UPDATE workspace_settings SET git_sync = git_sync #- '{repositories,0,auto_pull,enabled_by}' \
WHERE workspace_id = $1",
)
.bind(PARENT)
.execute(&db)
.await?;
let job = reconcile_fork_branch_pull(&db, PARENT, REPO, "wm-fork/main/feat", "main", "abc123")
.await?;
assert!(
job.is_some(),
"an unstamped parent must still sync its forks"
);
assert_eq!(pull_identities(&db, FORK).await?, vec![identity("aaron")]);
Ok(())
}
@@ -923,6 +923,7 @@ async fn test_pull_stays_on_the_workspace_lane(db: Pool<Postgres>) -> anyhow::Re
&db,
"test-workspace",
&repo,
("test-user", "test@windmill.dev"),
None,
false,
None,
@@ -910,12 +910,15 @@ fn redact_git_sync_webhook_secrets(git_sync: &mut serde_json::Value) {
}
/// Zero the server-owned auto-pull fields (webhook id/secret/url/error, synced
/// sha, last pull status) on a client-supplied `AutoPullSettings`. The client only
/// controls `enabled` / `mode` / `poll_interval_s`; the rest is written by the
/// server (webhook creation, poller) and must never be trusted from the request —
/// otherwise a caller could inject a webhook id/secret or fake sync state.
fn clear_client_supplied_auto_pull_state(
/// sha, last pull status) on a client-supplied `AutoPullSettings`, and stamp who
/// pulls run as: `saver_email` while auto pull is on. The client only controls
/// `enabled` / `mode` / `poll_interval_s`; the rest is written by the server (webhook
/// creation, poller, this save) and must never be trusted from the request —
/// otherwise a caller could inject a webhook id/secret, fake sync state, or pick who
/// pulls run as.
fn sanitize_client_auto_pull(
auto_pull: &mut windmill_common::workspaces::AutoPullSettings,
saver_email: &str,
) {
auto_pull.webhook_id = None;
auto_pull.webhook_secret = None;
@@ -923,6 +926,28 @@ fn clear_client_supplied_auto_pull_state(
auto_pull.webhook_error = None;
auto_pull.last_synced_sha = std::collections::HashMap::new();
auto_pull.last_pull_status = None;
auto_pull.enabled_by = auto_pull.enabled.then(|| saver_email.to_string());
}
#[cfg(test)]
mod sanitize_client_auto_pull_tests {
use windmill_common::workspaces::AutoPullSettings;
#[test]
fn a_save_stamps_the_saver_over_any_client_supplied_stamp() {
let mut ap = AutoPullSettings {
enabled: true,
enabled_by: Some("forged@example.com".to_string()),
..Default::default()
};
super::sanitize_client_auto_pull(&mut ap, "saver@example.com");
assert_eq!(ap.enabled_by.as_deref(), Some("saver@example.com"));
ap.enabled = false;
ap.enabled_by = Some("forged@example.com".to_string());
super::sanitize_client_auto_pull(&mut ap, "saver@example.com");
assert_eq!(ap.enabled_by, None, "auto pull off carries no stamp");
}
}
/// Whether a git-sync repository tracking `tracked` rules out `label_branch` as a dev workspace's
@@ -3983,7 +4008,7 @@ async fn edit_git_sync_config(
// stay clean.
for repo in git_sync_settings.repositories.iter_mut() {
if let Some(ap) = repo.auto_pull.as_mut() {
clear_client_supplied_auto_pull_state(ap);
sanitize_client_auto_pull(ap, &authed.email);
}
repo.open_pr_error = None;
repo.credential = None;
@@ -4230,7 +4255,7 @@ async fn edit_git_sync_repository(
// existing repo re-derives it from the DB (carried over below) and a new one
// starts clean.
if let Some(ap) = new_config.repository.auto_pull.as_mut() {
clear_client_supplied_auto_pull_state(ap);
sanitize_client_auto_pull(ap, &authed.email);
}
new_config.repository.open_pr_error = None;
new_config.repository.credential = None;
+3
View File
@@ -35418,6 +35418,9 @@ components:
type: string
last_pull_status:
$ref: "#/components/schemas/AutoPullStatus"
enabled_by:
type: string
description: Email of the admin automatic pulls apply changes as. Set by the server when the settings are saved.
required:
- enabled
@@ -1587,10 +1587,10 @@ pub(crate) async fn tarball_workspace(
// Use v2 format only if explicitly requested, otherwise use v1 (legacy) for backward compatibility
// Server-owned state (the HMAC webhook secret + hook id/error, the
// synced-sha / last-pull status, and what the credential check observed)
// must never leave the server: keep it out of export archives and synced
// repos, and don't let a re-imported workspace inherit another install's
// hook/sync state. Mirrors the GET-settings redaction.
// synced-sha / last-pull status, the admin automatic pulls run as, and what
// the credential check observed) must never leave the server: keep it out of
// export archives and synced repos, and don't let a re-imported workspace
// inherit another install's hook/sync state or pull identity.
fn redact_git_sync_for_export(git_sync: Option<Value>) -> Option<Value> {
let mut git_sync = git_sync?;
if let Some(repos) = git_sync
@@ -1607,6 +1607,7 @@ pub(crate) async fn tarball_workspace(
"webhook_error",
"last_synced_sha",
"last_pull_status",
"enabled_by",
] {
auto_pull.remove(field);
}
@@ -514,6 +514,11 @@ pub struct AutoPullSettings {
pub last_synced_sha: std::collections::HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_pull_status: Option<AutoPullStatus>,
/// Email of the admin this repository's automatic pulls (its own and its forks')
/// apply changes as: whoever last saved the settings with auto pull on. Stamped
/// server-side, never taken from the client.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled_by: Option<String>,
}
// Manual Debug so the HMAC `webhook_secret` (even encrypted) never lands in logs.
@@ -524,6 +529,7 @@ impl std::fmt::Debug for AutoPullSettings {
.field("mode", &self.mode)
.field("poll_interval_s", &self.poll_interval_s)
.field("sync_forks", &self.sync_forks)
.field("enabled_by", &self.enabled_by)
.field("webhook_id", &self.webhook_id)
.field(
"webhook_secret",
@@ -2709,6 +2715,7 @@ mod tests {
webhook_secret: None,
webhook_url: None,
webhook_error: None,
enabled_by: None,
last_synced_sha: synced
.iter()
.map(|(r, s)| (r.to_string(), s.to_string()))
+3 -2
View File
@@ -9,8 +9,8 @@ name = "windmill_git_sync"
path = "./src/lib.rs"
[features]
private = ["windmill-common/private", "windmill-dep-map/private"]
enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"]
private = ["windmill-common/private", "windmill-dep-map/private", "windmill-audit/private"]
enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise", "windmill-audit/enterprise"]
all_sqlx_features = ["enterprise"]
default = []
@@ -23,5 +23,6 @@ tracing.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-queue.workspace = true
windmill-dep-map.workspace = true
windmill-audit.workspace = true
regex = "1.10.3"
tokio = { workspace = true, features = ["full"] }
+29
View File
@@ -186,6 +186,35 @@ Routing — an event/poll result is `(repo, ref, head_sha, sender)`:
filters): fan out, each workspace pulls with its own filters; `wmill.yaml` in
the repo stays authoritative for include/exclude.
Identity — a pull applies changes as a real workspace admin, never a reserved identity.
The schedules, triggers and app policies it deploys persist their deployer as the identity
they run as, and `validate_on_behalf_of` refuses reserved sentinels there, so a real admin
is what keeps those deployable and revocable (demote or remove the admin and what runs
under them stops).
- The admin is `auto_pull.enabled_by`, stamped server-side with the email of whoever last
saved the git sync settings with auto pull on. Re-saving as another admin rotates it.
- A stamp naming someone who is no longer an active admin (demoted, or deactivated in the
workspace or on the instance), and not an active instance superadmin either, fails the
pull rather than falling back to someone else. A superadmin who is not a member runs it
under their instance username, and only while no member of the workspace holds that
username: `u/<username>` resolves through the workspace's members before the email. A
repository whose settings predate the stamp runs as the workspace's first active admin
until they are saved again.
- The identity is resolved before the deploy check is posted, and a failure to resolve it
or to enqueue is recorded on the repository's status, not returned: a returned error
would fail the webhook delivery, and hosts disable hooks whose deliveries keep failing.
The next push or poll retries.
- Fork pulls run as the parent repository's identity, stamped or not, resolved in the
parent (revoking that admin there stops fork pulls too), and first add that admin to
the fork as an admin member, since a plain fork carries only its creator. The fork's
owner cannot be the identity: a non-admin's `wmill sync push` diffs against what it can
see, so an item in a folder it cannot read reads as a create and the push fails on every
commit. CI tests do run as the owner (Phase 7), because they only execute.
- Known and accepted: repo writers control the pull's includes through `wmill.yaml`, so a
fork's owner can commit a user file that makes them admin of the fork and read the
parent secrets it cloned, as with the `push-on-merge-to-forks` Action this replaces.
Loop prevention (pull → deploys → deployment callback → commit → push event):
1. Skip events whose sender is the app bot (`windmill-sync-helper[bot]` /
@@ -1,5 +1,5 @@
import { getContext, setContext } from 'svelte'
import { enterpriseLicense } from '$lib/stores'
import { enterpriseLicense, userStore } from '$lib/stores'
import { get } from 'svelte/store'
import { sendUserToast } from '$lib/toast'
import { apiErrorMessage } from '$lib/utils'
@@ -534,6 +534,15 @@ export function createGitSyncContext(workspace: string) {
}
})
// The server stamps the saving admin as who pulls run as; mirror it so the card
// names them without a reload.
if (repoToSave.auto_pull) {
repoToSave.auto_pull = {
...repoToSave.auto_pull,
enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined
}
}
// Update local state with migrated repository
repositories[idx] = repoToSave
initialRepositories[idx] = { ...repoToSave }
@@ -1038,6 +1038,14 @@
: 'Checking the tracked branch about every minute. New commits deploy automatically.'}
{/if}
</div>
{#if repo.auto_pull?.enabled_by}
<div class="text-2xs text-secondary mt-1">
Pulls apply changes as {repo.auto_pull.enabled_by}, the admin who last saved
these settings{repo.auto_pull.sync_forks
? ', in this workspace and its forks'
: ''}.
</div>
{/if}
{#if hasManagedCredential && repo.auto_pull?.webhook_error}
<div class="mt-2">
<Alert type="warning" title="Falling back to polling" size="xs">