Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,24 @@ 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
(RS256/ES256, `iss`, `exp`, `aud` ∈ `audiences` ∪ {`oauth_client_id`}, `email_verified`), a **walgit access
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` = `$<proxy_secret_env>` (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: <o>[,<o>…] | *` 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`).
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <token>`, 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
Expand All @@ -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`
Expand Down
96 changes: 96 additions & 0 deletions crates/walgit-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ pub struct AuthConfig {
/// Pair with `oauth_client_secret`; both or neither.
pub oauth_client_id: Option<String>,
pub oauth_client_secret: Option<String>,
/// `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<String>,
}

/// Prefix of access tokens walgit mints itself (`/_auth/tokens`): recognisable in logs and
Expand All @@ -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)]
Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion crates/walgit-server/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response, ApiError> {
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::<Vec<_>>()
.join("\n");
Expand All @@ -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())
}
Expand Down
Loading