diff --git a/AGENTS.md b/AGENTS.md index 8fc7d20..cf4ddb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ machines whose "disk" is 20 GiB of tmpfs, next to a long tail of small repositor client: SSE envelope for the web UI, sideband band-2 lines for git. "Cloning into… and then nothing" is a bug. ### 1.3 Security contract (`Config::validate` fails closed) -- Three auth modes (`server.auth.mode`): **`none`** (everyone is `anon` with write and admin — `validate` refuses unless `server.listen` is loopback), +- Four auth modes (`server.auth.mode`): **`none`** (everyone is `anon` with write and admin — `validate` refuses unless `server.listen` is loopback), **`token`** (static tokens from the config, as `Authorization: Bearer` or an HTTP Basic password), **`oidc`** (any OpenID Connect issuer via discovery). In `oidc` mode `anonymous_read` must be false and an allowlist (`allowed_domains`/`allowed_emails`) must exist; three credentials are accepted — an ID token from the issuer @@ -83,7 +83,16 @@ machines whose "disk" is 20 GiB of tmpfs, next to a long tail of small repositor token** (`wgt_…`, HMAC-signed with `session_secret`, minted at `/_auth/tokens` by a signed-in browser, stateless, `access_token_ttl`; rotating the secret revokes all), and the HMAC **session cookie** set by `/_auth/login` → issuer → `/_auth/callback`. Static `tokens` work in `oidc` mode too (robots). Every path ends in the same - allowlist and `write_domains`. + allowlist and `write_domains`. **`proxy`** (D50): an identity-aware proxy in front authenticates and authorizes; + every request must carry `X-Walgit-Principal` (else 401) and `X-Walgit-Access: read|write|admin` (missing or + unknown → 403; admin ⊃ write ⊃ read; nothing in the config grants admin). The proxy proves itself on every + request with `X-Walgit-Proxy-Secret` = `$` (required, loopback listen included — a pod's + containers share loopback; trimmed like the header, ≥ 32 bytes, constant-time). Wrong/missing secret or a + repeated identity header → **403 naming the proxy, never 401** (the client's credential did not fail; a 401 + makes git erase it); an unresolvable secret fails startup. `anonymous_read` must be + false; `tokens`, `trusted_forwarders`, `admin_*` are refused. Optional `X-Walgit-Owners: [,…] | *` narrows + what exists: owner listings omit the rest, every route under their prefix answers the 404 of a missing + repository (never 403), their `…/repos` list is `[]`. None of these three headers is read in any other mode. - Open at the application (no credential): `/healthz`, `/readyz`, `/repos.js`, `/repos.mjs`, `/_auth/*` (the sign-in flow itself) and **`/services/public/*`** (data-free; today `install.sh` + `ca.pem`; everything else under it 404; never reads repo data or takes a bearer — test `public_lane_serves_only_the_installer_without_auth`). @@ -282,7 +291,7 @@ Unrelated constraints remain in force. The current design target and migration g - **D11** Too-large repos are served, not refused: remote reader for the web API; clones via bundle-uri; refs from the WAL. Object work returns 503 when remote objects are disabled or the repository is excluded from this host's serving placement (D30). -- **D12** Auth is `none` | `token` | `oidc` (§1.3). `oidc` is generic OpenID Connect through discovery; the +- **D12** Auth is `none` | `token` | `oidc` (§1.3; `proxy` added by D50). `oidc` is generic OpenID Connect through discovery; the walgit-issued access token (`wgt_…`, HMAC, stateless, `/_auth/tokens`) is the credential git uses, so no client needs a vendor CLI to mint tokens. An edge that wants to do auth itself uses `auth_request /_auth/check` (`deploy/nginx.conf.example`). @@ -487,6 +496,21 @@ full cold-read/resource acceptance gates listed in `docs/spec/README.md`. and candidate external-boundary proof remain separate obligations; local loose objects and retired download membership cannot justify retirement. See the cost and remaining-evidence rows in the linked docs. +- **D50 (2026-09-30): `proxy` mode — an identity-aware proxy is the authority, and must prove it.** Deployments + that already verify identity and decide access at a gateway (JWT verification, an external authorizer) need + walgit to take that verdict, not re-derive it. `none` + `X-Walgit-Principal` is not that: everyone is admin, + any loopback caller may name anyone, every name inherits write. `proxy` is explicit instead: principal and + access level are both required headers (no default, no anonymous, no config-granted admin); a shared secret + is the trust boundary on every listen address (a sidecar's loopback is shared by the whole pod, so reaching + it proves nothing), checked before any other header is read, and failing it is a 403 that names the proxy + (a proxy fault must not cost the user their stored credential); the proxy must strip the `X-Walgit-*` + identity headers clients send. The owner scope is a listing filter and a second wall — the proxy + still decides per repository — and answers like absence (404, `[]`) so it confirms nothing beyond itself. + Scope checks run once over all matched `{owner}/{repo}` routes (`web::owner_scope` as a `route_layer`) and + in `dispatch_route` for the fallback (git, LFS), so a new repository route inherits them. The principal name + is what `policy.json`, logs and push attribution see, as in every mode. A push broker behind proxy-mode + fronts keeps `token` mode (`trusted_forwarders`): the hop is walgit-to-walgit, not through the proxy. + ## 5. Working rules - **No backwards compatibility (pre-1.0, banner at top):** change the shape and delete the old one in the same diff --git a/README.md b/README.md index 1206f82..4895a53 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Read the [design](docs/PACKFILE_URI_DESIGN.md) and | **settings** | Per-repository config (maintenance, compaction, upstream follow) published into the WAL with history. | | **events** | A small bridge tails the WAL and POSTs ref events to a webhook, exactly-once per (repo, seq, ref) with a durable cursor. `docs/EVENTS.md`. | | **maintenance** | Checkpoints, geometric compaction, connectivity audits and repairs — one loop that computes the desired state from (config, WAL) every pass and does one bounded unit of the most important missing work. Manual `compact --base` rebuilds the base on a host with sufficient disk. | -| **auth** | `none` (loopback), `token` (static tokens), `oidc` (any OpenID Connect issuer: browser sign-in, ID tokens, and walgit-issued access tokens for git). `/services/public/install.sh` sets a developer's machine up in one idempotent command. | +| **auth** | `none` (loopback), `token` (static tokens), `oidc` (any OpenID Connect issuer: browser sign-in, ID tokens, and walgit-issued access tokens for git), `proxy` (behind an identity-aware proxy that asserts who and what). `/services/public/install.sh` sets a developer's machine up in one idempotent command. | | **stores** | S3 and S3-compatible (AWS, MinIO, rustfs, R2, Ceph, …) and GCS, first class; an in-memory store for tests. | ## How it works, briefly @@ -146,6 +146,7 @@ each repository one maintainer (placement globs) and you are done. | `none` | everyone is `anon` with write and admin — loopback experiments | nothing | | `token` | static `tokens` in the config (`token_env` reads the secret from the environment) | `Authorization: Bearer `, or the token as an HTTP Basic password | | `oidc` | any OpenID Connect issuer (`issuer`, `oauth_client_id/secret`, `allowed_domains`/`allowed_emails`): Google, Entra, Okta, Auth0, Keycloak, Dex, GitLab… | a **walgit access token**: sign in once in the browser, create one at `/_auth/tokens`, paste it into the installer. Stateless (HMAC with `session_secret`, `access_token_ttl`); rotating the secret revokes all. ID tokens from the issuer (`audiences`) and static `tokens` work too. | +| `proxy` | whoever an identity-aware proxy in front lets through: it asserts `X-Walgit-Principal`, `X-Walgit-Access` (`read`/`write`/`admin`) and optionally `X-Walgit-Owners`, and proves itself with `X-Walgit-Proxy-Secret` (`proxy_secret_env`, required — loopback too) | whatever the proxy accepts (its own tokens, mTLS, a session) — walgit never sees the credential | Developer setup is one idempotent command — `sh -c "$(curl -fsSL 'https://git.example.com/services/public/install.sh')"` — which stores the token in a file only the user can read, installs a tiny git credential helper (git ≥ 2.46: it @@ -172,7 +173,7 @@ crates/ walgit-store ObjectStore trait (CAS versions, conditional GET, range, compose); backends s3, gcs, memory; leases walgit-git bare repos on disk, receive-pack, pack ingest, refs ↔ packed-refs, advertisements, upload-pack drivers walgit-wal RepoHandle: sync levels, publish (group commit + CAS), checkpoints, log reader, remote reader, tasks - walgit-server axum: smart HTTP, LFS, auth (none/token/oidc), the maintainer loop, upstream follow, + walgit-server axum: smart HTTP, LFS, auth (none/token/oidc/proxy), the maintainer loop, upstream follow, web/ (API, UI, SDK routes, SSE), setup.rs (installer + recipes), events bridge walgit-config walgit.toml (+ WALGIT__ env overrides), per-repo settings merge, fail-closed validation walgit-cli `walgit serve|import|compact|wal|mirror|synth|config|repo`; `walgit-server` = `walgit serve` diff --git a/crates/walgit-config/src/lib.rs b/crates/walgit-config/src/lib.rs index e3cdaf1..55ac25a 100644 --- a/crates/walgit-config/src/lib.rs +++ b/crates/walgit-config/src/lib.rs @@ -190,6 +190,13 @@ pub struct AuthConfig { /// Pair with `oauth_client_secret`; both or neither. pub oauth_client_id: Option, pub oauth_client_secret: Option, + /// `proxy` mode: name of the environment variable holding the secret the identity-aware + /// proxy presents in `X-Walgit-Proxy-Secret` on every request (compared in constant time, + /// at least 32 bytes after trimming surrounding whitespace, so a trailing newline from a + /// secret file is harmless). Required in proxy mode, loopback listen included: in a + /// sidecar deployment every container in the pod shares the loopback interface, so + /// reaching the port does not identify the proxy. Never read in other modes. + pub proxy_secret_env: Option, } /// Prefix of access tokens walgit mints itself (`/_auth/tokens`): recognisable in logs and @@ -208,6 +215,12 @@ pub enum AuthMode { /// `OpenID` Connect: browser sign-in through the issuer, ID tokens as bearers, plus /// walgit-issued access tokens for git — and `tokens` for robots. Oidc, + /// An identity-aware proxy in front authenticates and authorizes every request and + /// asserts the result in headers: `X-Walgit-Principal` (who), `X-Walgit-Access` + /// (`read` | `write` | `admin`), optionally `X-Walgit-Owners` (which owners exist for + /// this caller). The proxy proves itself with `X-Walgit-Proxy-Secret` + /// (`proxy_secret_env`) on every request, loopback listen included. No anonymous access. + Proxy, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -831,6 +844,7 @@ impl Default for AuthConfig { access_token_ttl: Duration::from_hours(2160), oauth_client_id: None, oauth_client_secret: None, + proxy_secret_env: None, } } } @@ -1235,6 +1249,35 @@ impl Config { "server.auth.session_secret is required with oauth_client_id (it signs sessions and access tokens)" ); } + if a.mode == AuthMode::Proxy { + anyhow::ensure!( + !a.anonymous_read, + "server.auth.anonymous_read must be false in proxy mode (the proxy names every caller)" + ); + // Anything that would let a request in without the proxy — or grant more than + // the proxy asserted — is refused rather than silently ignored. + anyhow::ensure!( + a.tokens.is_empty() && a.trusted_forwarders.is_empty(), + "server.auth.tokens and trusted_forwarders are not read in proxy mode (the proxy asserts every identity); remove them" + ); + anyhow::ensure!( + a.admin_emails.is_empty() && a.admin_domains.is_empty(), + "server.auth.admin_emails/admin_domains are not read in proxy mode (admin comes from `X-Walgit-Access: admin`); remove them" + ); + // The trust boundary: anyone who can reach the port could send the identity + // headers — on loopback too, where every container of a pod shares the interface — + // so the proxy must prove itself with a shared secret. + anyhow::ensure!( + a.proxy_secret_env.as_deref().is_some_and(|v| !v.is_empty()), + "server.auth.proxy_secret_env is required in proxy mode (a loopback listen is shared by every process in the network namespace, so it does not identify the proxy)" + ); + } else { + anyhow::ensure!( + a.proxy_secret_env.is_none(), + "server.auth.proxy_secret_env is only read in proxy mode (got mode = {:?})", + a.mode + ); + } anyhow::ensure!(self.wal.max_batch >= 1, "wal.max_batch must be >= 1"); if let Some(u) = &self.events.webhook_url { anyhow::ensure!( @@ -1665,6 +1708,59 @@ audiences = ["walgit-cli", "https://git.example.com"] assert_eq!(tok.server.auth.issuer, ""); } + #[test] + fn proxy_mode_needs_a_secret_and_nothing_else_that_grants_access() { + let parse = |server: &str, auth: &str| { + Config::parse(&format!( + "[store]\nbucket = \"b\"\n[server]\n{server}\n[server.auth]\nmode = \"proxy\"\nanonymous_read = false\n{auth}\n" + )) + }; + // Without a secret anything that can reach the port could name any caller: a public + // bind, and loopback too (the sidecar shape — every container of the pod shares it). + for listen in ["0.0.0.0:8080", "127.0.0.1:8080", "[::1]:8080"] { + for secret in ["", "proxy_secret_env = \"\""] { + let err = parse(&format!("listen = \"{listen}\""), secret).unwrap_err(); + assert!( + err.to_string().contains("proxy_secret_env"), + "{listen}: {err}" + ); + } + } + for listen in ["0.0.0.0:8080", "127.0.0.1:8080"] { + let ok = parse( + &format!("listen = \"{listen}\""), + "proxy_secret_env = \"WALGIT_PROXY_SECRET\"", + ) + .unwrap(); + assert_eq!(ok.server.auth.mode, AuthMode::Proxy); + assert_eq!( + ok.server.auth.proxy_secret_env.as_deref(), + Some("WALGIT_PROXY_SECRET") + ); + } + // No anonymous access, and no second way in or implicit admin beside the proxy. + let err = Config::parse("[store]\nbucket = \"b\"\n[server.auth]\nmode = \"proxy\"\n") + .unwrap_err(); + assert!(err.to_string().contains("anonymous_read"), "{err}"); + for extra in [ + "tokens = [{ principal = \"ci\", token = \"s\" }]", + "trusted_forwarders = [\"front\"]", + "admin_emails = [\"a@example.com\"]", + "admin_domains = [\"example.com\"]", + ] { + let err = parse("", &format!("proxy_secret_env = \"S\"\n{extra}")).unwrap_err(); + assert!( + !err.to_string().contains("proxy_secret_env"), + "{extra}: {err}" + ); + } + // The secret is a proxy-mode key only. + let err = + Config::parse("[store]\nbucket = \"b\"\n[server.auth]\nproxy_secret_env = \"X\"\n") + .unwrap_err(); + assert!(err.to_string().contains("only read in proxy mode"), "{err}"); + } + #[test] fn events_section_parses_and_validates() { let c = Config::parse( diff --git a/crates/walgit-server/src/admin.rs b/crates/walgit-server/src/admin.rs index d9f2863..0d13b5e 100644 --- a/crates/walgit-server/src/admin.rs +++ b/crates/walgit-server/src/admin.rs @@ -52,10 +52,11 @@ pub async fn delete( /// `GET /` — list repos as text/plain, one `owner/name` per line. pub async fn list_repos(st: &AppState, headers: &HeaderMap) -> Result { - let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + let principal = st.auth.require_read(headers).await.map_err(auth_err)?; let repos = st.registry.list().await.map_err(wal_err)?; let body = repos .into_iter() + .filter(|r| principal.sees_owner(r.owner())) .map(|r| r.to_string()) .collect::>() .join("\n"); @@ -76,6 +77,7 @@ fn auth_err(e: crate::auth::AuthError) -> ApiError { ApiError::Unauthorized } crate::auth::AuthError::Forbidden => ApiError::Forbidden, + crate::auth::AuthError::UntrustedProxy => ApiError::UntrustedProxy, crate::auth::AuthError::Unavailable => { ApiError::ServiceUnavailable("auth provider unavailable".into()) } diff --git a/crates/walgit-server/src/auth.rs b/crates/walgit-server/src/auth.rs index fe8619a..ee459d3 100644 --- a/crates/walgit-server/src/auth.rs +++ b/crates/walgit-server/src/auth.rs @@ -1,5 +1,5 @@ -//! Authentication: `none` / `token` / `oidc`. Resolves a request to a -//! [`Principal`] (name + write bit). +//! Authentication: `none` / `token` / `oidc` / `proxy`. Resolves a request to a +//! [`Principal`] (name, write/admin bits, owner scope). //! //! * **`token`** — static tokens from the config, presented as `Authorization: //! Bearer ` or as the password of HTTP Basic (any user name). @@ -14,6 +14,13 @@ //! Static `tokens` are honoured in this mode too (robots, CI). //! Every path ends in the same allowlist: `allowed_domains` / `allowed_emails`, //! `write_domains`. +//! * **`proxy`** — an identity-aware proxy in front has already authenticated and +//! authorized the caller and says so in headers (D50): `X-Walgit-Principal` (who), +//! `X-Walgit-Access` (`read` | `write` | `admin`), optionally `X-Walgit-Owners` (the +//! owners that exist for this caller). The proxy proves itself on every request with +//! `X-Walgit-Proxy-Secret`, loopback listen included. A request that fails that proof +//! is a 403 naming the proxy, never a 401: the client's credential was not the one +//! rejected, and a 401 makes git erase it. These headers are read in no other mode. //! //! An edge in front of walgit may take the client's `Authorization` for its own //! hop credential; it then announces `client-authorization` in @@ -35,6 +42,19 @@ use serde::Deserialize; use tokio::sync::Mutex; use walgit_config::{ACCESS_TOKEN_PREFIX, AuthMode, StaticToken}; +/// End-user identity: set by a trusted forwarder (push broker hop), by anyone in `none` +/// mode, and by the proxy in `proxy` mode. +pub const PRINCIPAL_HEADER: &str = "x-walgit-principal"; +/// `proxy` mode: the caller's access level as the proxy decided it — `read`, `write` +/// (implies read) or `admin` (implies write). +pub const PROXY_ACCESS_HEADER: &str = "x-walgit-access"; +/// `proxy` mode: `[,…]` or `*`. Absent = every owner. +pub const PROXY_OWNERS_HEADER: &str = "x-walgit-owners"; +/// `proxy` mode: the shared secret named by `server.auth.proxy_secret_env`. +pub const PROXY_SECRET_HEADER: &str = "x-walgit-proxy-secret"; +/// Shortest accepted proxy secret (the same floor as `session_secret`), counted after +/// surrounding whitespace is trimmed. +const MIN_PROXY_SECRET_BYTES: usize = 32; /// Client `Authorization` as copied by an edge before it replaces that header with its own /// hop credential. Read only when the edge announces `client-authorization`. pub const FORWARDED_AUTHORIZATION_HEADER: &str = "x-walgit-authorization"; @@ -59,6 +79,8 @@ pub struct Principal { /// Independent of `write` (push and repository creation). pub admin: bool, pub anonymous: bool, + /// Owners this principal may address at all ([`OwnerScope::All`] outside `proxy` mode). + pub owners: OwnerScope, } impl Principal { @@ -68,8 +90,138 @@ impl Principal { write: false, admin: false, anonymous: true, + owners: OwnerScope::All, } } + + /// Whether `owner` exists for this principal (listings, and every route under it). + pub fn sees_owner(&self, owner: &str) -> bool { + self.owners.contains(owner) + } +} + +/// The owners a principal may address. Only `proxy` mode narrows it (`X-Walgit-Owners`): +/// an owner outside the list does not exist for the caller — listings omit it and every +/// route under its prefix answers 404, exactly like an owner without repositories. Never +/// 403: the answer must not confirm that the owner exists. The proxy remains the +/// authority for per-repository decisions; this is the listing filter and a second wall. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum OwnerScope { + #[default] + All, + /// Exact (case-sensitive, like the bucket prefix) owner names. Empty = none. + Only(Vec), +} + +impl OwnerScope { + pub fn contains(&self, owner: &str) -> bool { + match self { + OwnerScope::All => true, + OwnerScope::Only(owners) => owners.iter().any(|o| o == owner), + } + } + + /// Parse an `X-Walgit-Owners` value: `*`, or comma-separated owner names (blank entries + /// skipped, so an empty value is the empty scope). A malformed entry refuses the whole + /// header rather than dropping it: a proxy that sends garbage is misconfigured, and + /// guessing which part it meant is how a scope widens. + fn parse(value: &str) -> Option { + if value.trim() == "*" { + return Some(OwnerScope::All); + } + let mut owners = Vec::new(); + for owner in value.split(',').map(str::trim).filter(|o| !o.is_empty()) { + // Owner names follow the repository-id rules; the name half is a placeholder. + walgit_git::RepoId::new(owner, "_").ok()?; + owners.push(owner.to_string()); + } + Some(OwnerScope::Only(owners)) + } +} + +/// How a `proxy`-mode request proves it came from the proxy. There is no loopback +/// exemption: in a sidecar deployment every container of the pod shares the network +/// namespace, so "can reach 127.0.0.1" names the pod, not the proxy. +enum ProxyTrust { + /// SHA-256 of the shared secret. The presented value is hashed too and the digests are + /// compared in constant time, so neither length nor prefix leaks by timing. + Secret([u8; 32]), + /// The secret could not be resolved (none configured, or an unset, blank or short + /// variable): every request is refused. + Refuse, +} + +/// `server.auth.proxy_secret_env` resolved through `env`: `Ok(None)` outside `proxy` +/// mode, `Ok(Some(secret))` in it, `Err` when none is configured or the named variable +/// is unset, blank or shorter than 32 bytes. The value is trimmed the way the header +/// value is (`single_header`): a secret file or Kubernetes `Secret` ending in a newline +/// must match the header the proxy sends, and surrounding whitespace can never travel +/// in a header value anyway. Startup calls this with the process environment so a +/// missing secret fails the boot instead of refusing every request behind a green +/// `/readyz`. +pub fn resolve_proxy_secret( + auth: &walgit_config::AuthConfig, + env: &dyn Fn(&str) -> Option, +) -> Result, String> { + if auth.mode != AuthMode::Proxy { + return Ok(None); + } + let Some(var) = auth.proxy_secret_env.as_deref().filter(|v| !v.is_empty()) else { + return Err("server.auth.proxy_secret_env is required in proxy mode".to_string()); + }; + let value = env(var).unwrap_or_default(); + let value = value.trim(); + if value.is_empty() { + return Err(format!( + "server.auth.proxy_secret_env: ${var} is unset or blank" + )); + } + if value.len() < MIN_PROXY_SECRET_BYTES { + return Err(format!( + "server.auth.proxy_secret_env: ${var} must be at least {MIN_PROXY_SECRET_BYTES} bytes (after trimming whitespace)" + )); + } + Ok(Some(value.to_string())) +} + +fn process_env(name: &str) -> Option { + std::env::var(name).ok() +} + +fn proxy_trust(cfg: &walgit_config::Config, env: &dyn Fn(&str) -> Option) -> ProxyTrust { + use sha2::Digest; + if cfg.server.auth.mode != AuthMode::Proxy { + return ProxyTrust::Refuse; + } + match resolve_proxy_secret(&cfg.server.auth, env) { + Ok(Some(secret)) => ProxyTrust::Secret(sha2::Sha256::digest(secret.as_bytes()).into()), + // Unreachable in proxy mode; an authenticator built from an unvalidated config must + // not be the one place a missing secret is honoured. + Ok(None) => ProxyTrust::Refuse, + Err(e) => { + tracing::error!(error = %e, "proxy secret unavailable; refusing every request"); + ProxyTrust::Refuse + } + } +} + +/// Constant-time equality of two SHA-256 digests. +fn digests_equal(a: &[u8; 32], b: &[u8; 32]) -> bool { + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// The single value of `name`: `Ok(None)` when absent, `Err` when repeated or not visible +/// ASCII. A repeated identity header means something between the client and walgit +/// appended instead of replacing — the request is not trusted to mean either value. +fn single_header<'h>(headers: &'h HeaderMap, name: &str) -> Result, ()> { + let mut values = headers.get_all(name).iter(); + let Some(first) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(()); + } + first.to_str().map(|v| Some(v.trim())).map_err(|_| ()) } /// A JWKS public key: RSA or EC P-256. @@ -379,6 +531,8 @@ pub struct Authenticator { access_token_ttl: Duration, oauth_client_id: Option, oauth_client_secret: Option, + /// `proxy` mode's trust boundary (`Refuse` in every other mode, where it is never read). + proxy_trust: ProxyTrust, } impl Authenticator { @@ -388,19 +542,20 @@ impl Authenticator { issuer: cfg.server.auth.issuer.trim_end_matches('/').to_string(), discovery: Mutex::new(None), }); - Self::build(cfg, source.clone(), Some(source)) + Self::build(cfg, source.clone(), Some(source), &process_env) } /// Construct an authenticator with an injectable JWKS source (tests: no network; the /// browser sign-in endpoints are unavailable). pub fn with_key_source(cfg: &walgit_config::Config, keys: Arc) -> Arc { - Self::build(cfg, keys, None) + Self::build(cfg, keys, None, &process_env) } fn build( cfg: &walgit_config::Config, keys: Arc, discovery: Option>, + env: &dyn Fn(&str) -> Option, ) -> Arc { let auth = &cfg.server.auth; let oauth_client_id = auth.oauth_client_id.clone().filter(|s| !s.is_empty()); @@ -456,6 +611,7 @@ impl Authenticator { access_token_ttl: auth.access_token_ttl, oauth_client_id, oauth_client_secret: auth.oauth_client_secret.clone().filter(|s| !s.is_empty()), + proxy_trust: proxy_trust(cfg, env), }) } @@ -622,9 +778,13 @@ impl Authenticator { &self, headers: &HeaderMap, ) -> Result { + // The proxy's principal header is the identity itself, not a forwarding claim. + if self.mode == AuthMode::Proxy { + return self.authenticate_proxy(headers); + } let caller = self.authenticate_inner(headers).await?; let Some(forwarded) = headers - .get("x-walgit-principal") + .get(PRINCIPAL_HEADER) .and_then(|v| v.to_str().ok()) .map(str::trim) .filter(|v| !v.is_empty()) @@ -643,11 +803,74 @@ impl Authenticator { write: caller.write, admin: self.is_admin(forwarded), anonymous: false, + owners: OwnerScope::All, }); } Ok(caller) } + /// `proxy` mode: the proxy proves itself first (nothing else it says is read before + /// that; failing is [`AuthError::UntrustedProxy`], a 403 — the client's credential is + /// not what was rejected), then names the caller (401 without one: there is no + /// anonymous access) and its access level (403 when missing or unknown — fail closed, + /// never a default). Admin comes only from the proxy; nothing in the config grants it. + fn authenticate_proxy(&self, headers: &HeaderMap) -> Result { + let result = self.proxy_principal(headers); + if matches!(result, Err(AuthError::UntrustedProxy)) { + // Operator-facing: every request through a misconfigured proxy fails this way. + tracing::warn!( + secret_present = headers.contains_key(PROXY_SECRET_HEADER), + "request did not prove it came through the proxy (X-Walgit-Proxy-Secret missing, wrong or repeated, or a repeated identity header): a misconfigured proxy, or a caller bypassing it" + ); + } + result + } + + /// [`Self::authenticate_proxy`] without logging (also asked by [`Self::hides_owner`]). + fn proxy_principal(&self, headers: &HeaderMap) -> Result { + use sha2::Digest; + match &self.proxy_trust { + ProxyTrust::Secret(expected) => { + let presented = single_header(headers, PROXY_SECRET_HEADER) + .map_err(|()| AuthError::UntrustedProxy)?; + let digest: [u8; 32] = sha2::Sha256::digest(presented.unwrap_or("")).into(); + if presented.is_none() || !digests_equal(&digest, expected) { + return Err(AuthError::UntrustedProxy); + } + } + ProxyTrust::Refuse => return Err(AuthError::UntrustedProxy), + } + // A repeated principal means the proxy appended instead of replacing a header the + // client sent: the proxy's fault, so not a 401 either. + let name = single_header(headers, PRINCIPAL_HEADER) + .map_err(|()| AuthError::UntrustedProxy)? + .filter(|v| !v.is_empty()) + .ok_or(AuthError::Unauthorized)?; + let access = + single_header(headers, PROXY_ACCESS_HEADER).map_err(|()| AuthError::Forbidden)?; + let (write, admin) = match access.map(str::to_ascii_lowercase).as_deref() { + Some("read") => (false, false), + Some("write") => (true, false), + Some("admin") => (true, true), + other => { + tracing::debug!(access = ?other, "proxy access level missing or unknown"); + return Err(AuthError::Forbidden); + } + }; + let owners = match single_header(headers, PROXY_OWNERS_HEADER) { + Ok(None) => OwnerScope::All, + Ok(Some(v)) => OwnerScope::parse(v).ok_or(AuthError::Forbidden)?, + Err(()) => return Err(AuthError::Forbidden), + }; + Ok(Principal { + name: name.to_string(), + write, + admin, + anonymous: false, + owners, + }) + } + /// A static token or an issued access token, from a bearer or a Basic password. fn opaque_token_principal(&self, tok: &str) -> Option> { if let Some(st) = self.tokens.iter().find(|t| t.token == tok) { @@ -656,6 +879,7 @@ impl Authenticator { write: st.write, admin: st.admin, anonymous: false, + owners: OwnerScope::All, })); } if tok.starts_with(ACCESS_TOKEN_PREFIX) { @@ -674,6 +898,7 @@ impl Authenticator { write: true, admin: true, anonymous: false, + owners: OwnerScope::All, }), AuthMode::Token => { let presented = @@ -701,9 +926,22 @@ impl Authenticator { self.authenticate_cookie(headers) .ok_or(AuthError::Unauthorized) } + // `authenticate_with_forwarding` answers proxy mode before reaching here. + AuthMode::Proxy => self.authenticate_proxy(headers), } } + /// Whether `owner` is outside the caller's owner scope: `proxy` mode with an + /// `X-Walgit-Owners` list that does not name it. A request that does not authenticate + /// is not hidden here — its handler answers with its own 401/403, so the credential + /// story (git's `erase` on a real 401, the in-band help) stays in one place. + pub fn hides_owner(&self, headers: &HeaderMap, owner: &str) -> bool { + self.mode == AuthMode::Proxy + && self + .proxy_principal(headers) + .is_ok_and(|p| !p.sees_owner(owner)) + } + /// Require a principal with `write` for git push / LFS upload / repo create. pub async fn require_write(&self, headers: &HeaderMap) -> Result { let p = self.authenticate(headers).await?; @@ -821,6 +1059,7 @@ impl Authenticator { write, admin: self.is_admin(&email), anonymous: false, + owners: OwnerScope::All, }) } } @@ -851,13 +1090,19 @@ pub enum AuthError { Unauthorized, Forbidden, Unavailable, + /// `proxy` mode: the request did not prove it came through the proxy (secret missing, + /// wrong or repeated; a repeated identity header). A 403 naming the proxy, never a + /// 401: what failed is the proxy's configuration, not the client's credential, and a + /// 401 is what makes git erase that credential (§1.3). Not a 5xx either: a proxy + /// retries 5xx from its upstream, and a caller bypassing the proxy is simply refused. + UntrustedProxy, } impl AuthError { pub fn status(&self) -> StatusCode { match self { AuthError::Invalid | AuthError::Unauthorized => StatusCode::UNAUTHORIZED, - AuthError::Forbidden => StatusCode::FORBIDDEN, + AuthError::Forbidden | AuthError::UntrustedProxy => StatusCode::FORBIDDEN, AuthError::Unavailable => StatusCode::SERVICE_UNAVAILABLE, } } @@ -1410,6 +1655,356 @@ GcZ0izY/30012ajdHY+/QK5lsMoxTnn0skdS+spLxaS5ZEO4qvPVb8RAoCkWMMal } } +#[cfg(test)] +mod proxy_tests { + use super::*; + + const SECRET: &str = "0123456789abcdef0123456789abcdef-proxy-secret"; + /// `SECRET` as a secret file usually holds it. + const SECRET_NEWLINE: &str = "0123456789abcdef0123456789abcdef-proxy-secret\n"; + + struct NoKeys; + #[async_trait] + impl JwksSource for NoKeys { + async fn fetch(&self) -> Result { + Err("no keys in proxy mode".into()) + } + } + + fn proxy_config(listen: &str, secret_env: Option<&str>) -> walgit_config::Config { + let mut cfg = walgit_config::Config::default(); + cfg.server.listen = listen.parse().unwrap(); + cfg.server.auth.mode = AuthMode::Proxy; + cfg.server.auth.anonymous_read = false; + cfg.server.auth.proxy_secret_env = secret_env.map(str::to_string); + cfg + } + + /// An authenticator whose environment holds `WALGIT_PROXY_SECRET = value`. + fn proxy_auth(cfg: &walgit_config::Config, value: Option<&str>) -> Arc { + let value = value.map(str::to_string); + let env = move |name: &str| (name == "WALGIT_PROXY_SECRET").then(|| value.clone())?; + Authenticator::build(cfg, Arc::new(NoKeys), None, &env) + } + + /// The sidecar shape: loopback listen, and the secret all the same. + fn sidecar() -> Arc { + proxy_auth( + &proxy_config("127.0.0.1:8080", Some("WALGIT_PROXY_SECRET")), + Some(SECRET), + ) + } + + /// Exactly these headers. + fn bare(pairs: &[(&str, &str)]) -> HeaderMap { + let mut h = HeaderMap::new(); + for (k, v) in pairs { + h.append( + axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(), + v.parse().unwrap(), + ); + } + h + } + + /// These headers from the proxy: with its secret. + fn asserted(pairs: &[(&str, &str)]) -> HeaderMap { + let mut h = bare(pairs); + h.insert(PROXY_SECRET_HEADER, SECRET.parse().unwrap()); + h + } + + #[tokio::test] + async fn the_proxy_names_the_caller_and_its_access_level() { + let auth = sidecar(); + for (access, write, admin) in [ + ("read", false, false), + ("write", true, false), + ("admin", true, true), + (" Admin ", true, true), + ] { + let h = asserted(&[ + (PRINCIPAL_HEADER, "dev@example.com"), + (PROXY_ACCESS_HEADER, access), + ]); + let p = auth.authenticate(&h).await.unwrap(); + assert_eq!( + (p.name.as_str(), p.write, p.admin, p.anonymous), + ("dev@example.com", write, admin, false), + "{access}" + ); + assert_eq!(p.owners, OwnerScope::All, "no header = every owner"); + } + let read = asserted(&[(PRINCIPAL_HEADER, "r"), (PROXY_ACCESS_HEADER, "read")]); + auth.require_read(&read).await.unwrap(); + assert!(matches!( + auth.require_write(&read).await, + Err(AuthError::Forbidden) + )); + let write = asserted(&[(PRINCIPAL_HEADER, "w"), (PROXY_ACCESS_HEADER, "write")]); + assert!(matches!( + auth.require_admin(&write).await, + Err(AuthError::Forbidden) + )); + + // No principal (or a blank one) is a 401: there is no anonymous access. + for h in [ + asserted(&[(PROXY_ACCESS_HEADER, "admin")]), + asserted(&[(PRINCIPAL_HEADER, " "), (PROXY_ACCESS_HEADER, "admin")]), + asserted(&[]), + ] { + assert!(matches!( + auth.require_read(&h).await, + Err(AuthError::Unauthorized) + )); + } + // A missing or unknown access level is a 403, never a default. + for access in [None, Some(""), Some("owner"), Some("read,write")] { + let mut h = asserted(&[(PRINCIPAL_HEADER, "dev@example.com")]); + if let Some(a) = access { + h.insert(PROXY_ACCESS_HEADER, a.parse().unwrap()); + } + assert!( + matches!(auth.authenticate(&h).await, Err(AuthError::Forbidden)), + "{access:?}" + ); + } + // Appended rather than replaced: neither value is trusted, and the fault is the + // proxy's (a 403 naming it), not the client credential's (a 401 would erase it). + let twice = asserted(&[ + (PRINCIPAL_HEADER, "a"), + (PRINCIPAL_HEADER, "b"), + (PROXY_ACCESS_HEADER, "read"), + ]); + assert!(matches!( + auth.authenticate(&twice).await, + Err(AuthError::UntrustedProxy) + )); + let twice = asserted(&[ + (PRINCIPAL_HEADER, "a"), + (PROXY_ACCESS_HEADER, "read"), + (PROXY_ACCESS_HEADER, "admin"), + ]); + assert!(matches!( + auth.authenticate(&twice).await, + Err(AuthError::Forbidden) + )); + // Config-granted admin does not exist in proxy mode, `none`'s implicit one included. + assert!(!auth.is_admin("dev@example.com")); + } + + #[tokio::test] + async fn the_proxy_proves_itself_with_the_shared_secret() { + let cfg = proxy_config("0.0.0.0:8080", Some("WALGIT_PROXY_SECRET")); + let auth = proxy_auth(&cfg, Some(SECRET)); + let identity = [ + (PRINCIPAL_HEADER, "dev@example.com"), + (PROXY_ACCESS_HEADER, "admin"), + ]; + let with = |secret: &[&str]| { + let mut h = bare(&identity); + for s in secret { + h.append(PROXY_SECRET_HEADER, s.parse().unwrap()); + } + h + }; + assert!(auth.authenticate(&with(&[SECRET])).await.unwrap().admin); + for bad in [ + &[][..], + &[""][..], + &["wrong"][..], + &[&SECRET[1..]][..], + &[SECRET, SECRET][..], + ] { + let err = auth.authenticate(&with(bad)).await.unwrap_err(); + assert!(matches!(err, AuthError::UntrustedProxy), "{bad:?}: {err:?}"); + // Never a 401: git would erase the client's stored credential, which is not + // what failed. + assert_eq!(err.status(), StatusCode::FORBIDDEN, "{bad:?}"); + } + // A bad secret is refused before any other header is read. + let mut h = with(&["wrong"]); + h.remove(PROXY_ACCESS_HEADER); + h.remove(PRINCIPAL_HEADER); + assert!(matches!( + auth.authenticate(&h).await, + Err(AuthError::UntrustedProxy) + )); + + // Loopback (the sidecar shape) requires it too: the whole pod shares loopback. + let auth = sidecar(); + assert!(auth.authenticate(&with(&[SECRET])).await.is_ok()); + assert!(matches!( + auth.authenticate(&bare(&identity)).await, + Err(AuthError::UntrustedProxy) + )); + + // A secret file / Kubernetes Secret ending in a newline (or padded) still matches + // the header, which arrives trimmed. + for stored in [ + format!("{SECRET}\n"), + format!("{SECRET}\r\n"), + format!(" {SECRET} \n"), + ] { + let auth = proxy_auth(&cfg, Some(stored.as_str())); + assert!( + auth.authenticate(&with(&[SECRET])).await.is_ok(), + "{stored:?}" + ); + assert!(matches!( + auth.authenticate(&with(&[&SECRET[1..]])).await, + Err(AuthError::UntrustedProxy) + )); + } + + // Unresolvable secret, or none configured (loopback included): nothing is trusted. + for (cfg, value) in [ + (cfg.clone(), None), + (cfg.clone(), Some("")), + (cfg.clone(), Some("\n")), + (cfg.clone(), Some("short")), + (proxy_config("0.0.0.0:8080", None), None), + (proxy_config("127.0.0.1:8080", None), None), + ] { + let auth = proxy_auth(&cfg, value); + assert!( + matches!( + auth.authenticate(&with(&[SECRET])).await, + Err(AuthError::UntrustedProxy) + ), + "{value:?}" + ); + assert!(matches!( + auth.authenticate(&with(&[])).await, + Err(AuthError::UntrustedProxy) + )); + } + } + + #[test] + fn the_secret_resolves_at_startup_or_fails_it() { + let cfg = proxy_config("0.0.0.0:8080", Some("WALGIT_PROXY_SECRET")); + let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_string); + assert_eq!( + resolve_proxy_secret(&cfg.server.auth, &env(Some(SECRET))) + .unwrap() + .as_deref(), + Some(SECRET) + ); + // Trimmed like the header: a trailing newline is not part of the secret. + assert_eq!( + resolve_proxy_secret(&cfg.server.auth, &env(Some(SECRET_NEWLINE))) + .unwrap() + .as_deref(), + Some(SECRET) + ); + for (value, why) in [ + (None, "unset"), + (Some(""), "unset"), + (Some("\n"), "blank"), + (Some(" \t\r\n"), "blank"), + (Some("short"), "32 bytes"), + // 31 bytes once the newline is trimmed. + (Some("0123456789abcdef0123456789abcde\n"), "32 bytes"), + ] { + let err = resolve_proxy_secret(&cfg.server.auth, &env(value)).unwrap_err(); + assert!(err.contains(why), "{value:?}: {err}"); + } + // Required in proxy mode, loopback included; not read in any other mode. + let loopback = proxy_config("127.0.0.1:8080", None); + let err = resolve_proxy_secret(&loopback.server.auth, &env(Some(SECRET))).unwrap_err(); + assert!(err.contains("required"), "{err}"); + let token = walgit_config::Config::default(); + assert_eq!( + resolve_proxy_secret(&token.server.auth, &env(None)), + Ok(None) + ); + } + + #[tokio::test] + async fn owner_scope_narrows_what_exists_for_the_caller() { + assert_eq!(OwnerScope::parse("*"), Some(OwnerScope::All)); + assert_eq!(OwnerScope::parse(" * "), Some(OwnerScope::All)); + assert_eq!( + OwnerScope::parse("acme, tools-2,,"), + Some(OwnerScope::Only(vec!["acme".into(), "tools-2".into()])) + ); + assert_eq!(OwnerScope::parse(""), Some(OwnerScope::Only(vec![]))); + for bad in ["*,acme", "acme/app", "../x", ".hidden", "a b"] { + assert_eq!(OwnerScope::parse(bad), None, "{bad}"); + } + let only = OwnerScope::Only(vec!["acme".into()]); + assert!(only.contains("acme") && !only.contains("Acme") && !only.contains("other")); + assert!(!OwnerScope::Only(vec![]).contains("acme")); + + let auth = sidecar(); + let scoped = |owners: &str| { + asserted(&[ + (PRINCIPAL_HEADER, "dev@example.com"), + (PROXY_ACCESS_HEADER, "read"), + (PROXY_OWNERS_HEADER, owners), + ]) + }; + let p = auth.authenticate(&scoped("acme,tools")).await.unwrap(); + assert!(p.sees_owner("acme") && p.sees_owner("tools") && !p.sees_owner("other")); + assert!(auth.hides_owner(&scoped("acme"), "other")); + assert!(!auth.hides_owner(&scoped("acme"), "acme")); + assert!(!auth.hides_owner(&scoped("*"), "other")); + assert!(auth.hides_owner(&scoped(""), "acme"), "empty = no owners"); + assert!(matches!( + auth.authenticate(&scoped("acme/app")).await, + Err(AuthError::Forbidden) + )); + // Unauthenticated requests are not hidden: their handler answers 401/403. + assert!(!auth.hides_owner(&asserted(&[(PROXY_OWNERS_HEADER, "acme")]), "other")); + } + + /// The proxy's headers mean nothing in any other mode: no access level, no scope, no + /// secret is read, whoever sends them. + #[tokio::test] + async fn proxy_headers_are_ignored_outside_proxy_mode() { + let forged = [ + (PROXY_ACCESS_HEADER, "admin"), + (PROXY_OWNERS_HEADER, "nobody"), + (PROXY_SECRET_HEADER, SECRET), + ]; + + let mut cfg = walgit_config::Config::default(); + cfg.server.auth.mode = AuthMode::Token; + cfg.server.auth.tokens = vec![StaticToken { + principal: "alice".into(), + token: "s3cret".into(), + token_env: None, + write: false, + admin: false, + }]; + let auth = proxy_auth(&cfg, Some(SECRET)); + let mut h = bare(&forged); + h.insert( + axum::http::header::AUTHORIZATION, + "Bearer s3cret".parse().unwrap(), + ); + h.insert(PRINCIPAL_HEADER, "root@example.com".parse().unwrap()); + let p = auth.authenticate(&h).await.unwrap(); + assert_eq!( + (p.name.as_str(), p.write, p.admin, &p.owners), + ("alice", false, false, &OwnerScope::All), + "no trusted forwarder, so not even the principal header is read" + ); + assert!(!auth.hides_owner(&h, "acme")); + let anon = auth.authenticate(&bare(&forged)).await.unwrap(); + assert!(anon.anonymous && !anon.write && !anon.admin); + + // `none` keeps its own (loopback-only) forwarding, but never the proxy's scope. + let auth = proxy_auth(&walgit_config::Config::default(), Some(SECRET)); + let mut h = bare(&forged); + h.insert(PROXY_ACCESS_HEADER, "read".parse().unwrap()); + let p = auth.authenticate(&h).await.unwrap(); + assert_eq!((p.write, &p.owners), (true, &OwnerScope::All)); + assert!(!auth.hides_owner(&h, "acme")); + } +} + #[cfg(test)] mod session_tests { use super::*; diff --git a/crates/walgit-server/src/bridge.rs b/crates/walgit-server/src/bridge.rs index 349ff18..d4b4a95 100644 --- a/crates/walgit-server/src/bridge.rs +++ b/crates/walgit-server/src/bridge.rs @@ -348,6 +348,7 @@ fn auth_err(e: crate::auth::AuthError) -> crate::error::ApiError { ApiError::Unauthorized } crate::auth::AuthError::Forbidden => ApiError::Forbidden, + crate::auth::AuthError::UntrustedProxy => ApiError::UntrustedProxy, crate::auth::AuthError::Unavailable => { ApiError::ServiceUnavailable("auth provider unavailable".into()) } diff --git a/crates/walgit-server/src/error.rs b/crates/walgit-server/src/error.rs index ad76313..d08c476 100644 --- a/crates/walgit-server/src/error.rs +++ b/crates/walgit-server/src/error.rs @@ -6,12 +6,21 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; +/// Body of [`ApiError::UntrustedProxy`] (and of the in-band git `ERR` for it). +pub const UNTRUSTED_PROXY_MESSAGE: &str = "forbidden: the request did not come through walgit's identity-aware proxy \ + (X-Walgit-Proxy-Secret missing or wrong): the proxy in front of walgit is misconfigured, or the request \ + bypassed it. Your credential was not rejected; the operator must fix the proxy"; + #[derive(Debug)] pub enum ApiError { NotFound(String), BadRequest(String), Unauthorized, Forbidden, + /// `proxy` mode: the request did not prove it came through the proxy + /// (`AuthError::UntrustedProxy`). 403, never 401: git erases the client's stored + /// credential on a 401, and the credential is not what failed. + UntrustedProxy, Conflict(String), PayloadTooLarge, UnsupportedMediaType(String), @@ -25,7 +34,7 @@ impl ApiError { ApiError::NotFound(_) => StatusCode::NOT_FOUND, ApiError::BadRequest(_) => StatusCode::BAD_REQUEST, ApiError::Unauthorized => StatusCode::UNAUTHORIZED, - ApiError::Forbidden => StatusCode::FORBIDDEN, + ApiError::Forbidden | ApiError::UntrustedProxy => StatusCode::FORBIDDEN, ApiError::Conflict(_) => StatusCode::CONFLICT, ApiError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, ApiError::UnsupportedMediaType(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, @@ -43,6 +52,7 @@ impl ApiError { ApiError::BadRequest(m) => format!("bad request: {m}"), ApiError::Unauthorized => "unauthorized".to_string(), ApiError::Forbidden => "forbidden".to_string(), + ApiError::UntrustedProxy => UNTRUSTED_PROXY_MESSAGE.to_string(), ApiError::Conflict(m) => format!("conflict: {m}"), ApiError::PayloadTooLarge => "payload too large".to_string(), ApiError::UnsupportedMediaType(m) => format!("unsupported media type: {m}"), diff --git a/crates/walgit-server/src/lfs.rs b/crates/walgit-server/src/lfs.rs index da8bc30..9a5930a 100644 --- a/crates/walgit-server/src/lfs.rs +++ b/crates/walgit-server/src/lfs.rs @@ -517,6 +517,7 @@ fn auth_err(e: crate::auth::AuthError) -> ApiError { ApiError::Unauthorized } crate::auth::AuthError::Forbidden => ApiError::Forbidden, + crate::auth::AuthError::UntrustedProxy => ApiError::UntrustedProxy, crate::auth::AuthError::Unavailable => { ApiError::ServiceUnavailable("auth provider unavailable".into()) } diff --git a/crates/walgit-server/src/lib.rs b/crates/walgit-server/src/lib.rs index 5f6c615..5e443db 100644 --- a/crates/walgit-server/src/lib.rs +++ b/crates/walgit-server/src/lib.rs @@ -114,6 +114,9 @@ impl AppState { let bridge = bridge::Bridge::new(&cfg, registry.clone()); let metrics_handle = metrics::install()?; let tls = tls::load(&cfg)?; + // A proxy secret that cannot be resolved fails the boot, not every request. + auth::resolve_proxy_secret(&cfg.server.auth, &|v| std::env::var(v).ok()) + .map_err(anyhow::Error::msg)?; if let Some(t) = &tls { tracing::info!(fingerprint = %t.fingerprint, mode = ?cfg.server.tls.mode, "TLS terminated in-process"); } @@ -205,6 +208,11 @@ pub fn router(state: Arc) -> Router { }, ), ) + // Owner scope (proxy mode) on every repository-prefixed route registered above. + .route_layer(axum::middleware::from_fn_with_state( + state.clone(), + web::owner_scope, + )) .fallback(dispatch) // Sliding browser sessions: re-issue a session cookie older than ttl/4. .layer(axum::middleware::from_fn_with_state( @@ -360,6 +368,10 @@ pub(crate) async fn dispatch_route( body: Body, peer: Option, ) -> Response { + // Owner scope (proxy mode, D50): an owner outside it has no repositories to answer for. + if st.auth.hides_owner(&headers, route.id.owner()) { + return web::out_of_scope().into_response(); + } let mut body = Some(body); let sub = route.subpath.as_str(); let result: Result = async { diff --git a/crates/walgit-server/src/policy.rs b/crates/walgit-server/src/policy.rs index 33a3dd7..0cd3592 100644 --- a/crates/walgit-server/src/policy.rs +++ b/crates/walgit-server/src/policy.rs @@ -716,6 +716,7 @@ fn auth_err(e: crate::auth::AuthError) -> ApiError { ApiError::Unauthorized } crate::auth::AuthError::Forbidden => ApiError::Forbidden, + crate::auth::AuthError::UntrustedProxy => ApiError::UntrustedProxy, crate::auth::AuthError::Unavailable => { ApiError::ServiceUnavailable("auth provider unavailable".into()) } diff --git a/crates/walgit-server/src/settings.rs b/crates/walgit-server/src/settings.rs index 0c6da1e..790e4f8 100644 --- a/crates/walgit-server/src/settings.rs +++ b/crates/walgit-server/src/settings.rs @@ -24,6 +24,7 @@ fn auth_err(e: crate::auth::AuthError) -> ApiError { crate::auth::AuthError::Invalid | crate::auth::AuthError::Unauthorized => { ApiError::Unauthorized } + crate::auth::AuthError::UntrustedProxy => ApiError::UntrustedProxy, _ => ApiError::Forbidden, } } diff --git a/crates/walgit-server/src/smart.rs b/crates/walgit-server/src/smart.rs index ca89d49..f21befe 100644 --- a/crates/walgit-server/src/smart.rs +++ b/crates/walgit-server/src/smart.rs @@ -58,6 +58,17 @@ pub async fn info_refs( e, crate::auth::AuthError::Forbidden | crate::auth::AuthError::Unavailable ); + // A proxy that did not prove itself: nothing the client holds can fix it, and behind + // a proxy `Authorization` has usually been consumed already — say so in band. + if is_git_client(headers) + && !service_param.is_empty() + && matches!(e, crate::auth::AuthError::UntrustedProxy) + { + return Ok(git_err_response( + &service_param, + &format!("walgit: {}", crate::error::UNTRUSTED_PROXY_MESSAGE), + )); + } if is_git_client(headers) && !service_param.is_empty() && has_creds && retry_cannot_help { return Ok(git_err_response( &service_param, @@ -1461,6 +1472,9 @@ pub(crate) fn auth_help_message( .to_string(); let why = match e { crate::auth::AuthError::Forbidden => "your identity is not allowed to access this host", + crate::auth::AuthError::UntrustedProxy => { + "the proxy in front of this host did not prove itself to walgit (misconfigured proxy)" + } crate::auth::AuthError::Unavailable => { "the token verifier is temporarily unavailable; retry" } @@ -1565,6 +1579,7 @@ pub(crate) fn auth_err(e: crate::auth::AuthError) -> ApiError { ApiError::Unauthorized } crate::auth::AuthError::Forbidden => ApiError::Forbidden, + crate::auth::AuthError::UntrustedProxy => ApiError::UntrustedProxy, crate::auth::AuthError::Unavailable => { ApiError::ServiceUnavailable("auth provider unavailable".into()) } diff --git a/crates/walgit-server/src/web/api.rs b/crates/walgit-server/src/web/api.rs index 2d26704..50fe6f7 100644 --- a/crates/walgit-server/src/web/api.rs +++ b/crates/walgit-server/src/web/api.rs @@ -224,6 +224,7 @@ pub(crate) fn auth_err(e: AuthError) -> ApiError { match e { AuthError::Invalid | AuthError::Unauthorized => ApiError::Unauthorized, AuthError::Forbidden => ApiError::Forbidden, + AuthError::UntrustedProxy => ApiError::UntrustedProxy, AuthError::Unavailable => ApiError::ServiceUnavailable("auth provider unavailable".into()), } } @@ -474,9 +475,13 @@ pub(crate) async fn owners( State(st): State>, headers: HeaderMap, ) -> Result { - st.auth.require_read(&headers).await.map_err(auth_err)?; + let principal = st.auth.require_read(&headers).await.map_err(auth_err)?; let repos = st.registry.list().await.map_err(internal)?; - let mut out: Vec = repos.into_iter().map(|r| r.owner().to_string()).collect(); + let mut out: Vec = repos + .into_iter() + .filter(|r| principal.sees_owner(r.owner())) + .map(|r| r.owner().to_string()) + .collect(); out.sort(); out.dedup(); Ok(json_swr(&out, None).into_response(&headers)) @@ -486,11 +491,12 @@ pub(crate) async fn owner_repos( headers: HeaderMap, Path(owner): Path, ) -> Result { - st.auth.require_read(&headers).await.map_err(auth_err)?; + let principal = st.auth.require_read(&headers).await.map_err(auth_err)?; let repos = st.registry.list().await.map_err(internal)?; + // An owner outside the scope lists like an unknown one: `[]`, not 404. let mut out: Vec = repos .into_iter() - .filter(|r| r.owner() == owner) + .filter(|r| r.owner() == owner && principal.sees_owner(r.owner())) .map(|r| r.name().to_string()) .collect(); out.sort(); diff --git a/crates/walgit-server/src/web/login.rs b/crates/walgit-server/src/web/login.rs index 7067469..6ea3aa1 100644 --- a/crates/walgit-server/src/web/login.rs +++ b/crates/walgit-server/src/web/login.rs @@ -400,6 +400,9 @@ async fn check(State(st): State>, headers: HeaderMap) -> Response r } Err(crate::auth::AuthError::Forbidden) => crate::error::ApiError::Forbidden.into_response(), + Err(crate::auth::AuthError::UntrustedProxy) => { + crate::error::ApiError::UntrustedProxy.into_response() + } Err(crate::auth::AuthError::Unavailable) => { crate::error::ApiError::ServiceUnavailable("auth provider unavailable".into()) .into_response() diff --git a/crates/walgit-server/src/web/mod.rs b/crates/walgit-server/src/web/mod.rs index 0d1979f..2021ff4 100644 --- a/crates/walgit-server/src/web/mod.rs +++ b/crates/walgit-server/src/web/mod.rs @@ -9,13 +9,52 @@ use std::sync::Arc; use axum::{ body::Body, - extract::{Request, State}, + extract::{FromRequestParts, RawPathParams, Request, State}, http::{StatusCode, header}, middleware::Next, response::{IntoResponse, Redirect, Response}, }; use crate::AppState; +use crate::error::ApiError; + +/// The answer for an owner outside the caller's scope: the same 404 as a repository that +/// does not exist, so the scope never confirms what lies beyond it. +pub(crate) fn out_of_scope() -> ApiError { + ApiError::NotFound(walgit_wal::WalError::NotFound.to_string()) +} + +/// `proxy` mode's owner scope (D50) on every matched route under a repository prefix — +/// the routes with `{owner}` and `{repo}` path parameters: the JSON API in both lanes, repo +/// admin, UI data and pages. Installed once as a `route_layer` over all merged routers, so +/// a new repository route inherits it; the fallback dispatcher (git smart HTTP, LFS, `.git` +/// paths) makes the same check itself (`dispatch_route`). Raw (still percent-encoded) +/// parameters are compared: scope entries are validated owner names without `%`, so an +/// encoded spelling can only miss the scope, never enter it. +pub async fn owner_scope( + State(st): State>, + req: Request, + next: Next, +) -> Response { + if st.auth.mode() != walgit_config::AuthMode::Proxy { + return next.run(req).await; + } + let (mut parts, body) = req.into_parts(); + let owner = RawPathParams::from_request_parts(&mut parts, &()) + .await + .ok() + .and_then(|params| { + let owner = params.iter().find(|(k, _)| *k == "owner")?.1.to_string(); + params.iter().any(|(k, _)| k == "repo").then_some(owner) + }); + let req = Request::from_parts(parts, body); + if let Some(owner) = owner + && st.auth.hides_owner(req.headers(), &owner) + { + return out_of_scope().into_response(); + } + next.run(req).await +} /// Send a browser on `localhost` / `127.0.0.1` to `walgit.localhost` (same port). /// Keep `/_auth/*` on the literal loopback host so an issuer's registered callback remains exact. @@ -117,6 +156,9 @@ pub async fn require_auth( let q = url_encode(&next_url); return Redirect::temporary(&format!("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/_auth/login?next={q}")).into_response(); } + if matches!(e, crate::auth::AuthError::UntrustedProxy) { + return crate::error::ApiError::UntrustedProxy.into_response(); + } let status = match e { crate::auth::AuthError::Forbidden => StatusCode::FORBIDDEN, crate::auth::AuthError::Unavailable => StatusCode::SERVICE_UNAVAILABLE, diff --git a/crates/walgit-server/src/web/ui.rs b/crates/walgit-server/src/web/ui.rs index dad07f5..a28ba3e 100644 --- a/crates/walgit-server/src/web/ui.rs +++ b/crates/walgit-server/src/web/ui.rs @@ -1031,6 +1031,7 @@ fn auth_err(error: crate::auth::AuthError) -> ApiError { ApiError::Unauthorized } crate::auth::AuthError::Forbidden => ApiError::Forbidden, + crate::auth::AuthError::UntrustedProxy => ApiError::UntrustedProxy, crate::auth::AuthError::Unavailable => { ApiError::ServiceUnavailable("auth provider unavailable".into()) } diff --git a/crates/walgit-server/tests/api_v1.rs b/crates/walgit-server/tests/api_v1.rs index 5708254..6545217 100644 --- a/crates/walgit-server/tests/api_v1.rs +++ b/crates/walgit-server/tests/api_v1.rs @@ -631,3 +631,310 @@ async fn policy_and_settings_writes_require_admin() -> TestResult { assert_eq!(st, 200, "{text}"); Ok(()) } + +/// The proxy's shared secret, as the proxy sends it. +const PROXY_SECRET: &str = "0123456789abcdef0123456789abcdef-proxy-secret"; + +/// A `proxy`-mode server on loopback (the sidecar shape) whose secret variable `var` holds +/// [`PROXY_SECRET`] the way a secret file usually does: with a trailing newline. +async fn proxy_server(var: &'static str) -> anyhow::Result { + // A name only this test reads; set once, before the server resolves it. + #[allow(unsafe_code)] + // SAFETY: the variable is private to this test and written before anything reads it. + unsafe { + std::env::set_var(var, format!("{PROXY_SECRET}\n")); + } + Server::start_with_tweak(|c| { + c.server.auth.mode = walgit_config::AuthMode::Proxy; + c.server.auth.anonymous_read = false; + c.server.auth.proxy_secret_env = Some(var.to_string()); + }) + .await +} + +/// D50: behind an identity-aware proxy (`server.auth.mode = "proxy"`, loopback: the +/// sidecar shape, with the secret all the same) identity and access come only from the +/// proxy's headers, and `X-Walgit-Owners` narrows what exists: listings omit other owners +/// and every route under their prefix — JSON API in both lanes, repo admin, UI data, git +/// smart HTTP — answers the 404 of a repository that does not exist. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn proxy_mode_takes_identity_access_and_owner_scope_from_the_proxy() -> TestResult { + let server = proxy_server("WALGIT_TEST_PROXY_SECRET_SCOPE").await?; + let as_ = |access: &'static str, owners: Option<&'static str>| { + let mut h = vec![ + ("X-Walgit-Proxy-Secret", PROXY_SECRET), + ("X-Walgit-Principal", "dev@example.com"), + ("X-Walgit-Access", access), + ]; + if let Some(o) = owners { + h.push(("X-Walgit-Owners", o)); + } + h + }; + for path in ["/acme/app/api", "/other/app/api"] { + let (st, text, _) = req(&server, reqwest::Method::PUT, path, &as_("write", None)).await?; + assert_eq!(st, 201, "{path}: {text}"); + } + + // No principal: 401; no or an unknown access level: 403; read cannot write. + assert_eq!( + req( + &server, + reqwest::Method::GET, + "/api/v1/owners", + &[("X-Walgit-Proxy-Secret", PROXY_SECRET)] + ) + .await? + .0, + 401 + ); + let nameless = [ + ("X-Walgit-Proxy-Secret", PROXY_SECRET), + ("X-Walgit-Access", "admin"), + ]; + assert_eq!( + req(&server, reqwest::Method::GET, "/acme/app/api", &nameless) + .await? + .0, + 401 + ); + let levelless = [ + ("X-Walgit-Proxy-Secret", PROXY_SECRET), + ("X-Walgit-Principal", "dev@example.com"), + ]; + assert_eq!( + req(&server, reqwest::Method::GET, "/acme/app/api", &levelless) + .await? + .0, + 403 + ); + assert_eq!( + req( + &server, + reqwest::Method::PUT, + "/acme/new/api", + &as_("read", None) + ) + .await? + .0, + 403 + ); + assert_eq!( + req( + &server, + reqwest::Method::DELETE, + "/acme/app/api", + &as_("write", None) + ) + .await? + .0, + 403, + "write is push, not admin" + ); + let (st, me, _) = req( + &server, + reqwest::Method::GET, + "/api/v1/me", + &as_("read", None), + ) + .await?; + assert_eq!(st, 200); + assert_eq!( + serde_json::from_str::(&me)?["principal"], + "dev@example.com" + ); + + // Absent or `*`: every owner. + for owners in [None, Some("*")] { + let (_, text, _) = req( + &server, + reqwest::Method::GET, + "/api/v1/owners", + &as_("read", owners), + ) + .await?; + assert_eq!( + serde_json::from_str::(&text)?, + serde_json::json!(["acme", "other"]) + ); + } + + let scoped = as_("admin", Some("acme")); + for path in ["/api/v1/owners", "/services/api/owners"] { + let (st, text, _) = req(&server, reqwest::Method::GET, path, &scoped).await?; + assert_eq!(st, 200, "{path}"); + assert_eq!( + serde_json::from_str::(&text)?, + serde_json::json!(["acme"]), + "{path}" + ); + } + for path in ["/api/v1/owners/other/repos", "/services/api/owners/other"] { + let (st, text, _) = req(&server, reqwest::Method::GET, path, &scoped).await?; + assert_eq!( + (st.as_u16(), text.as_str()), + (200, "[]"), + "{path}: lists like an unknown owner" + ); + } + let (_, text, _) = req( + &server, + reqwest::Method::GET, + "/api/v1/owners/acme/repos", + &scoped, + ) + .await?; + assert_eq!( + serde_json::from_str::(&text)?, + serde_json::json!(["app"]) + ); + + for (method, path) in [ + (reqwest::Method::GET, "/other/app/api"), + (reqwest::Method::GET, "/other/app/api-browser"), + (reqwest::Method::GET, "/other/app/api/refs"), + (reqwest::Method::GET, "/other/app/api/overview"), + (reqwest::Method::GET, "/other/app/api/settings"), + (reqwest::Method::GET, "/other/app/api/policy"), + ( + reqwest::Method::GET, + "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/other/app.git/info/refs?service=git-upload-pack", + ), + ( + reqwest::Method::POST, + "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/other/app.git/info/lfs/objects/batch", + ), + (reqwest::Method::DELETE, "/other/app/api"), + (reqwest::Method::PUT, "/other/fresh/api"), + ] { + let (st, text, _) = req(&server, method.clone(), path, &scoped).await?; + assert_eq!(st, 404, "{method} {path} is out of scope: {text}"); + } + assert_eq!( + req( + &server, + reqwest::Method::GET, + "/other/app/api", + &as_("admin", Some("")) + ) + .await? + .0, + 404, + "an empty owner list is the empty scope" + ); + // In scope, the same caller is served; the out-of-scope repository is intact. + assert_eq!( + req(&server, reqwest::Method::GET, "/acme/app/api", &scoped) + .await? + .0, + 200 + ); + let (st, _, h) = req( + &server, + reqwest::Method::GET, + "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/acme/app.git/info/refs?service=git-upload-pack", + &scoped, + ) + .await?; + assert_eq!(st, 200); + assert!(hdr(&h, "content-type").contains("git-upload-pack")); + assert_eq!( + req( + &server, + reqwest::Method::GET, + "/other/app/api", + &as_("read", None) + ) + .await? + .0, + 200 + ); + Ok(()) +} + +/// A request that does not prove it came through the proxy (secret missing or wrong) is +/// the proxy's misconfiguration — or a caller bypassing it — never the client's bad +/// credential: a 403 that names the proxy, without `WWW-Authenticate`, and never a 401 +/// (git erases the stored credential on a 401). A git client is told in band. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_proxy_that_does_not_prove_itself_is_a_403_naming_the_proxy_never_a_401() -> TestResult { + let server = proxy_server("WALGIT_TEST_PROXY_SECRET_UNPROVEN").await?; + let identity = [ + ("X-Walgit-Principal", "dev@example.com"), + ("X-Walgit-Access", "admin"), + ("Authorization", "Bearer the-users-own-token"), + ]; + let with_secret = |secret: Option<&'static str>| { + let mut h = identity.to_vec(); + if let Some(s) = secret { + h.push(("X-Walgit-Proxy-Secret", s)); + } + h + }; + let (st, _, _) = req( + &server, + reqwest::Method::PUT, + "/acme/app/api", + &with_secret(Some(PROXY_SECRET)), + ) + .await?; + assert_eq!( + st, 201, + "the right secret (stored with a trailing newline) is accepted" + ); + + for secret in [None, Some("wrong"), Some(&PROXY_SECRET[1..])] { + for (method, path) in [ + (reqwest::Method::GET, "/api/v1/owners"), + (reqwest::Method::GET, "/api/v1/me"), + (reqwest::Method::GET, "/acme/app/api"), + (reqwest::Method::DELETE, "/acme/app/api"), + ( + reqwest::Method::GET, + "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/acme/app.git/info/refs?service=git-upload-pack", + ), + (reqwest::Method::GET, "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/_auth/check"), + ] { + let (st, text, h) = req(&server, method.clone(), path, &with_secret(secret)).await?; + assert_ne!(st, 401, "{secret:?} {method} {path}: {text}"); + assert_eq!(st, 403, "{secret:?} {method} {path}: {text}"); + assert!(text.contains("proxy"), "{secret:?} {method} {path}: {text}"); + assert!( + h.get("www-authenticate").is_none(), + "{secret:?} {method} {path}: no credential challenge" + ); + } + // LFS (the batch body is parsed first, so send a valid one). + let (st, text) = req_body( + &server, + reqwest::Method::POST, + "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/acme/app.git/info/lfs/objects/batch", + &with_secret(secret), + r#"{"operation":"download","objects":[]}"#, + ) + .await?; + assert_eq!( + (st.as_u16(), text.contains("proxy")), + (403, true), + "{secret:?} lfs: {text}" + ); + // git: the same, in band (git prints `remote error: …` and keeps its credential). + for service in ["git-upload-pack", "git-receive-pack"] { + let mut h = with_secret(secret); + h.push(("User-Agent", "git/2.46.0")); + let (st, text, _) = req( + &server, + reqwest::Method::GET, + &format!("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/acme/app.git/info/refs?service={service}"), + &h, + ) + .await?; + assert_ne!(st, 401, "{secret:?} {service}: {text}"); + assert!( + text.contains("ERR walgit: forbidden") && text.contains("proxy"), + "{secret:?} {service}: {text}" + ); + } + } + Ok(()) +} diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 2aa7302..8b48d24 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -5,7 +5,7 @@ crates in parallel)**, kept as the reference for names and shapes. Rule still in — a type or function listed here is relied on by another crate. **Where this file and the code disagree, the code is right and this file is stale**; verify with `rg`/`cargo doc` before relying on a signature. Known supersessions (2026-08-20 sweep): `RepoHandle::sync()` is now the *Serve* level of the sync-level family -(`sync_refs` / `sync` = Serve / `sync_full` / `sync_objects`, `AGENTS.md §2.3`); auth is none/token/oidc +(`sync_refs` / `sync` = Serve / `sync_full` / `sync_objects`, `AGENTS.md §2.3`); auth is none/token/oidc/proxy (`AGENTS.md §1.3`, with explicit admin principals); the server router is `web/API.md` + `AGENTS.md D15/D20/D26/D27`; the packfile replacement target is [PACKFILE_URI_DESIGN.md](PACKFILE_URI_DESIGN.md). Runtime bundle contracts are removed; retained protobuf messages exist only for durable replay. diff --git a/walgit.example.toml b/walgit.example.toml index 79584aa..82a85e1 100644 --- a/walgit.example.toml +++ b/walgit.example.toml @@ -5,7 +5,7 @@ # Validate with: walgit --config walgit.toml config check [server] -listen = "127.0.0.1:8080" # default; `mode = none` is refused unless this is loopback. Public bind: 0.0.0.0 with token/oidc. +listen = "127.0.0.1:8080" # default; `mode = none` is refused unless this is loopback. Public bind: 0.0.0.0 with token/oidc/proxy. http2 = true # HTTP/2 (h2c when TLS is off, ALPN when on) max_concurrent_requests = 512 # global cap on in-flight git requests max_concurrent_per_repo = 64 # per-repo cap (upload-pack / receive-pack) @@ -37,12 +37,16 @@ mode = "off" # "off" (HTTP/1.1 + h2c) | "self_signed" (gene # hostnames = ["git.example.com"] # self_signed SANs; empty = localhost, *.localhost, 127.0.0.1, ::1 + public_url's host [server.auth] -mode = "none" # "none" | "token" | "oidc" +mode = "none" # "none" | "token" | "oidc" | "proxy" # none — everyone is `anon` with write+admin; loopback listen only # token — static `tokens` below, as `Authorization: Bearer` or the password of HTTP Basic # oidc — any OpenID Connect issuer: browser sign-in through it, ID tokens as bearers, # walgit-issued access tokens for git (/_auth/tokens), plus `tokens` for robots -anonymous_read = true # must be false in oidc mode + # proxy — an identity-aware proxy in front authenticates and authorizes every request and + # asserts X-Walgit-Principal, X-Walgit-Access (read|write|admin, required) and + # optionally X-Walgit-Owners (owner[,owner…] or *; others then 404). The proxy must + # strip these headers from clients. No tokens, trusted_forwarders or admin_* here. +anonymous_read = true # must be false in oidc and proxy mode # tokens = [ # token → principal; `token_env` reads the value from the environment at startup # { principal = "alice", token_env = "WALGIT_TOKEN_ALICE", write = true, admin = true }, # { principal = "ci", token = "literal-secret", write = false }, @@ -62,6 +66,11 @@ anonymous_read = true # must be false in oidc mode # audiences = [] # oidc: `aud` values accepted on bearer ID tokens clients mint themselves; # # the configured web client is always accepted # trusted_forwarders = [] # principals allowed to set X-Walgit-Principal (a front in front of a push broker) +# proxy_secret_env = "WALGIT_PROXY_SECRET" # proxy: env var holding the secret the proxy sends as X-Walgit-Proxy-Secret; +# # required, loopback listen included (a sidecar's pod shares loopback). +# # Trimmed (a trailing newline is fine), >= 32 bytes; unset/blank/short +# # at startup = the server refuses to start. Wrong/missing on a request +# # = 403 naming the proxy (never 401: git would drop the user's token). [store] backend = "s3" # "s3" (AWS, MinIO, rustfs, R2, Ceph, …) | "gcs" | "memory" (tests) diff --git a/web/API.md b/web/API.md index 147dee4..e0cc045 100644 --- a/web/API.md +++ b/web/API.md @@ -228,6 +228,7 @@ Top-level namespaces. Sorted, `[]` if none. Must come from the authoritative repo list (walgit lists the object store, not local disk, so a cold node sees everything). +Only owners in the caller's scope (`proxy` mode's `X-Walgit-Owners`, AGENTS.md §1.3). Cache: SWR (§2a). ### `GET /api/v1/owners/{owner}/repos` @@ -238,7 +239,7 @@ Repositories under one owner, short names only. ["hello", "walgit"] ``` -Sorted, `[]` for an unknown/empty owner (200, not 404). Cache: SWR. +Sorted, `[]` for an unknown/empty owner or one outside the caller's scope (200, not 404). Cache: SWR. ### `GET /api/v1/me`