The operator ruled the implementation repo's main (50c8719) the latest and accepted state. Sync the spec to it. Accepted changes are reflected: one bare-metal environment, TrueNAS bulk storage, R2 off-cluster backup, CloudNativePG, single-node VPN gateway with Cilium fail-closed, inbound WireGuard, per-app auth sidecars, Renovate, the CI gate as approver, and the pm-agent. New ADR-0017..0025; ADR-0016 corrected and accepted; R-* IDs unchanged, eight amended. Where the implementation has simply not met a requirement, the requirement stands and the shortfall is recorded in docs/16-known-gaps.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
4.3 KiB
ADR-0022 — Forward-auth via per-app oauth2-proxy sidecars
- Status: Accepted
- Date: 2026-11-08
- Accepted by: operator ruling of 2026-11-08 that the implementation is the accepted state; effective on merge.
- Serves: R-IDP-02, R-IDP-06
- Supersedes: ADR-0014 — enforcement point and app list only: the "at the Gateway" clause and the list of forward-auth apps in its Decision. Everything else in ADR-0014 stands.
Context
ADR-0014 put a single oauth2-proxy at the Gateway as an external-authorization check in front of every app without usable OIDC. That design needs a per-route authorization hook on the shared Gateway, and the platform's Gateway implementation does not offer a usable one:
- Cilium generates and owns the shared Gateway's Envoy configuration, regenerating it on every route change, with no supported way to inject an authorization filter into it.
- Cilium's native Gateway API
ExternalAuthfilter needs the experimental Gateway API CRD channel, a cluster-wide change, and shipped with a fail-open bug in the very property R-IDP-02 forbids. It was evaluated and deferred. - Without a gate, the bare UIs stayed reachable unauthenticated on the LAN.
Crafty's web UI was already gated by a co-located oauth2-proxy, which proved the pattern on this
platform. Detail: docs/plans/cilium-native-externalauth-evaluation.md in the
implementation repo.
Decision
Forward-auth is enforced by a per-app oauth2-proxy running as a reverse-proxy sidecar in the app's pod, in the data path, rather than by a shared check at the Gateway.
- The app's route targets the sidecar's port. Network policy opens only that port to the Gateway, so the bare UI port is unreachable from it and a failed or absent gate fails closed.
- Each gate is its own instance: its own IdP client, its own SOPS-encrypted credentials, and the
authorized groups declared in the service's
authblock. - Session cookies are per-instance: a unique cookie name per gate, scoped to the app's own host. Instances hold different cookie secrets, so a shared name on a zone-wide domain makes them evict one another's sessions.
- An app MAY declare the path prefixes of its programmatic API, so unauthenticated calls there are refused rather than redirected to an HTML login page.
- The catalog renderer generates the sidecar for every
forward-authservice with an exposed UI.
App list (replaces ADR-0014's). Gated this way: PiHole, Sonarr, Radarr, Jackett, qBittorrent and the Crafty web UI. Homepage has no usable OIDC and requires a gate under R-IDP-02. Prometheus, Alertmanager and Hubble UI are not exposed, so no gate applies; any future exposure MUST be gated.
Alternatives considered
- Self-authored Envoy listener with an authorization filter on its own VIP — works, and was proven against the live proxy, but needs hand-maintained TLS and routes per app, a second ingress address and DNS repointing. Rejected; retained in the implementation repo as an unwired template.
- Cilium native
ExternalAuth— the cleanest shape and the long-term target, one per-route filter and no per-app proxies. Deferred: experimental CRDs, an immature security-critical filter, thin documentation. A future ADR may adopt it and retire the sidecars.
Consequences
- Enforcement is Gateway-API-native (a backend swap) and does not depend on the Gateway's generated configuration.
- More moving parts: one proxy, one IdP client and one credential set per gated app. Adding a gated app needs all three.
- Sessions are per-host. A live Keycloak session makes the bounce silent, so SSO still holds, but a browser performs one redirect per app.
- An expired session answers an unauthenticated XHR with a redirect that resolves as a successful HTML response, which is why API paths are declarable and why each gate exposes metrics for scraping (10).
- Crafty is platform-managed outside the renderer, so its gate is hand-maintained and must follow the same per-instance cookie rule.
- A UI whose pod the renderer does not generate, such as Homepage, gets a gate only by explicit work (known gaps).
- 15 carries the resulting design; ADR-0014's text is not edited.