Skip to content

feat(auth): proxy mode — take identity and access from an identity-aware proxy - #96

Open
jonasgrosch wants to merge 2 commits into
tobi:mainfrom
conxai-technologies:conxai/proxy-auth
Open

jonasgrosch wants to merge 2 commits into
tobi:mainfrom
conxai-technologies:conxai/proxy-auth

Conversation

@jonasgrosch

Copy link
Copy Markdown

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" with X-Walgit-Principal, which is unsafe as a production shape: in none every 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:

  • Who: X-Walgit-Principal is required (401 without). No anonymous access; anonymous_read must be false.
  • What: X-Walgit-Access: read | write | admin is required (403 when missing/unknown); admin ⊃ write ⊃ read. Nothing in config grants admin: tokens, trusted_forwarders and admin_* are refused by validate in this mode.
  • Trust boundary: unless server.listen is loopback (sidecar), the proxy sends X-Walgit-Proxy-Secret = the value of the env var named by server.auth.proxy_secret_env (≥ 32 bytes, constant-time compare, checked before any other header). validate refuses a public bind without it; an unresolvable secret fails startup rather than 401-ing everything behind a green /readyz.
  • Owner scope (optional): X-Walgit-Owners: <owner>[,<owner>…] | * narrows what exists for the caller: owner listings return only owners in scope, …/owners/{o}/repos returns [] 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 one route_layer over the {owner}/{repo} routes plus a check in dispatch_route, so new repository routes inherit it.
  • Repeated identity headers are refused. None of the proxy headers is read in any other mode. The principal name feeds logs, policy.json matching 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 token and none; config validation. Integration (tests/api_v1.rs): 401/403, owner-scoped listings, 404 across API, admin, UI data, git and LFS routes.

Open questions

  • none still honours X-Walgit-Principal from any loopback caller with full admin; left unchanged to keep the diff small — worth tightening now that proxy exists?
  • A push broker behind a proxy-mode front: forward.rs sends a bearer, not the proxy headers, so the broker stays in token mode (documented).
  • smart.rs::auth_help_message names "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

…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>
@jonasgrosch

Copy link
Copy Markdown
Author

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, proxy owner scope did not cover the owner-profile routes. The scope check applies to routes with both {owner} and {repo}; /api[-browser]/v1/owners/{owner} has only {owner}, so a caller scoped to acme could read, overwrite or delete another owner's profile. Fix: the profile handlers answer the same 404 for out-of-scope owners, plus a test pinning every owner-only and repo route the two PRs add — conxai-technologies/walgit@9067576. Whichever of #96/#97 lands second should carry it; happy to add it to either branch.

🤖 Generated with Claude Code

@alanagoyal

Copy link
Copy Markdown

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.

@0bserver07

Copy link
Copy Markdown
Contributor

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 trusted_forwarders already lets a gateway vouch for a user with its own token, maybe an access ceiling there would cover what you need?

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>
@jonasgrosch

Copy link
Copy Markdown
Author

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 conxai/forwarder-access-ceiling. A trusted forwarder's token write/admin bits are the ceiling, X-Walgit-Access can only narrow below it, and X-Walgit-Owners scopes owners the same way. Narrowing headers walgit won't honour refuse the request instead of being dropped, and a bad forwarder token is a 403, not a 401. It covers our Envoy+OPA sidecar with no new auth mode. If Tobi prefers that shape we'll close #96 and open this instead.

@0bserver07

Copy link
Copy Markdown
Contributor

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 X-Walgit-Access: admin gives the user admin whenever the forwarder has it, even if walgit doesn't treat that user as an admin, so the header can raise access instead of only lowering it. Limiting it to the user's own admin would fix that. Also /metrics still shows all the repo names to a scoped user.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants