mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +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>
230 lines
8.3 KiB
Rust
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:?}");
|
|
}
|
|
}
|
|
}
|