mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix(git-sync): run auto-pull as the admin who enabled it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
80eba80d6e
commit
0cd62c0320
+2
-2
@@ -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"
|
||||
}
|
||||
-26
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+47
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+23
@@ -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 +1 @@
|
||||
a4da009a5eae72bd55f34de41ba7929b53d53c9b
|
||||
9b0192411568d7a2a1ca078283652452b56c60fb
|
||||
|
||||
@@ -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}]}');
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 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")]);
|
||||
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,7 @@ 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());
|
||||
}
|
||||
|
||||
/// Whether a git-sync repository tracking `tracked` rules out `label_branch` as a dev workspace's
|
||||
@@ -1363,6 +1367,22 @@ mod git_sync_deploy_mode_tests {
|
||||
use serde_json::json;
|
||||
use windmill_common::workspaces::{AutoPullMode, 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");
|
||||
}
|
||||
|
||||
fn auto_pull(mode: AutoPullMode, webhook_id: Option<i64>) -> AutoPullSettings {
|
||||
AutoPullSettings { enabled: true, mode, webhook_id, ..Default::default() }
|
||||
}
|
||||
@@ -3983,7 +4003,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 +4250,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;
|
||||
|
||||
@@ -34919,6 +34919,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
|
||||
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user