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
This commit is contained in:
hugocasa
2026-09-07 18:33:32 +02:00
co-authored by Claude Fable 5.1
parent 4cda578330
commit a7214cae64
7 changed files with 20 additions and 42 deletions
+1 -1
View File
@@ -1 +1 @@
333dadfd8bdab3102fd23df6c4b2fb31675c69cd
422a7406f76e57bdb3e89a08971242e4a8a465bb
+2 -3
View File
@@ -4775,9 +4775,8 @@ async fn maintain_git_credentials_inner(db: &Pool<Postgres>) -> error::Result<()
// Least-recently-checked first, so a pass that runs out of budget resumes
// where it stopped instead of re-checking the same head of the list forever.
// A repository with no recorded credential sorts last: it has nothing to
// rotate, yet a remote whose URL carries a token on a host that is not
// GitLab still costs a probe every pass and never records a check, so put
// first it would hold the head of the list ahead of the tokens that expire.
// rotate, and it never records a check, so put first it would hold the head
// of the list ahead of the tokens that expire.
let rows = sqlx::query!(
r#"SELECT ws.workspace_id, ws.git_sync
FROM workspace_settings ws
+1 -2
View File
@@ -46,8 +46,7 @@ INSERT INTO workspace_settings (workspace_id, git_sync) VALUES
-- The resource each repository entry names, all pointing at the same repository.
-- The errored fork carries its token in the URL, the way a repository configured
-- by hand does: nothing stores a credential for it, so only its recorded check
-- knows which host it talks to.
-- by hand does: a plain remote, whatever the chain above it holds.
INSERT INTO resource (workspace_id, path, value, resource_type) VALUES
('parent-ws', 'u/admin/repo', '{"url":"https://gitlab.com/grp/proj.git"}', 'git_repository'),
('fork-ws', 'u/admin/repo', '{"url":"https://gitlab.com/grp/proj.git"}', 'git_repository'),
+7 -16
View File
@@ -39,28 +39,19 @@ async fn credential_status_is_a_workspaces_own(db: Pool<Postgres>) -> anyhow::Re
}
/// The host a repository talks to is declared when its credential is stored, and
/// travels with the credential down the fork chain. A repository whose token
/// rides in its URL has no stored credential, so its host is known only from the
/// check that introspected the token.
/// travels with the credential down the fork chain.
///
/// Read from the recorded status alone, a fork answered with the default
/// 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. Read from the credential alone, a URL-token
/// repository answered with the default forever.
/// 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::GitLab,
"with nothing stored, the host the check recorded is the answer"
);
assert_eq!(
repo_provider(&db, "fork-ws", REPO).await,
GitProvider::GitHub,
"a fork with neither a credential to resolve nor a check of its own \
answers the default"
"with nothing stored there is no declaration to read, so the default stands"
);
set_git_credential(
@@ -94,9 +85,9 @@ async fn the_provider_comes_from_the_credential_and_reaches_forks(
);
assert_eq!(
repo_provider(&db, "errored-fork-ws", REPO).await,
GitProvider::GitLab,
"a token carried in the URL is held by nobody, so the parent's credential \
is not consulted and the recorded check alone names the host"
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(())
}
-3
View File
@@ -34389,9 +34389,6 @@ components:
type: array
items:
type: string
token_fingerprint:
type: string
description: one-way digest identifying which credential this status describes
rotatable:
type: boolean
description: whether this workspace renews the credential itself
@@ -436,11 +436,6 @@ pub struct GitCredentialStatus {
pub expires_at: Option<chrono::NaiveDate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
/// Which credential this status describes, as a one-way digest. Lets a later
/// check tell "the token I know about stopped working" from "someone put a
/// different credential here", which look identical at the API otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_fingerprint: Option<String>,
/// Whether *this workspace* renews the credential. That needs a scope which
/// permits it (`api` or `self_rotate`) and a credential this workspace holds:
/// a token carried in the repository URL is the operator's to manage, and one
+9 -12
View File
@@ -81,21 +81,18 @@ App installation token. Storing it this way keeps it out of the variables API an
out of every fork's own storage; it is not a boundary against the admins of those
workspaces.
A URL with the token written into it keeps working, whether it sits in the
resource or in a secret variable the resource points at (`"url": "$var:..."`).
Windmill reports its expiry on the repository but does not renew it: that token
is yours to manage, as it is for a GitHub repository authenticated the same way.
Forking copies such a URL with its value, so two workspaces would hold the same
token each believing it issued it, and presenting an already-rotated one to
GitLab costs the whole token family. Use the **GitLab** button to hand the token
to Windmill if you want it renewed.
A URL with the token written into it, in the resource or in a secret variable
the resource points at (`"url": "$var:..."`), is a plain git remote: it syncs on
deploy and by polling, and nothing else. Windmill does not know the token is
there, so it registers no webhook, opens no merge request, and neither reports
nor renews its expiry. Use the **GitLab** button to hand the token to Windmill
if you want any of that.
## Expiry and renewal
Windmill reads `expires_at` from the token itself and shows it on the repository
in the workspace's git sync settings, for every repository including the ones
whose token sits in the URL. A token Windmill holds it also renews: within three
weeks of expiry it rotates it through GitLab's own
Windmill reads `expires_at` from the token it holds and shows it on the
repository in the workspace's git sync settings. Within three weeks of expiry it
rotates the token through GitLab's own
`POST /personal_access_tokens/self/rotate`, stores the replacement, and verifies
it. Only the token can rotate itself, so one without `api` (or `self_rotate`) is
a permanent warning rather than something Windmill can fix.