Files
windmill/backend/windmill-common/src/git_sync_oss.rs
T
9444049d60 feat: bring gitlab repositories to parity for git sync (#10938)
* 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>
2026-09-08 11:54:43 +02:00

230 lines
8.3 KiB
Rust

#[cfg(feature = "private")]
#[allow(unused)]
pub use crate::git_sync_ee::*;
#[cfg(not(all(feature = "private", feature = "enterprise")))]
use sqlx::{Pool, Postgres};
use url::Url;
/// Gated on the pair to match [`with_stored_credential`] below, whose callers
/// reach it through this facade un-gated and so depend on it. Nothing routes
/// here today (the one caller imports the enterprise item directly), so this is
/// for uniformity: the next plain caller would otherwise find no definition
/// under `private` without `enterprise`.
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub async fn get_github_app_token_internal(
_db: &Pool<Postgres>,
_job_token: &str,
) -> crate::error::Result<String> {
return Err(crate::error::Error::BadRequest(
"Github app authentication is not available on the open source build".to_string(),
));
}
/// Server-held git credentials are an enterprise feature, so on this build a
/// repository URL authenticates with whatever it already carries.
///
/// Gated on the pair rather than on `private` alone: `private` does not imply
/// `enterprise`, and the callers are plain (no `#[cfg]`), so a build with one
/// and not the other would find neither this nor the enterprise definition.
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub async fn with_stored_credential(
_db: &Pool<Postgres>,
_w_id: &str,
url: String,
) -> crate::error::Result<String> {
Ok(url)
}
lazy_static::lazy_static! {
/// Matches a `user:password@` (or `user@`) userinfo component right after the URL scheme.
static ref GIT_URL_USERINFO_RE: regex::Regex =
regex::Regex::new(r"://[^/@]+@").unwrap();
}
/// Strip embedded credentials (the `user:password@` userinfo component) from a git URL so it can be
/// safely included in error messages and logs. Falls back to a regex when the URL does not parse.
pub fn sanitize_git_url(url: &str) -> String {
if let Ok(mut parsed) = Url::parse(url) {
if !parsed.username().is_empty() || parsed.password().is_some() {
// These setters only fail for cannot-be-a-base URLs, in which case we keep the parsed
// string as-is and let the regex fallback below handle stripping.
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
}
return GIT_URL_USERINFO_RE
.replace(parsed.as_str(), "://***@")
.into_owned();
}
GIT_URL_USERINFO_RE.replace(url, "://***@").into_owned()
}
/// Validate a user-supplied git remote URL before it is handed to `git` (`clone`,
/// `ls-remote`, `remote add`, `fetch`, ...). Two classes of abuse are rejected:
/// - Argument injection: a URL that git parses as a command-line option (e.g.
/// `--upload-pack=<cmd>`) turns `git ls-remote <url> HEAD` into arbitrary command
/// execution on the worker host, outside any job sandbox.
/// - Dangerous transports: git's remote-helper syntax (`ext::sh -c ...`, `fd::...`) runs
/// arbitrary programs, and `file://` / local paths read host files — both escape the
/// intended network-only fetch.
///
/// Only the standard network transports are allowed: `http(s)`, `ssh`, `git`, and the
/// scp-like `[user@]host:path` shorthand. Validation is transport-syntax based (not git
/// version dependent) so it holds regardless of git's own option/protocol handling.
pub fn validate_git_repo_url(url: &str) -> crate::error::Result<()> {
let reject =
|msg: &str| crate::error::Error::BadRequest(format!("Invalid git repository URL: {msg}"));
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(reject("the URL is empty"));
}
// Leading '-' makes git parse the URL as an option (argument injection).
if trimmed.starts_with('-') {
return Err(reject("the URL must not start with '-'"));
}
// `<helper>::<address>` remote-helper transports execute arbitrary programs.
if trimmed.contains("::") {
return Err(reject("remote-helper transports (`::`) are not allowed"));
}
if let Some((scheme, _rest)) = trimmed.split_once("://") {
// A real scheme is ASCII-alnum plus `+ - .` and holds no slash (a slash means the
// `://` came from the path, so there is no scheme and this is not a valid URL).
let is_scheme = !scheme.is_empty()
&& !scheme.contains('/')
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
if !is_scheme {
return Err(reject("malformed URL scheme"));
}
match scheme.to_ascii_lowercase().as_str() {
"http" | "https" | "ssh" | "git" => Ok(()),
other => Err(reject(&format!(
"scheme `{other}` is not allowed (use http(s), ssh, or git)"
))),
}
} else {
// No scheme: accept only the scp-like `[user@]host:path` shorthand. The host (the
// part before the first `:`) must be non-empty and slash-free; a slash there means a
// local path (`./repo`, `/abs/repo`), and a single-letter host is a Windows drive.
let Some((host, _path)) = trimmed.split_once(':') else {
return Err(reject(
"local paths are not allowed; use an http(s), ssh, or git URL",
));
};
let bad_host = host.is_empty()
|| host.contains('/')
|| (host.len() == 1 && host.chars().all(|c| c.is_ascii_alphabetic()));
if bad_host {
return Err(reject(
"local paths are not allowed; use an http(s), ssh, or git URL",
));
}
Ok(())
}
}
pub fn prepend_token_to_github_url(
github_url: &str,
installation_token: &str,
) -> crate::error::Result<String> {
let url = Url::parse(github_url)?;
let host = url.host_str().ok_or_else(|| {
crate::error::Error::BadRequest("Invalid GitHub URL: no host".to_string())
})?;
Ok(format!(
"https://x-access-token:{}@{}{}",
installation_token,
host,
url.path()
))
}
#[cfg(test)]
mod tests {
use super::sanitize_git_url;
#[test]
fn strips_username_and_password() {
assert_eq!(
sanitize_git_url("https://user:p4ssw0rd@github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
}
#[test]
fn strips_token_only_userinfo() {
assert_eq!(
sanitize_git_url("https://ghp_secrettoken@github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
}
#[test]
fn leaves_credential_free_url_untouched() {
assert_eq!(
sanitize_git_url("https://github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
}
#[test]
fn strips_credentials_from_unparseable_url() {
// scp-like syntax that `url::Url` cannot parse
assert_eq!(
sanitize_git_url("not a url://user:secret@host/repo"),
"not a url://***@host/repo"
);
}
use super::validate_git_repo_url;
#[test]
fn accepts_standard_transports() {
for url in [
"https://github.com/org/repo.git",
"http://internal.example/org/repo.git",
"https://user:token@github.com/org/repo.git",
"ssh://git@github.com/org/repo.git",
"ssh://git@github.com:2222/org/repo.git",
"git://github.com/org/repo.git",
"git@github.com:org/repo.git",
"user@host.example:path/to/repo",
] {
assert!(validate_git_repo_url(url).is_ok(), "should accept {url}");
}
}
#[test]
fn rejects_argument_injection() {
for url in [
"--upload-pack=touch /tmp/pwned",
"-oProxyCommand=touch /tmp/pwned",
"--config=core.fsmonitor=touch /tmp/pwned",
] {
assert!(validate_git_repo_url(url).is_err(), "should reject {url}");
}
}
#[test]
fn rejects_remote_helpers_and_local_transports() {
for url in [
"ext::sh -c 'id > /tmp/pwned'",
"fd::17/foo",
"file:///etc/passwd",
"/etc/passwd",
"./local/repo",
"../local/repo",
"ftp://host/repo",
"C:\\path\\to\\repo",
"",
" ",
] {
assert!(validate_git_repo_url(url).is_err(), "should reject {url:?}");
}
}
}