mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 08:03:50 +00:00
* feat: track and rotate gitlab git-sync repository tokens * chore: point ee-repo-ref at the gitlab credential branch * fix: strip server-owned credential status and correct expiry copy * fix: gate credential maintenance on enterprise and alert on stalled renewal * fix: alert on an auto-renewed token only once it has actually expired * feat: receive gitlab push webhooks for instant git sync pull * feat: open gitlab merge requests and post diff previews on them Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: keep gitlab merge request previews out of the project's own pipeline Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: bound the credential maintenance pass and gate the gitlab picker on a license Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: create the gitlab picker's variable in the edited workspace Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: make the gitlab picker's variable path collision-resistant Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * docs: state the gitlab scope and rotation facts the code relies on Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: resolve the check marker's repository from its path, not a stored url Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: refuse to finish a check whose repository has been repointed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: trust a check marker's captured url when it carries no identity Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: recreate a missing webhook from credential maintenance Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * docs: state that relative-url gitlab installs are out of scope Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: keep credential status out of exports and clear stale webhook warnings Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: refuse an unprovable check and guard the picker on the stored repository Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: re-check the picker's target path at the moment it is written Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: snapshot the picker's inputs before it starts writing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * docs: recommend a project access token per repository Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * [ee] feat: keep the git-sync credential in workspace settings * [ee] fix: drop a removed repo's credential and honor the workspace override * [ee] fix: resolve a fork's git credential from its whole ancestry * [ee] refactor: reuse fork_ancestor_chain instead of a second ancestry walk * [ee] fix: resolve an app installation from the whole ancestry, not the parent * [ee] revert: keep the app installation fallback at one level * fix: store the git credential only once the resource is saved * fix: keep a repository's credential when it leaves git sync settings * docs: cut the gitlab picker's token guidance down to what it needs * feat: mark a repository whose credential windmill holds * fix: ignore the managed-credential marker when the url carries a token * docs: drop the picker's setup alert for a line by the token field * feat: replace a repository's stored token from its resource * fix: store a picked credential for its own workspace, before the resource * refactor: key a stored git credential by its repository, not its resource Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * chore: refresh the sqlx cache for the repository-keyed credential queries Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: gate the credential pass budget on the features that use it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: decide credential rotation ownership by repository, not resource path Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * refactor: renew only the credentials windmill holds, not tokens in a repo url Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: authenticate the fork-branch poll and correct the renewal guidance Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: do not claim a managed credential for a url the client cannot resolve Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: define the credential facade for private builds without enterprise Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: pin the listed token before the await and name the real renewal blocker Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: pin the token the replace flow checked, and derive the scope test once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: classify the renewal state once so the card cannot contradict itself Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * refactor: ask only whether the token gets renewed, not why it does not Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * refactor: replace the managed-credential marker with a server answer Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: read renewal from the credential and its origin, not a removed field Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: read the provider for url-token repos, await the origin before defaulting, and visit unchecked repos last The maintenance pass sorted repositories with no recorded check first on the premise that they cost nothing, but a token-in-URL remote on a host that is not GitLab is probed every pass and never records a check, so it held the head of the list ahead of the tokens that expire. Such repositories now sort last. The card decided its delivery defaults before the origin lookup landed, so a freshly picked GitLab repository never got webhook delivery; the two lookups are awaited together. The resource editor offers to replace a token only where it is held, not in a fork that borrows it, and the replace flow refuses a URL it cannot parse instead of keying the token to it. Attaching a stored credential to a commit-hash probe now requires admin, matching the installation credential beside it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * docs: describe the gitlab listing token the way the picker and the setup guide do Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * refactor: a token in the repository url is a plain remote, not a tracked credential Drops the status fingerprint that told one URL token from another, the docs' promise that such a token's expiry is reported, and the test's expectation that a URL-token repository declares a host. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: the card reads the credential origin for managed controls and honours the licence for a borrowed token Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * chore: bump the ee ref Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: hide a repository's credential line once nothing is held for the repository it names Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * docs: describe the exported credential status as it is Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * refactor: run the credential maintenance pass as its own task, without a budget The pass ran inside the monitor's join, whose deadline cancels every future in it, and a rotation cancelled between GitLab issuing a token and Windmill storing it loses the token family. A wall-clock budget with a least-recently-checked ordering kept it under the deadline. Spawning the pass instead makes the deadline irrelevant, so the budget, the ordering and the counter go; the advisory lock keeps a slow pass from overlapping the next, as it already did. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * docs: say what detaching the maintenance pass buys, and what it does not Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * chore: run git sync on the hub script version that reads a stored credential Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * chore: run the deploy push and the connection test on the hub versions that read a stored credential Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: keep App repositories and plain remotes out of the stored-credential paths Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * chore: host-neutral deploy preview wording, drop the project filter from the GitLab picker Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * chore: bump ee ref, rotation no longer retains a second connection per repository Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * chore: bump ee ref, the rotation write-back holds a single connection Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * chore: hold the credential maintenance lock in a transaction so a dead sweep releases it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * docs: describe the credential-stored callback as it fires Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * fix: keep the credential maintenance lock past the pool's idle-in-transaction timeout Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75 * chore: update ee-repo-ref to e092518ee60e33160fee9ae91a4d109566f7b0ee This commit updates the EE repository reference after PR #771 was merged in windmill-ee-private. Previous ee-repo-ref: 74481f7cc345757aebb2a8b04d3a22978328c348 New ee-repo-ref: e092518ee60e33160fee9ae91a4d109566f7b0ee 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>
253 lines
8.4 KiB
Rust
253 lines
8.4 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::{
|
|
git_credential_for_url, 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(())
|
|
}
|