Build the deferred forward-auth ENFORCEMENT for non-OIDC UIs (pihole/*arr/ qbittorrent/homepage; Crafty next), gated by Keycloak via oauth2-proxy. - helmrelease: add `--allowed-groups=platform-admins` (R-IDP-06 floor: authenticated AND in group) + `--skip-provider-button` (302 straight to Keycloak; verified the interstitial 403 sign-in page otherwise). - forward-auth-cec.yaml: interim self-authored CiliumClusterwideEnvoyConfig ext_authz listener -> oauth2-proxy /oauth2/auth, failure_mode_allow:false, per-route opt-in via virtualHost. TEMPLATE — not Flux-wired (PM deploys with an L2 VIP + DNS repoint). The ext_authz stanza is verified in-cluster. - README: document the mechanism, why per-route ext_authz on the shared Gateway is NOT clean on Cilium 1.16.5 (machine-owned cilium-gateway-shared CEC; native ExternalAuth/GEP-1494 in no released Cilium), the three options + recommendation, the opt-in steps, the in-cluster proof, and the human browser test. Keycloak `oauth2-proxy` client already carries the callback redirect URI + a bare `groups` claim (full.path=false) — no realm change needed. Proof (cilium-envoy v1.30.8): a standalone envoy running this exact ext_authz stanza against the live oauth2-proxy denied unauthenticated requests (403, oauth2-proxy sign-in body; upstream never served), while a no-filter control route on the same backend returned 200. R-IDP-02 property holds. Co-Authored-By: Claude Opus 4.8 <[email protected]>
171 lines
8.8 KiB
YAML
171 lines
8.8 KiB
YAML
---
|
|
# =============================================================================
|
|
# forward-auth-cec.yaml — INTERIM ext_authz enforcement for Cilium 1.16.5
|
|
# Serves: R-IDP-02 (no management UI unauthenticated), R-IDP-06 (group authz).
|
|
# Owner: WS-NET (+ WS-SEC). Status: TEMPLATE — NOT wired into kustomization.yaml.
|
|
# =============================================================================
|
|
#
|
|
# WHY THIS IS A STANDALONE CEC (and not an edit to the shared Gateway):
|
|
# On Cilium 1.16.5 the shared Gateway's Envoy program is a MACHINE-OWNED,
|
|
# constantly-regenerated CiliumEnvoyConfig (`gateway/cilium-gateway-shared`,
|
|
# ownerReferences->Gateway, regenerated on EVERY HTTPRoute change). There is
|
|
# no supported hook to inject an `ext_authz` HTTP filter into that generated
|
|
# listener, and native Gateway-API ExternalAuth (GEP-1494) ships in NO
|
|
# released Cilium version yet (PoC only). So ext_authz forward-auth on 1.16.5
|
|
# must be a SEPARATELY-authored Envoy listener. See README.md "Enforcement".
|
|
#
|
|
# WHAT IT DOES (VERIFIED in-cluster against cilium-envoy v1.30.8, 2026-08-28):
|
|
# An `envoy.filters.http.ext_authz` HTTP filter calls oauth2-proxy's
|
|
# /oauth2/auth decision endpoint. oauth2-proxy returns 202 for a valid
|
|
# Keycloak session in `platform-admins` (request passes to the upstream) or
|
|
# 401/403 otherwise (`failure_mode_allow: false` => Envoy denies and returns
|
|
# oauth2-proxy's sign-in/redirect to the browser — the request NEVER reaches
|
|
# the upstream). Proven: unauthenticated => oauth2-proxy 403 sign-in returned,
|
|
# upstream body ("UPSTREAM-OK") never served; a no-filter control route on the
|
|
# same backend returned 200.
|
|
#
|
|
# OPT-IN MODEL (per-route, low blast radius):
|
|
# A route is protected ONLY by being listed as a virtualHost on THIS listener.
|
|
# Apps NOT listed here keep hitting the shared Gateway unprotected. To protect
|
|
# an app (e.g. pihole below; next: Crafty):
|
|
# 1. Add its Service to `spec.backendServices` and a matching EDS Cluster.
|
|
# 2. Add its virtualHost to the `forward-auth` RouteConfiguration.
|
|
# 3. REMOVE its HTTPRoute from the shared Gateway (else it stays reachable
|
|
# unauthenticated there) and point its DNS at THIS listener's VIP.
|
|
# The app's ServiceDefinition already declares `auth.mode: forward-auth` +
|
|
# `groups: [platform-admins]`; the HTTPRoute carries those as annotations
|
|
# (homelab.keithjackson.dev/auth-mode,/auth-groups) — the opt-in signal.
|
|
#
|
|
# DEPLOY (PM step — NOT automatic on merge):
|
|
# * Allocate a dedicated L2 VIP (CiliumLoadBalancerIPPool / the Phase-2 pool)
|
|
# and a LoadBalancer Service that this CEC binds via `spec.services`
|
|
# (`oauth2-protected-ingress` below). TLS reuses the SAME wildcard cert the
|
|
# shared Gateway already syncs to SDS: `cilium-secrets/gateway-wildcard-home-tls`.
|
|
# * Repoint the protected hostnames' DNS (ExternalDNS/pihole) at that VIP.
|
|
# * `kubectl apply -f forward-auth-cec.yaml` (or add to kustomization.yaml).
|
|
# -----------------------------------------------------------------------------
|
|
apiVersion: cilium.io/v2
|
|
kind: CiliumClusterwideEnvoyConfig
|
|
metadata:
|
|
name: oauth2-forward-auth
|
|
labels:
|
|
app.kubernetes.io/name: oauth2-proxy
|
|
app.kubernetes.io/component: forward-auth-enforcement
|
|
spec:
|
|
# Services Cilium must resolve to EDS endpoints for the clusters below.
|
|
# oauth2-proxy = the ext_authz backend; the rest = protected upstreams.
|
|
backendServices:
|
|
- name: oauth2-proxy
|
|
namespace: oauth2-proxy
|
|
number: ["80"]
|
|
- name: pihole # PROOF target (docs/13 row 6, auth.mode=forward-auth)
|
|
namespace: pihole
|
|
number: ["80"]
|
|
# Bind this listener to a dedicated LoadBalancer Service (its own L2 VIP).
|
|
# The Service is a PM deploy step (see header) so this CEC stays inert on merge.
|
|
services:
|
|
- name: oauth2-protected-ingress
|
|
namespace: gateway
|
|
listener: oauth2-forward-auth-listener
|
|
ports: [443]
|
|
resources:
|
|
# --- Listener: TLS-terminated HTTPS with the ext_authz HTTP filter ---------
|
|
- "@type": type.googleapis.com/envoy.config.listener.v3.Listener
|
|
name: oauth2-forward-auth-listener
|
|
listenerFilters:
|
|
- name: envoy.filters.listener.tls_inspector
|
|
typedConfig:
|
|
"@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector
|
|
filterChains:
|
|
- filterChainMatch:
|
|
transportProtocol: tls
|
|
serverNames:
|
|
- "*.home.keithjackson.dev"
|
|
transportSocket:
|
|
name: envoy.transport_sockets.tls
|
|
typedConfig:
|
|
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
|
|
commonTlsContext:
|
|
tlsCertificateSdsSecretConfigs:
|
|
# SAME wildcard cert the shared Gateway already syncs to SDS.
|
|
- name: cilium-secrets/gateway-wildcard-home-tls
|
|
filters:
|
|
- name: envoy.filters.network.http_connection_manager
|
|
typedConfig:
|
|
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
|
statPrefix: forward-auth
|
|
useRemoteAddress: true
|
|
upgradeConfigs:
|
|
- upgradeType: websocket
|
|
rds:
|
|
routeConfigName: forward-auth
|
|
httpFilters:
|
|
# ---- THE ENFORCEMENT FILTER (verified stanza) --------------
|
|
- name: envoy.filters.http.ext_authz
|
|
typedConfig:
|
|
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
|
|
transportApiVersion: V3
|
|
# HARD FAIL CLOSED: if oauth2-proxy is down or errors, DENY
|
|
# (never fail open onto an unauthenticated UI — R-IDP-02).
|
|
failureModeAllow: false
|
|
httpService:
|
|
serverUri:
|
|
uri: http://oauth2-proxy.oauth2-proxy.svc.cluster.local
|
|
cluster: oauth2-proxy:oauth2-proxy:80
|
|
timeout: 5s
|
|
# Prepended to the request path -> /oauth2/auth<path>.
|
|
# Per-route group differentiation: rely on oauth2-proxy's
|
|
# global `--allowed-groups=platform-admins` floor
|
|
# (helmrelease.yaml). A future non-admin group would use a
|
|
# dedicated listener rather than a query on path_prefix.
|
|
pathPrefix: /oauth2/auth
|
|
authorizationRequest:
|
|
allowedHeaders:
|
|
patterns:
|
|
- exact: cookie # the oauth2-proxy session
|
|
- exact: authorization
|
|
- exact: x-forwarded-uri
|
|
- exact: x-forwarded-host
|
|
- exact: x-forwarded-proto
|
|
authorizationResponse:
|
|
# On ALLOW (202): forward identity to the upstream app.
|
|
allowedUpstreamHeaders:
|
|
patterns:
|
|
- prefix: x-auth-request-
|
|
- exact: authorization
|
|
# On DENY: return the sign-in redirect/cookie to browser.
|
|
allowedClientHeaders:
|
|
patterns:
|
|
- exact: set-cookie
|
|
- exact: location
|
|
- exact: www-authenticate
|
|
- name: envoy.filters.http.router
|
|
typedConfig:
|
|
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
|
# --- Routes: one virtualHost per PROTECTED host (add Crafty etc. here) ------
|
|
- "@type": type.googleapis.com/envoy.config.route.v3.RouteConfiguration
|
|
name: forward-auth
|
|
virtualHosts:
|
|
- name: pihole
|
|
domains:
|
|
- pihole.home.keithjackson.dev
|
|
- pihole.home.keithjackson.dev:*
|
|
routes:
|
|
- match:
|
|
prefix: /
|
|
route:
|
|
cluster: pihole:pihole:80
|
|
# --- Clusters ---------------------------------------------------------------
|
|
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
|
|
name: oauth2-proxy:oauth2-proxy:80 # the ext_authz backend
|
|
type: EDS
|
|
connectTimeout: 5s
|
|
edsClusterConfig:
|
|
serviceName: oauth2-proxy/oauth2-proxy:80
|
|
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
|
|
name: pihole:pihole:80 # the protected upstream
|
|
type: EDS
|
|
connectTimeout: 5s
|
|
edsClusterConfig:
|
|
serviceName: pihole/pihole:80
|