diff --git a/.github/workflows/helm-validate.yml b/.github/workflows/helm-validate.yml index e1d160c0..2898b304 100644 --- a/.github/workflows/helm-validate.yml +++ b/.github/workflows/helm-validate.yml @@ -167,10 +167,21 @@ jobs: # live, unnoticed, for an unknown period. helm lint and kubeconform both # stayed green throughout. # + # TWO layers, both from deploy/scripts/authentik-path-policy.sh (the + # single source of truth this and the post-deploy live probe share): + # 1. denylist — no routed path may string-prefix-match a forbidden + # Authentik surface under Traefik's matcher. + # 2. CLOSED SET — the routed set must equal the approved set EXACTLY. + # A denylist can only object to surfaces someone remembered to + # enumerate, which is precisely why /applications shipped green. + # Any new Authentik path now fails here until it is approved in + # the policy file with a justification. + # # Self-test FIRST: prove the probe still fails on the exact regression - # (a bare "/application" that matches "/applications") before trusting it - # to pass on this chart's actual rendered output. A check only ever - # observed passing is not evidence of anything. + # (a bare "/application" that matches "/applications") and on an + # unapproved extra path, before trusting it to pass on this chart's + # actual rendered output. A check only ever observed passing is not + # evidence of anything. set -euo pipefail ./deploy/scripts/check-authentik-public-paths.sh --self-test ./deploy/scripts/check-authentik-public-paths.sh rendered/fuzefront/templates diff --git a/deploy/helm/fuzefront/templates/_helpers.tpl b/deploy/helm/fuzefront/templates/_helpers.tpl index 713963d2..b0f51d7a 100644 --- a/deploy/helm/fuzefront/templates/_helpers.tpl +++ b/deploy/helm/fuzefront/templates/_helpers.tpl @@ -116,13 +116,43 @@ authentik_providers_oauth2; there is no SAML provider configured, so `/application/saml/` is not needed). If a SAML provider is ever added, add `/application/saml/` explicitly then — do not widen back to a bare prefix. -`gate-authentik-public-paths` (helm-validate.yml, backed by -deploy/scripts/check-authentik-public-paths.sh) renders both overlays and -fails the build if any Authentik-backed path here is a STRING PREFIX (i.e. -would traefik-match) of a known-forbidden path — /applications, /if/admin, -/if/user, /api/v3/core, /api/v3/providers, /api/v3/policies, /sources. Keep -that forbidden list current if a new sensitive Authentik surface is ever -identified. +THIS LIST IS A CLOSED SET, AND IT IS ENFORCED. Every entry below must also +appear in APPROVED_PUBLIC_PATHS in deploy/scripts/authentik-path-policy.sh +with a written justification. Adding a path here without adding it there +FAILS THE BUILD, and vice versa. That is deliberate: this list used to be +guarded only by a denylist, which by construction can object only to +surfaces somebody remembered to enumerate — which is exactly how +/applications reached the public internet unlisted, unnoticed, and green. +Do not "fix" a closed-set failure by pasting the path into the policy file; +add the browser network capture that proves the OIDC flow needs it, or +remove it from here. + +The policy file is the single source of truth for BOTH guards, which check +it from opposite ends: + + - `gate-authentik-public-paths` (helm-validate.yml, backed by + deploy/scripts/check-authentik-public-paths.sh) renders both overlays + and fails if (1) any Authentik-backed path here is a STRING PREFIX (i.e. + would traefik-match) of a known-forbidden path, or (2) the rendered set + is not EXACTLY the approved set. + - `check-authentik-live-boundary.sh` (prod-post-deploy.yml) black-box + probes the real public edge, catching what a static check cannot: a + Traefik upgrade that changes matcher semantics, or an Ingress applied + outside this chart. + +Both carry a --self-test that must go red on the known-broken input before +the real check is trusted. + +NOTE on the entries below: 5 of the 12 — /if/session-end/, /flows/, /ws/, +/-/ and /outpost.goauthentik.io/ — have NO evidence in this repo +that a browser needs them — they were inherited from the pre-incident list. +They are marked `unverified` in the policy file, which prints a warning for +each on every CI run. /-/ in particular serves only Authentik's health +endpoints, whose sole in-repo consumer is the kubelet probe hitting the pod +directly on port 9000 (authentik.yaml) — which never transits this Ingress. +Settle each with a network capture of a live login and delete the ones that +turn out to be unnecessary; do not delete them blind, since a path a real +login needs is an outage. */}} {{- define "fuzefront.authentikPublicPaths" -}} - /application/o/ diff --git a/deploy/scripts/authentik-path-policy.sh b/deploy/scripts/authentik-path-policy.sh new file mode 100755 index 00000000..e921da7e --- /dev/null +++ b/deploy/scripts/authentik-path-policy.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# authentik-path-policy.sh — THE single source of truth for which Authentik +# surfaces may face the public internet, and which must never. +# +# Sourced by BOTH guards, which check the same policy from opposite ends: +# - check-authentik-public-paths.sh (static, pre-merge: rendered chart) +# - check-authentik-live-boundary.sh (black-box, post-deploy: live edge) +# +# It exists because those two scripts previously carried their own copies of +# FORBIDDEN_PATHS under a "keep the two lists in sync" comment. A comment is +# not a mechanism: nothing failed if they drifted, and a surface added to one +# list but not the other would be guarded pre-merge and unguarded in prod (or +# the reverse) with both jobs green. Deduplicating the list removes the drift +# by construction instead of asking a reviewer to notice it. +# +# ── WHY ANY AUTHENTIK PATH IS PUBLIC AT ALL ─────────────────────────────── +# FuzeFront abstracts the IdP vendor at the API layer: products and the SPA +# call the security service (/v1/security/*, @fuzefront/security-client) and +# never name Authentik. That abstraction is real and holds — there is no +# Authentik path anywhere in frontend/src. +# +# It cannot, however, extend to the browser-redirect leg of OIDC. OIDC is a +# front-channel protocol: the user agent itself must transit the authorization +# endpoint and render the login/consent UI. A backend cannot proxy that away +# without becoming a credential interceptor. So a SMALL, CLOSED set of +# Authentik-native paths is reverse-proxied under app.fuzefront.com (which is +# why the IdP HOST stays invisible — the vendor is hidden at the hostname +# layer even though the path layer necessarily leaks it). +# +# "Small and closed" is the whole security property, and it is what +# APPROVED_PUBLIC_PATHS below makes machine-checkable. + +# ── Layer 1: the CLOSED SET ──────────────────────────────────────────────── +# The complete set of paths that fuzefront.authentikPublicPaths (_helpers.tpl) +# is permitted to route to authentik-server. The static guard asserts the +# rendered chart matches this EXACTLY — no extras, and nothing missing. +# +# This is an ALLOWLIST, and that is deliberate. The guard used to be a +# denylist alone, which can only ever forbid a surface somebody remembered to +# enumerate. That is precisely how `/applications` reached the internet: no +# one had listed it, so no check could object. Under a closed set, ANY new +# Authentik path — enumerated or not, thought of or not — fails the build +# until a human adds it here with a justification. The denylist below is kept +# as a second layer, not the only one. +# +# Format: "||" +# verified = "capture" — confirmed required by an observed browser network +# capture or by code in this repo +# verified = "unverified" — inherited from the pre-incident list; no evidence +# in this repo demonstrates a browser needs it. NOT +# removed blind (removing a path a live login turns +# out to need is an outage), but reported by the +# guard so the debt is visible instead of silent. +# shellcheck disable=SC2034 # consumed by the scripts that source this file +APPROVED_PUBLIC_PATHS=( + "/application/o/|capture|OIDC authorize/token/userinfo/jwks/end-session. The browser MUST reach authorize: backend/security/src/services/oidc.ts pins token/userinfo/jwks to the in-cluster base but leaves authorization_endpoint EXTERNAL, 'it is browser-facing'. Narrowed from a bare /application, which string-prefix-matched /applications." + "/if/flow/|capture|Flow-executor UI. The authorize endpoint 302s the user agent here to render login/consent. frontend/vite.config.ts excludes /if/ from the service-worker navigation fallback for exactly this reason." + "/if/session-end/|unverified|RP-initiated-logout landing page. No code in this repo constructs or navigates to it; grep for session-end/end_session across backend/security/src, frontend/src and deploy/helm returns nothing." + "/source/|capture|Social (Google) sign-in. frontend/vite.config.ts: 'Social sign-in navigates the browser to /source/oauth/login//; without these entries the SW served the cached SPA shell instead of letting the redirect reach Authentik'. Note the SINGULAR /source/ — /sources is the admin source-list API and is forbidden below." + "/api/v3/flows/executor/|capture|The flow-executor SPA calls this from the browser to advance stages. (The security service ALSO drives it, but server-side over the in-cluster base — see authentikBaseUrl() — so that consumer needs no public route.)" + "/api/v3/root/config/|capture|Anonymous-safe bootstrap config. Not observed in the 2026.5.5 capture (config arrives embedded in the initial HTML) but retained so a flow variant that bootstraps from it still renders a login page." + "/static/dist/|capture|Static JS/CSS assets for the flow-executor UI above." + "/static/authentik/|capture|Static branding/theme assets for the flow-executor UI above." + "/outpost.goauthentik.io/|unverified|Embedded-outpost / forward-auth endpoints. No forward-auth consumer exists in this chart; inherited from the pre-incident list." + "/flows/|unverified|No Authentik surface at this bare prefix is referenced anywhere in this repo. Inherited from the pre-incident list. Distinct from /api/v3/flows/executor/ above, which is the one the browser demonstrably uses." + "/ws/|unverified|Authentik websocket endpoint. No browser consumer in this repo; the flow executor is plain HTTP." + "/-/|unverified|Serves Authentik's health endpoints. Its ONLY demonstrated consumer in this repo is the kubelet probe — deploy/helm/fuzefront/templates/authentik.yaml uses httpGet /-/health/live/ and /-/health/ready/ on port 9000 DIRECTLY against the pod, which does not transit the Ingress at all. Nothing in this repo shows a browser needing it." +) + +# ── Layer 2: the DENYLIST ────────────────────────────────────────────────── +# Surfaces that must never be reachable, checked independently of the closed +# set. Under Traefik's PathPrefix (a plain STRING prefix, NOT the Kubernetes +# spec's element-wise segment match) an approved path that is a string prefix +# of any entry here routes that entry to the internet. +# +# Kept even though the closed set now subsumes it: the closed set says "only +# these", this says "and specifically never those", and the live probe can +# only use this half (it cannot enumerate what an edge does NOT route). +# shellcheck disable=SC2034 # consumed by the scripts that source this file +AUTHENTIK_FORBIDDEN_PATHS=( + # The original incident: Authentik's application-list API. + "/applications" + # Admin + user-account UI. + "/if/admin" + "/if/user" + # Admin REST API namespaces. + "/api/v3/core" + "/api/v3/providers" + "/api/v3/policies" + "/api/v3/admin" + "/api/v3/rbac" + "/api/v3/crypto" + "/api/v3/events" + "/api/v3/outposts" + "/api/v3/stages" + "/api/v3/propertymappings" + "/api/v3/managed" + # Admin source-list API. Note this is the PLURAL form; the singular + # /source/ is approved above for the social-login redirect. + "/sources" +) diff --git a/deploy/scripts/check-authentik-live-boundary.sh b/deploy/scripts/check-authentik-live-boundary.sh index 3af5e85b..5cc20ee2 100755 --- a/deploy/scripts/check-authentik-live-boundary.sh +++ b/deploy/scripts/check-authentik-live-boundary.sh @@ -62,19 +62,31 @@ set -euo pipefail APP_ORIGIN_DEFAULT="https://app.fuzefront.com" AUTH_ORIGIN_DEFAULT="https://auth.fuzefront.com" -# Every path that must respond IDENTICALLY (status + content-type) to a -# guaranteed-unmatched, SAME-TIER control path — i.e. must NOT be specially -# routed to authentik-server. Mirrors FORBIDDEN_PATHS in -# check-authentik-public-paths.sh; keep the two lists in sync. -FORBIDDEN_PATHS=( - "/applications" - "/if/admin/" - "/if/user/" - "/api/v3/core/" - "/api/v3/providers/" - "/api/v3/policies/" - "/sources" -) +# The forbidden list comes from the SHARED policy file that the static +# pre-merge guard (check-authentik-public-paths.sh) also sources. The two +# scripts used to carry separate copies under a "keep the two lists in sync" +# comment — a comment is not a mechanism, and a surface added to one list but +# not the other would be guarded in exactly one of the two places with both +# jobs green. Deduplicating removes that drift by construction. +# +# The static guard also enforces a CLOSED SET (an allowlist) which this probe +# structurally cannot: a black-box prober can confirm a named path is not +# routed, but it cannot enumerate every path an edge does NOT route. So the +# two layers are complementary — the closed set catches unanticipated +# surfaces pre-merge, this catches controller/edge behaviour post-deploy. +# shellcheck source=deploy/scripts/authentik-path-policy.sh +. "$(dirname "${BASH_SOURCE[0]}")/authentik-path-policy.sh" + +# Probe each forbidden surface in BOTH its bare and trailing-slash form. The +# canonical policy list is slash-free, but Authentik/Traefik can treat +# `/if/admin` and `/if/admin/` differently, and the original incident path was +# the bare `/applications`. A correctly-unrouted path falls through to the same +# tier control either way, so probing both only widens coverage. +FORBIDDEN_PATHS=() +for _p in "${AUTHENTIK_FORBIDDEN_PATHS[@]}"; do + FORBIDDEN_PATHS+=("$_p" "${_p}/") +done +unset _p # fetch STATUS and CONTENT-TYPE for a URL. Loud on network failure (curl exit # != 0) rather than treating it as a silent pass — a probe that can't reach diff --git a/deploy/scripts/check-authentik-public-paths.sh b/deploy/scripts/check-authentik-public-paths.sh index 5d847633..febf7a76 100755 --- a/deploy/scripts/check-authentik-public-paths.sh +++ b/deploy/scripts/check-authentik-public-paths.sh @@ -36,26 +36,23 @@ EXCLUDED_INGRESS_NAMES=( "fuzefront-authentik-admin" ) -# Every path that must NEVER be reachable through an Authentik-backed Ingress -# path in this chart. Extend this list if a new sensitive Authentik surface -# is identified — see the comment above fuzefront.authentikPublicPaths for -# the full rationale (admin UI, admin REST API, application-list API). -FORBIDDEN_PATHS=( - "/applications" - "/if/admin" - "/if/user" - "/api/v3/core" - "/api/v3/providers" - "/api/v3/policies" - "/sources" -) +# The forbidden list AND the approved closed set both come from the shared +# policy file, which both this guard and the live post-deploy probe source. +# They used to be duplicated under a "keep the two lists in sync" comment; +# nothing enforced that, so the two could drift silently in opposite +# directions with both jobs green. See authentik-path-policy.sh for the full +# rationale, including why the closed set (an ALLOWLIST) was added: a denylist +# alone can only forbid a surface somebody remembered to enumerate, which is +# exactly how /applications reached the internet unlisted and unnoticed. +# shellcheck source=deploy/scripts/authentik-path-policy.sh +. "$(dirname "${BASH_SOURCE[0]}")/authentik-path-policy.sh" -# --- core check: does any allowed path string-prefix-match a forbidden one? --- -# $1 = directory of rendered YAML templates to scan -check_dir() { +# --- extraction: which paths does the rendered chart route to authentik? --- +# $1 = directory of rendered YAML templates to scan. +# Prints one path per line, sorted+deduped. Shared by BOTH checks below so they +# can never disagree about what the chart actually renders. +extract_paths() { local dir="$1" - local fail=0 - local allowed_paths # Pull every `path: ` that immediately follows a # `- path:` line whose sibling backend targets authentik-server, across all # rendered Ingress manifests. We don't have a YAML parser dependency here @@ -78,35 +75,42 @@ check_dir() { fi done - allowed_paths=$( - for f in "$dir"/*.yaml; do - [ -f "$f" ] || continue - grep -q '^kind: Ingress$' "$f" || continue - awk -v excluded="$excluded_pattern" ' - BEGIN { in_ingress = 0; skip = 0; pending = "" } - /^---[ \t]*$/ { pending = ""; next } - /^kind:[ \t]*Ingress[ \t]*$/ { in_ingress = 1; skip = 0; pending = ""; next } - /^kind:[ \t]*/ && !/Ingress/ { in_ingress = 0; skip = 0; pending = ""; next } - in_ingress && /^ name:[ \t]*/ { - name = $0 - sub(/^ name:[ \t]*/, "", name) - skip = (excluded != "" && name ~ ("^(" excluded ")$")) ? 1 : 0 - next - } - skip { next } - /^[ \t]*-[ \t]*path:[ \t]*/ { - split($0, a, "path:") - gsub(/^[ \t]+|[ \t]+$/, "", a[2]) - pending = a[2] - next - } - /name: authentik-server/ && pending != "" { - print pending - pending = "" - } - ' "$f" - done | sort -u - ) + for f in "$dir"/*.yaml; do + [ -f "$f" ] || continue + grep -q '^kind: Ingress$' "$f" || continue + awk -v excluded="$excluded_pattern" ' + BEGIN { in_ingress = 0; skip = 0; pending = "" } + /^---[ \t]*$/ { pending = ""; next } + /^kind:[ \t]*Ingress[ \t]*$/ { in_ingress = 1; skip = 0; pending = ""; next } + /^kind:[ \t]*/ && !/Ingress/ { in_ingress = 0; skip = 0; pending = ""; next } + in_ingress && /^ name:[ \t]*/ { + name = $0 + sub(/^ name:[ \t]*/, "", name) + skip = (excluded != "" && name ~ ("^(" excluded ")$")) ? 1 : 0 + next + } + skip { next } + /^[ \t]*-[ \t]*path:[ \t]*/ { + split($0, a, "path:") + gsub(/^[ \t]+|[ \t]+$/, "", a[2]) + pending = a[2] + next + } + /name: authentik-server/ && pending != "" { + print pending + pending = "" + } + ' "$f" + done | sort -u +} + +# --- check 1 (denylist): does any routed path string-prefix-match a forbidden one? --- +# $1 = directory of rendered YAML templates to scan +check_dir() { + local dir="$1" + local fail=0 + local allowed_paths + allowed_paths="$(extract_paths "$dir")" if [ -z "$allowed_paths" ]; then echo "::error::no Authentik-backed Ingress paths found in $dir — the extraction regex" \ @@ -119,7 +123,7 @@ check_dir() { printf ' %s\n' "$p" done <<< "$allowed_paths" - for forbidden in "${FORBIDDEN_PATHS[@]}"; do + for forbidden in "${AUTHENTIK_FORBIDDEN_PATHS[@]}"; do for allowed in $allowed_paths; do # Traefik's PathPrefix: does $forbidden start with the literal string # $allowed? (bash prefix match, exactly what PathPrefix does — no @@ -139,6 +143,109 @@ check_dir() { return $fail } +# --- check 2 (closed set): is the routed set EXACTLY the approved set? --- +# The denylist above can only object to a surface somebody remembered to +# enumerate — which is why `/applications`, unlisted, reached the internet +# with every check green. This asserts set EQUALITY against +# APPROVED_PUBLIC_PATHS instead, so a path nobody anticipated still fails. +# $1 = directory of rendered YAML templates to scan +check_closed_set() { + local dir="$1" + local fail=0 + local rendered approved extra missing entry path verified why + rendered="$(extract_paths "$dir")" + + if [ -z "$rendered" ]; then + echo "::error::no Authentik-backed Ingress paths found in $dir — the extraction regex" \ + "itself may be broken (this check must never silently pass on nothing to check)." >&2 + return 1 + fi + + approved="$( + for entry in "${APPROVED_PUBLIC_PATHS[@]}"; do + printf '%s\n' "${entry%%|*}" + done | sort -u + )" + + # Rendered but NOT approved — a newly exposed Authentik surface. + extra="$(comm -23 <(printf '%s\n' "$rendered") <(printf '%s\n' "$approved") || true)" + # Approved but NOT rendered — the chart dropped a path, or the policy file is + # stale. Either way the two disagree and a human must reconcile them. + missing="$(comm -13 <(printf '%s\n' "$rendered") <(printf '%s\n' "$approved") || true)" + + if [ -n "$extra" ]; then + while IFS= read -r path; do + [ -n "$path" ] || continue + echo "::error::Authentik Ingress path '$path' is routed to authentik-server but is NOT in" \ + "APPROVED_PUBLIC_PATHS (deploy/scripts/authentik-path-policy.sh). Every publicly" \ + "routed IdP surface must be explicitly approved with a justification — a denylist" \ + "only ever catches surfaces someone thought to enumerate, which is how /applications" \ + "shipped. If this path is genuinely required by the browser OIDC flow, add it there" \ + "with the network capture that proves it; otherwise remove it from" \ + "fuzefront.authentikPublicPaths in _helpers.tpl." >&2 + done <<< "$extra" + fail=1 + fi + + if [ -n "$missing" ]; then + while IFS= read -r path; do + [ -n "$path" ] || continue + echo "::error::'$path' is approved in deploy/scripts/authentik-path-policy.sh but the chart" \ + "no longer routes it. The policy and the chart have drifted — either restore it in" \ + "fuzefront.authentikPublicPaths (_helpers.tpl) or drop it from APPROVED_PUBLIC_PATHS." >&2 + done <<< "$missing" + fail=1 + fi + + if [ "$fail" -eq 0 ]; then + echo "Closed set OK: the ${#APPROVED_PUBLIC_PATHS[@]} routed Authentik paths are exactly the approved set." + fi + + # Surface the approved-but-unproven entries. These are NOT a failure: removing + # a path that a live login turns out to need is an outage, and this session + # has no cluster or browser capture to settle it. Printing them each run keeps + # the debt visible instead of letting it sit silently in a list forever. + local unverified_count=0 + for entry in "${APPROVED_PUBLIC_PATHS[@]}"; do + path="${entry%%|*}" + verified="${entry#*|}"; verified="${verified%%|*}" + why="${entry##*|}" + if [ "$verified" = "unverified" ]; then + unverified_count=$((unverified_count + 1)) + echo "::warning::Authentik public path '$path' is approved but UNVERIFIED: $why" + fi + done + if [ "$unverified_count" -gt 0 ]; then + echo "$unverified_count of ${#APPROVED_PUBLIC_PATHS[@]} approved paths lack evidence that a browser needs them." \ + "Each is a candidate for removal once a network capture of a live login settles it." + fi + + return $fail +} + +# Build a rendered-Ingress fixture routing exactly the given paths to +# authentik-server. Used by the self-test to construct both the known-broken +# and the known-good inputs from real data rather than hand-copied YAML. +# $1 = target directory, remaining args = paths +write_fixture() { + local dir="$1"; shift + { + printf 'apiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n name: fuzefront\nspec:\n rules:\n - host: app.fuzefront.com\n http:\n paths:\n' + local p + for p in "$@"; do + printf ' - path: %s\n pathType: Prefix\n backend:\n service:\n name: authentik-server\n port:\n number: 9000\n' "$p" + done + } > "$dir/ingress.yaml" +} + +# Every approved path, as a plain array — the "correct" fixture input. +approved_path_list() { + local entry + for entry in "${APPROVED_PUBLIC_PATHS[@]}"; do + printf '%s\n' "${entry%%|*}" + done +} + self_test() { echo "Self-test: proving the probe FAILS on the known-broken input first" \ "(a check only ever observed passing is not evidence of anything)." @@ -212,6 +319,48 @@ EOF exit 1 fi echo "Self-test OK: probe correctly passed on narrowed '/application/o/'." + rm -rf "$tmp" + + # ── Closed-set check ──────────────────────────────────────────────────── + # The denylist above only objects to enumerated surfaces. These three cases + # prove the set-equality check fires on the two ways the chart and the + # policy can disagree, and stays quiet when they agree. + local -a approved + mapfile -t approved < <(approved_path_list) + + # (a) RED: an EXTRA path nobody approved. Deliberately NOT one of the + # enumerated forbidden prefixes — the whole point is catching a surface + # the denylist has never heard of. + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + write_fixture "$tmp" "${approved[@]}" "/api/v3/some-future-authentik-surface/" + if check_closed_set "$tmp" >/dev/null 2>&1; then + echo "::error::self-test FAILED — the closed-set check passed on a manifest routing an" \ + "unapproved Authentik path. It would not have caught /applications either." >&2 + exit 1 + fi + echo "Self-test OK: closed-set check correctly failed on an unapproved extra path." + + # (b) RED: an approved path silently dropped from the chart. + rm -rf "$tmp"; tmp="$(mktemp -d)" + write_fixture "$tmp" "${approved[@]:1}" + if check_closed_set "$tmp" >/dev/null 2>&1; then + echo "::error::self-test FAILED — the closed-set check passed while the chart was missing" \ + "an approved path, so policy/chart drift would go unreported." >&2 + exit 1 + fi + echo "Self-test OK: closed-set check correctly failed on a missing approved path." + + # (c) GREEN: exactly the approved set. + rm -rf "$tmp"; tmp="$(mktemp -d)" + write_fixture "$tmp" "${approved[@]}" + if ! check_closed_set "$tmp" >/dev/null 2>&1; then + echo "::error::self-test FAILED — the closed-set check rejected exactly the approved set." \ + "It must pass on the correct shape." >&2 + exit 1 + fi + echo "Self-test OK: closed-set check correctly passed on exactly the approved set." + rm -rf "$tmp" trap - EXIT } @@ -225,6 +374,11 @@ case "${1:-}" in exit 2 ;; *) - check_dir "$1" + # Both layers, and report both before exiting so one failure does not mask + # the other. + rc=0 + check_dir "$1" || rc=1 + check_closed_set "$1" || rc=1 + exit $rc ;; esac diff --git a/docs/planning/provider-agnostic-security-layer.md b/docs/planning/provider-agnostic-security-layer.md index e960859c..7e33a781 100644 --- a/docs/planning/provider-agnostic-security-layer.md +++ b/docs/planning/provider-agnostic-security-layer.md @@ -21,6 +21,67 @@ **Naming rule for the whole effort:** no `authentik` or `permit` in any consumer-facing path, type, field, config key, or doc. Open-standard protocol terms (OIDC/OAuth2/JWKS/PKCE) are acceptable but kept internal/server-side where practical. Provider names live only inside the adapter implementations and server-only env. +### What "abstracted" means at each layer — read this before calling a routed IdP path a bug + +This plan's abstraction is **host-level and API-level. It is deliberately not +path-level**, and conflating the three has now twice produced a report that the +Ingress "contradicts" the design. Decision 1 above says so directly — *"IdP +hiding = Model 1 (**reverse-proxy Authentik under the app host**)"* — and a +reverse proxy necessarily serves the upstream's own paths. + +| Layer | Abstracted? | What that means concretely | +|---|---|---| +| **Consumer / SDK API** | **Yes, for routing.** | Products and the SPA call `/v1/security/*` via `@fuzefront/security-client`. Verifiable: `frontend/src` contains **no Authentik path** — no `/if/flow`, `/application/o`, `/api/v3`, or `goauthentik` anywhere. The auth calls it does make are `/api/v1/security/session`, `/v1/security/session/exchange`, `/api/v1/security/employee/*`. | +| **IdP hostname** | **Yes, fully.** | The browser only ever sees `app.fuzefront.com`. `auth.fuzefront.com` is named in no consumer contract. This is the "one hard constraint" above. | +| **Server-to-IdP calls** | **Yes, fully.** | Token/userinfo/JWKS/discovery and the server-brokered password + enrollment flows are pinned to the in-cluster Service via `AUTHENTIK_BASE_URL`; none of them transits the public edge. | +| **Browser-transited OIDC paths** | **No — and cannot be.** | OIDC is a *front-channel* protocol: the user agent itself must reach the authorization endpoint and render the login/consent UI. A backend cannot proxy that leg away without becoming a credential interceptor. `backend/security/src/services/oidc.ts` encodes exactly this split — it rewrites `token_endpoint`, `userinfo_endpoint` and `jwks_uri` onto the in-cluster host while leaving `authorization_endpoint` **EXTERNAL**, commented *"it is browser-facing"*. | + +So a **small, closed set** of Authentik-native paths (`/application/o/`, +`/if/flow/`, `/source/`, the flow-executor API and its static assets) is +reverse-proxied under `app.fuzefront.com`. Those paths are the protocol +minimum, not a leak, and their presence is what *implements* Model 1 rather +than violating it. + +Two consequences worth stating explicitly, because both have been misread: + +- **`/applications` was never part of that set and was never intended to be + public.** It reached the internet because Traefik implements `pathType: + Prefix` as a plain *string* prefix rather than the Kubernetes spec's + element-wise segment match, so a bare `- /application` silently also matched + `/applications`. That was a controller-semantics bug, not a design decision, + and the fact that it was *possible to ship green* is the real finding. +- **The naming rule is violated in a second, unrelated place, and it is a real + finding rather than a protocol necessity.** The rule binds "any consumer-facing + path, type, field, config key, **or doc**" — and the portals-directory UI ships + user-visible strings naming the vendor: + `frontend/src/components/portalsDirectory/PortalTierBadge.tsx:8-9` + (*"Shares the root Authentik"*, *"Its own Authentik instance"*) and + `PortalCard.tsx:58` (*"Authenticates against its own Authentik instance"*), + with the same wording mirrored in `adminPortalsService.ts:33`. These are + admin-facing rather than product-facing, and they are cosmetic strings that + nothing routes on — but they are not required by any protocol and could be + neutralised ("shared identity provider" / "dedicated identity provider") + without touching behaviour. Tracked here rather than fixed in the same PR as + the Ingress guard, since it is a separate surface with a separate owner. +- **At the path layer the naming rule is not fully satisfiable.** + `/static/authentik/` and `/outpost.goauthentik.io/` literally contain the + vendor name, and cannot be renamed without breaking the reverse proxy — + Authentik ignores `X-Forwarded-Prefix` and builds absolute URLs at its own + root paths. Treat the naming rule as binding on paths **we** design + (`/v1/security/*`), not on an upstream's native routes that we merely proxy. + +**The closed set is enforced, not documented.** +`deploy/scripts/authentik-path-policy.sh` is the machine-readable source of +truth: it carries each approved path with its justification and whether a +browser is actually *proven* to need it, plus the denylist of surfaces that +must never be routed. Both guards source it — +`check-authentik-public-paths.sh` (pre-merge, models Traefik's string-prefix +matcher against the rendered chart and asserts the routed set equals the +approved set exactly) and `check-authentik-live-boundary.sh` (post-deploy, +black-box probe of the real edge). Adding any Authentik path to +`fuzefront.authentikPublicPaths` now fails CI until it is approved there with +a justification. + --- ## Target architecture