mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
* fix: walk the whole fork ancestry for app installations and fork conflicts Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs: describe the fork-conflict gate as ancestor-wide Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore: update ee-repo-ref to d252afcc80e77fcc4f9a2a346b80908c8605a6c0 This commit updates the EE repository reference after PR #803 was merged in windmill-ee-private. Previous ee-repo-ref: 5f68c8c351ffc92feccffe69a857b60be376464e New ee-repo-ref: d252afcc80e77fcc4f9a2a346b80908c8605a6c0 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
377 lines
13 KiB
Rust
377 lines
13 KiB
Rust
//! A fork reaches the git credential held above it in its fork chain.
|
|
//!
|
|
//! Fork creation copies the parent's git-sync repositories but not the credential,
|
|
//! which is stored per workspace so that rotation has one owner. Chains nest (a
|
|
//! fork of a dev workspace, a fork of that), so the depth-2 cases here are what
|
|
//! keep the lookup from regressing to the parent.
|
|
//!
|
|
//! The recorded *status* is not shared the same way: it describes one repository,
|
|
//! and a fork can repoint its copy of the resource, so each workspace answers from
|
|
//! its own record and gets one by fork creation copying it down.
|
|
#![cfg(all(feature = "enterprise", feature = "private"))]
|
|
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_common::git_sync_ee::{
|
|
create_repo_webhook, git_app_installations_for, git_credential_for_url, managed_pr_base_branch,
|
|
repo_provider, repo_supports_managed_git_features, set_git_credential, GitProvider,
|
|
};
|
|
use windmill_common::workspaces::GitCredentialProvider;
|
|
|
|
const REPO: &str = "$res:u/admin/repo";
|
|
const URL: &str = "https://gitlab.com/grp/proj.git";
|
|
|
|
/// A repository is managed when a credential is held for the repository its
|
|
/// URL names now and the last check found it healthy. The recorded status is
|
|
/// keyed by resource path, so alone it would outlive a repoint; the held
|
|
/// credential alone says nothing about whether the host still accepts it.
|
|
#[sqlx::test(fixtures("git_sync_fork_credential"))]
|
|
async fn credential_status_is_a_workspaces_own(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
assert!(
|
|
!repo_supports_managed_git_features(&db, "parent-ws", REPO).await,
|
|
"a healthy status with nothing held behind it does not qualify"
|
|
);
|
|
set_git_credential(
|
|
&db,
|
|
"parent-ws",
|
|
URL,
|
|
"glpat-secret",
|
|
GitCredentialProvider::Gitlab,
|
|
)
|
|
.await?;
|
|
assert!(
|
|
repo_supports_managed_git_features(&db, "parent-ws", REPO).await,
|
|
"the workspace holding both the credential and the recorded status qualifies"
|
|
);
|
|
assert!(
|
|
!repo_supports_managed_git_features(&db, "fork-ws", REPO).await,
|
|
"a fork borrowing the credential with no record of its own does not: the \
|
|
status describes one repository, and this fork's resource could name another"
|
|
);
|
|
assert!(
|
|
!repo_supports_managed_git_features(&db, "errored-fork-ws", REPO).await,
|
|
"a workspace whose own credential failed stays disqualified"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// The host a repository talks to is declared when its credential is stored, and
|
|
/// travels with the credential down the fork chain.
|
|
///
|
|
/// Read from the recorded status instead, a fork answered with the default
|
|
/// provider until its own check ran, which is long enough to register a webhook
|
|
/// against the wrong receiver.
|
|
#[sqlx::test(fixtures("git_sync_fork_credential"))]
|
|
async fn the_provider_comes_from_the_credential_and_reaches_forks(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
assert_eq!(
|
|
repo_provider(&db, "parent-ws", REPO).await,
|
|
GitProvider::GitHub,
|
|
"with nothing stored there is no declaration to read, so the default stands"
|
|
);
|
|
|
|
set_git_credential(
|
|
&db,
|
|
"parent-ws",
|
|
URL,
|
|
"glpat-secret",
|
|
GitCredentialProvider::Gitlab,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
repo_provider(&db, "parent-ws", REPO).await,
|
|
GitProvider::GitLab,
|
|
"the workspace that stored it reads its own declaration"
|
|
);
|
|
assert_eq!(
|
|
repo_provider(&db, "fork-ws", REPO).await,
|
|
GitProvider::GitLab,
|
|
"and a fork resolving that credential reads it too, without a check of its own"
|
|
);
|
|
assert_eq!(
|
|
repo_provider(&db, "deep-fork-ws", REPO).await,
|
|
GitProvider::GitLab,
|
|
"two levels down as well"
|
|
);
|
|
assert_eq!(
|
|
repo_provider(&db, "orphan-ws", REPO).await,
|
|
GitProvider::GitHub,
|
|
"a workspace outside the chain resolves no credential and no declaration"
|
|
);
|
|
assert_eq!(
|
|
repo_provider(&db, "errored-fork-ws", REPO).await,
|
|
GitProvider::GitHub,
|
|
"a token written into the URL makes the repository a plain remote: the \
|
|
parent's credential is not consulted and no host is declared"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// The stored credential is shared with forks and keyed by one repository.
|
|
///
|
|
/// Both properties are the point of keeping it in `workspace_settings` under the
|
|
/// repository's identity: sharing is what stops a rotation from stranding every
|
|
/// fork on a revoked token, and the key is what stops a rewritten resource URL
|
|
/// from carrying the token to a host of the writer's choosing.
|
|
#[sqlx::test(fixtures("git_sync_fork_credential"))]
|
|
async fn a_fork_reads_an_ancestors_credential_for_the_bound_repository_only(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
set_git_credential(
|
|
&db,
|
|
"parent-ws",
|
|
URL,
|
|
"glpat-secret",
|
|
GitCredentialProvider::Gitlab,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
git_credential_for_url(&db, "parent-ws", URL)
|
|
.await?
|
|
.as_deref(),
|
|
Some("glpat-secret"),
|
|
"the workspace that stored it reads it back"
|
|
);
|
|
assert_eq!(
|
|
git_credential_for_url(&db, "fork-ws", URL)
|
|
.await?
|
|
.as_deref(),
|
|
Some("glpat-secret"),
|
|
"a fork stores none of its own and resolves the parent's"
|
|
);
|
|
assert_eq!(
|
|
git_credential_for_url(&db, "deep-fork-ws", URL)
|
|
.await?
|
|
.as_deref(),
|
|
Some("glpat-secret"),
|
|
"a fork of a fork resolves the root's, two levels up"
|
|
);
|
|
assert_eq!(
|
|
git_credential_for_url(&db, "fork-ws", "https://evil.example/grp/proj.git").await?,
|
|
None,
|
|
"a resource repointed at another repository asks for that one's \
|
|
credential and finds none"
|
|
);
|
|
assert_eq!(
|
|
git_credential_for_url(&db, "orphan-ws", URL).await?,
|
|
None,
|
|
"a workspace with no credential and no parent resolves nothing"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// One repository's credential is untouched by another's.
|
|
///
|
|
/// The key is the repository, so picking a second repository stores beside the
|
|
/// first rather than over it. Keyed by the resource instead, a workspace editing
|
|
/// one repository's resource to point somewhere else would replace the token the
|
|
/// original repository was still syncing with.
|
|
#[sqlx::test(fixtures("git_sync_fork_credential"))]
|
|
async fn each_repository_keeps_its_own_credential(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
const OTHER_URL: &str = "https://gitlab.com/grp/other.git";
|
|
|
|
set_git_credential(
|
|
&db,
|
|
"parent-ws",
|
|
URL,
|
|
"glpat-first",
|
|
GitCredentialProvider::Gitlab,
|
|
)
|
|
.await?;
|
|
set_git_credential(
|
|
&db,
|
|
"parent-ws",
|
|
OTHER_URL,
|
|
"glpat-second",
|
|
GitCredentialProvider::Gitlab,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
git_credential_for_url(&db, "parent-ws", URL)
|
|
.await?
|
|
.as_deref(),
|
|
Some("glpat-first"),
|
|
"storing a second repository's token leaves the first's in place"
|
|
);
|
|
assert_eq!(
|
|
git_credential_for_url(&db, "parent-ws", OTHER_URL)
|
|
.await?
|
|
.as_deref(),
|
|
Some("glpat-second")
|
|
);
|
|
|
|
set_git_credential(
|
|
&db,
|
|
"parent-ws",
|
|
URL,
|
|
"glpat-replacement",
|
|
GitCredentialProvider::Gitlab,
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
git_credential_for_url(&db, "parent-ws", URL)
|
|
.await?
|
|
.as_deref(),
|
|
Some("glpat-replacement"),
|
|
"storing the same repository again replaces rather than duplicates"
|
|
);
|
|
assert_eq!(
|
|
git_credential_for_url(&db, "parent-ws", OTHER_URL)
|
|
.await?
|
|
.as_deref(),
|
|
Some("glpat-second"),
|
|
"and still leaves the other repository alone"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// A credential issued for `https` is not served for the `http` spelling.
|
|
///
|
|
/// The resource holding the URL is writable by anyone with write on its path, so
|
|
/// without the scheme in the key that edit would send the token over cleartext.
|
|
#[sqlx::test(fixtures("git_sync_fork_credential"))]
|
|
async fn a_credential_is_not_served_over_a_downgraded_transport(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
set_git_credential(
|
|
&db,
|
|
"parent-ws",
|
|
URL,
|
|
"glpat-secret",
|
|
GitCredentialProvider::Gitlab,
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
git_credential_for_url(&db, "parent-ws", "http://gitlab.com/grp/proj.git").await?,
|
|
None
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// A GitLab the server cannot reach is the error reported, not the GitHub App
|
|
/// lookup that runs after it: for a self-managed GitLab behind a firewall or an
|
|
/// untrusted certificate, "no GitHub App installation" names neither the host
|
|
/// nor the cause.
|
|
#[sqlx::test(fixtures("git_sync_fork_credential"))]
|
|
async fn an_unreachable_gitlab_host_is_the_reported_error(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
let err = create_repo_webhook(
|
|
&db,
|
|
"parent-ws",
|
|
"http://glpat-secret@127.0.0.1:1/grp/proj.git",
|
|
"https://windmill.example/api/w/parent-ws/git_sync/webhook/gitlab",
|
|
"hook-secret",
|
|
)
|
|
.await
|
|
.expect_err("nothing listens on port 1");
|
|
assert!(
|
|
err.to_string().contains("Could not reach the git host"),
|
|
"unexpected error: {err}"
|
|
);
|
|
assert!(
|
|
!err.to_string().contains("glpat-secret"),
|
|
"the URL credential leaked into the error: {err}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// GitHub App installations are normally copied into a fork, but a workspace
|
|
/// attached as a dev workspace, or forked before its parent connected the App,
|
|
/// holds none, and neither does anything forked from it. The lookup reaches the
|
|
/// nearest workspace up the chain that holds some, and the background App path
|
|
/// (PR base resolution here) authenticates with that installation's token.
|
|
#[sqlx::test(fixtures("git_sync_fork_credential"))]
|
|
async fn app_installations_come_from_the_nearest_ancestor_holding_some(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
use axum::{routing::get, Router};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
// A stand-in GitHub API: one repository, and a record of who asked for it.
|
|
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
|
|
let app = Router::new().route(
|
|
"/api/v3/repos/acme/repo",
|
|
get({
|
|
let seen = seen.clone();
|
|
move |headers: axum::http::HeaderMap| {
|
|
let seen = seen.clone();
|
|
async move {
|
|
let auth = headers
|
|
.get("authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
seen.lock().unwrap().push(auth);
|
|
axum::Json(serde_json::json!({ "default_branch": "trunk" }))
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
|
let port = listener.local_addr()?.port();
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
let stub = format!("http://127.0.0.1:{port}");
|
|
|
|
// The root holds the installation, with a cached token so nothing is minted.
|
|
sqlx::query(
|
|
"UPDATE workspace_settings SET git_app_installations = $1::jsonb WHERE workspace_id = 'parent-ws'",
|
|
)
|
|
.bind(serde_json::json!([{
|
|
"installation_id": 42, "account_id": "acme", "jwt_token": "x",
|
|
"github_base_url": stub,
|
|
"installation_token": "root-token", "installation_token_expiration": 4102444800i64
|
|
}]))
|
|
.execute(&db)
|
|
.await?;
|
|
// The fork's copy of the resource names the App-backed repository.
|
|
sqlx::query("UPDATE resource SET value = $1::jsonb WHERE workspace_id = 'deep-fork-ws' AND path = 'u/admin/repo'")
|
|
.bind(serde_json::json!({ "url": format!("{stub}/acme/repo.git"), "is_github_app": true }))
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
git_app_installations_for(&db, "deep-fork-ws").await?,
|
|
("parent-ws".to_string(), vec![(42, Some(stub.clone()))]),
|
|
"two levels down, the root's installations are the ones to use"
|
|
);
|
|
assert_eq!(
|
|
git_app_installations_for(&db, "orphan-ws").await?,
|
|
("orphan-ws".to_string(), vec![]),
|
|
"a workspace with nothing above it resolves nothing"
|
|
);
|
|
assert_eq!(
|
|
managed_pr_base_branch(&db, "deep-fork-ws", REPO)
|
|
.await?
|
|
.as_deref(),
|
|
Some("trunk"),
|
|
"the background App path reaches the repository through the root's installation"
|
|
);
|
|
let seen = seen.lock().unwrap().clone();
|
|
assert!(
|
|
!seen.is_empty() && seen.iter().all(|auth| auth == "Bearer root-token"),
|
|
"every call authenticated with the root's cached token: {seen:?}"
|
|
);
|
|
|
|
// A closer holder takes precedence over the root.
|
|
sqlx::query(
|
|
"UPDATE workspace_settings SET git_app_installations = $1::jsonb WHERE workspace_id = 'fork-ws'",
|
|
)
|
|
.bind(serde_json::json!([{
|
|
"installation_id": 7, "account_id": "acme", "jwt_token": "x",
|
|
"github_base_url": stub,
|
|
"installation_token": "mid-token", "installation_token_expiration": 4102444800i64
|
|
}]))
|
|
.execute(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
git_app_installations_for(&db, "deep-fork-ws").await?.0,
|
|
"fork-ws",
|
|
"the nearest holder wins over the root"
|
|
);
|
|
Ok(())
|
|
}
|