feat(auth): proxy mode — take identity and access from an identity-aware proxy - #96
jonasgrosch wants to merge 2 commits into
Conversation
…are proxy
Deployments that already authenticate and authorize at a gateway (JWT
verification plus an external authorizer) had one way to hand walgit the
verdict: `mode = "none"` + `X-Walgit-Principal`. That makes everyone admin,
lets any loopback caller name anyone, and gives every forwarded name write.
`server.auth.mode = "proxy"` makes the contract explicit and fail-closed:
- `X-Walgit-Principal` is required (401 without it); there is no anonymous
access (`anonymous_read` must be false).
- `X-Walgit-Access: read | write | admin` is required (missing or unknown:
403). admin implies write, write implies read. Nothing in the config
grants admin; `tokens`, `trusted_forwarders` and `admin_*` are refused.
- Off loopback the proxy proves itself with `X-Walgit-Proxy-Secret`, the
value of `$<proxy_secret_env>` (>= 32 bytes, SHA-256 digests compared in
constant time, checked before any other header is read). `validate`
refuses a non-loopback listen without it; an unresolvable secret fails
startup. Loopback (the sidecar shape) may omit it.
- Repeated identity headers are refused: something appended instead of
replacing.
- Optional `X-Walgit-Owners: <owner>[,<owner>…] | *` narrows what exists
for the caller: owner listings omit the rest, `…/owners/{o}/repos` lists
`[]`, and every route under an out-of-scope owner answers the same 404 as
a missing repository (never 403). Enforced by one `route_layer` over all
matched `{owner}/{repo}` routes plus a check in `dispatch_route` for git
smart HTTP and LFS, so new repository routes inherit it. An
unauthenticated request falls through to its handler's own 401/403.
- None of `X-Walgit-Access`, `X-Walgit-Owners`, `X-Walgit-Proxy-Secret` is
read in any other mode.
The principal name feeds logs, `policy.json` and push attribution as in
every other mode. Documented as D50 and in §1.3 of AGENTS.md, the README
auth table, `walgit.example.toml` and web/API.md.
No round trips added: header parsing only, no store access.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Heads-up from our integration of the #94 PRs (proxy mode + metadata + default HEAD + baseline together, full workspace suite green): with this PR and #97 both merged, 🤖 Generated with Claude Code |
|
opened #102 to address the hardcoded Google wording mentioned here. it makes git authentication errors provider-neutral, adds an optional OIDC provider display name with an issuer URL fallback, and gives separate guidance for permission denials and verifier outages. this is independent of the proposed proxy authentication mode; existing authentication rules and HTTP 401 credential-refresh behavior are unchanged. |
|
This is careful work and the whole suite passes for me locally. The bigger question is probably for Tobi: AGENTS.md says no new auth paths, and that the app shouldn't trust an edge because of config, so I think #94 needs his call first. Token mode with If proxy mode stays, a few things I noticed. On a loopback listener no secret is needed at all, and in the sidecar setup from #94 everything in the pod shares loopback. The secret from the env isn't trimmed but the header value is, so a secret file ending in a newline fails every request. And a wrong proxy secret returns 401, which makes git throw away the user's stored credential. |
Three review points on proxy mode: - The secret from `proxy_secret_env` is trimmed the way the header value is, so a secret file or Kubernetes Secret ending in a newline no longer fails every request. A blank result is refused at startup; the 32-byte floor counts after trimming. - A missing, wrong or repeated proxy secret (and a repeated X-Walgit-Principal) is a new `AuthError::UntrustedProxy`: 403 with a body naming the misconfigured proxy, no WWW-Authenticate, and an in-band ERR for git clients. Never a 401, which makes git erase the user's stored credential although that credential is not what failed. Not a 5xx either, since a proxy retries 5xx from its upstream and a caller bypassing the proxy is simply refused. - The secret is required in proxy mode on every listen address. The loopback exemption is gone: in the sidecar shape every container of the pod shares loopback, so reaching 127.0.0.1 does not identify the proxy. The sidecar still works; it injects the header from a Secret. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, all three were right. On #96 (af56d86): the secret is trimmed like the header and a blank or short result fails startup; a missing or wrong secret is now a 403 naming the proxy (in-band ERR for git), never a 401; and the secret is required on loopback too, since the whole pod shares it. On the alternative: we prototyped it on |
|
Thanks, all three look good to me now. I also had a look at the ceiling branch and I like that approach more since it doesn't add a new mode. One thing before it goes up: sending |
Why
Many deployments already authenticate and authorize at a gateway in front of their services (JWT verification + an external authorizer). The only way to accept that gateway's verdict today is
mode = "none"withX-Walgit-Principal, which is unsafe as a production shape: innoneevery caller is admin, anything that can reach the loopback port may assert any identity, and every forwarded identity inherits write.What
A new explicit mode,
server.auth.mode = "proxy", in which the proxy is the authority and must prove itself:X-Walgit-Principalis required (401 without). No anonymous access;anonymous_readmust be false.X-Walgit-Access: read | write | adminis required (403 when missing/unknown);admin⊃write⊃read. Nothing in config grants admin:tokens,trusted_forwardersandadmin_*are refused byvalidatein this mode.server.listenis loopback (sidecar), the proxy sendsX-Walgit-Proxy-Secret= the value of the env var named byserver.auth.proxy_secret_env(≥ 32 bytes, constant-time compare, checked before any other header).validaterefuses a public bind without it; an unresolvable secret fails startup rather than 401-ing everything behind a green/readyz.X-Walgit-Owners: <owner>[,<owner>…] | *narrows what exists for the caller: owner listings return only owners in scope,…/owners/{o}/reposreturns[]for others, and every route under an out-of-scope owner (JSON API in both lanes, repo admin, UI data, git smart HTTP, LFS) answers the same 404 as a missing repository. Enforced by oneroute_layerover the{owner}/{repo}routes plus a check indispatch_route, so new repository routes inherit it.policy.jsonmatching and push attribution as in every mode.Docs
Decision + §1.3 security contract in
AGENTS.md; README auth-mode table;walgit.example.toml;web/API.md.Cost
Header parsing only — no store access, no new round trips.
Tests
Unit: header parsing/validation; the secret (missing, wrong, repeated, unresolvable, public bind without it); owner-scope parsing and filtering; proxy headers ignored in
tokenandnone; config validation. Integration (tests/api_v1.rs): 401/403, owner-scoped listings, 404 across API, admin, UI data, git and LFS routes.Open questions
nonestill honoursX-Walgit-Principalfrom any loopback caller with full admin; left unchanged to keep the diff small — worth tightening now thatproxyexists?forward.rssends a bearer, not the proxy headers, so the broker stays intokenmode (documented).smart.rs::auth_help_messagenames "Google Identity-Aware Proxy" in every mode; happy to make it generic.Part of #94. Each of these PRs claims the next decision number in
AGENTS.md(D50/D5x); renumber on merge as you prefer.🤖 Generated with Claude Code