From 1a323cadef99261e4ac61088339e82a3f1bac340 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 18 Jul 2026 22:29:55 +0200 Subject: [PATCH 1/7] fix(security): restrict tenant route hostnames --- docs/TENANTS.md | 8 ++- .../restrict-tenant-http-route-hostnames.yaml | 67 +++++++++++++++++++ .../cluster-policies/kustomization.yaml | 1 + 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml diff --git a/docs/TENANTS.md b/docs/TENANTS.md index 2e8012009..d394c0420 100644 --- a/docs/TENANTS.md +++ b/docs/TENANTS.md @@ -220,8 +220,12 @@ carry itself**: here** — **hostnames**, **`gethomepage.dev/*` dashboard annotations**, routes, and app config: - List all of a tenant's hostnames (local + prod + any custom domains) directly in its - `deploy/httproute.yaml`. The Gateway attaches only the hostnames that match a listener in a - given environment, so listing them all is safe everywhere. + `deploy/httproute.yaml`, and add every approved hostname to the platform-side + Kyverno allow-list in + [`restrict-tenant-http-route-hostnames.yaml`](../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml). + The shared platform Gateway intentionally accepts routes from all namespaces, so the + admission policy is the boundary that prevents a tenant artifact from claiming another + platform hostname. - The platform's `homepage` app discovers `gethomepage.dev/*` annotations on the tenant's HTTPRoute cluster-wide, so the tenant authors them in its own artifact — they are tenant self-presentation, not platform config. diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml new file mode 100644 index 000000000..0eefa772a --- /dev/null +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml @@ -0,0 +1,67 @@ +# Tenant GitOps service accounts may create HTTPRoutes in their own namespaces, +# but they must not be able to claim arbitrary hosts on the shared Gateway. +# Keep this allow-list in platform config so a compromised tenant artifact cannot +# intercept another platform hostname under the wildcard Gateway certificate. +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: restrict-tenant-http-route-hostnames + annotations: + policies.kyverno.io/title: Restrict Tenant HTTPRoute Hostnames + policies.kyverno.io/category: Multi-Tenancy, Gateway API, Best Practices + policies.kyverno.io/severity: high + policies.kyverno.io/subject: HTTPRoute + policies.kyverno.io/minversion: 1.6.0 + policies.kyverno.io/description: >- + Requires tenant-managed HTTPRoutes to declare hostnames and confines each + tenant namespace to its approved public hostnames. This prevents tenant + OCI artifacts from attaching routes for other platform services to the + shared hostname-less Gateway listener. +spec: + validationFailureAction: Enforce + # Uses admission request context, so it cannot run as a background scan. + background: false + rules: + - name: restrict-wedding-app-hostnames + match: + any: + - resources: + kinds: + - HTTPRoute + namespaces: + - wedding-app + validate: + message: "wedding-app HTTPRoutes may only use approved wedding-app hostnames." + deny: + conditions: + any: + - key: "{{ request.object.spec.hostnames || `[]` }}" + operator: Equals + value: [] + - key: "{{ request.object.spec.hostnames || `[]` }}" + operator: AnyNotIn + value: + - wedding.platform.devantler.tech + - wedding.platform.lan + - name: restrict-ascoachingogvaner-hostnames + match: + any: + - resources: + kinds: + - HTTPRoute + namespaces: + - ascoachingogvaner + validate: + message: "ascoachingogvaner HTTPRoutes may only use approved ascoachingogvaner hostnames." + deny: + conditions: + any: + - key: "{{ request.object.spec.hostnames || `[]` }}" + operator: Equals + value: [] + - key: "{{ request.object.spec.hostnames || `[]` }}" + operator: AnyNotIn + value: + - ascoachingogvaner.dk + - www.ascoachingogvaner.dk + - ascoachingogvaner.platform.lan diff --git a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml index 381a7b082..4fc98fc07 100644 --- a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml +++ b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml @@ -13,6 +13,7 @@ resources: - best-practices/disallow-latest-tag.yaml - best-practices/propagate-reloader-to-flagger-primary.yaml - best-practices/restrict-tenant-secret-stores.yaml + - best-practices/restrict-tenant-http-route-hostnames.yaml - best-practices/validate-host-restrictions.yaml - best-practices/validate-pdb-drain-safe.yaml - best-practices/validate-pod-security.yaml From 8fbb9d3cd0251905fc27680db076309617c33a6b Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 16 Aug 2026 01:27:38 +0200 Subject: [PATCH 2/7] fix(security): close the non-HTTPRoute path to the shared Gateway listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tenant hostname allow-list matches HTTPRoute, but the tenant role also granted grpcroutes/tcproutes/tlsroutes/udproutes and the Gateway listeners pinned no route kinds — and an HTTPS listener accepts GRPCRoute by default. A tenant artifact could therefore claim another service's hostname under the wildcard certificate without the policy ever running. Narrow the role to httproutes + referencegrants (what its own comment already described), pin allowedRoutes.kinds to HTTPRoute on all four listeners, and add the negative coverage: a kyverno fixture for the hostname policy, which had none, plus a structural test over the rendered prod overlay. --- .github/workflows/ci.yaml | 3 + docs/TENANTS.md | 8 + .../cluster-roles/gateway-tenant-edit.yaml | 18 ++- k8s/bases/infrastructure/gateway/gateway.yaml | 14 ++ .../add-ascoachingogvaner-dk-listeners.yaml | 10 ++ .../test-tenant-route-hostname-boundary.sh | 101 ++++++++++++ .../kyverno-test.yaml | 54 +++++++ .../resources.yaml | 153 ++++++++++++++++++ 8 files changed, 357 insertions(+), 4 deletions(-) create mode 100755 scripts/tests/test-tenant-route-hostname-boundary.sh create mode 100644 tests/restrict-tenant-http-route-hostnames/kyverno-test.yaml create mode 100644 tests/restrict-tenant-http-route-hostnames/resources.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b2edda395..32b85f64c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -100,6 +100,7 @@ jobs: - 'scripts/tests/test-openbao-oidc-role.sh' - 'scripts/tests/test-github-config-role-activation-parity.sh' - 'scripts/tests/test-restrict-tenant-secret-stores.sh' + - 'scripts/tests/test-tenant-route-hostname-boundary.sh' - 'scripts/tests/test-kyverno-admission-vpa.sh' - 'scripts/tests/kyverno-admission-vpa-rules.yaml' # The image-verifier liveness checker and its test are one @@ -402,6 +403,8 @@ jobs: kyverno test ./tests shellcheck scripts/tests/test-restrict-tenant-secret-stores.sh bash scripts/tests/test-restrict-tenant-secret-stores.sh + shellcheck scripts/tests/test-tenant-route-hostname-boundary.sh + bash scripts/tests/test-tenant-route-hostname-boundary.sh - name: 🚦 Validate default-off Cilium bandwidth manager if: needs.changes.outputs.k8s == 'true' diff --git a/docs/TENANTS.md b/docs/TENANTS.md index 93d12fd45..18dc2ce8b 100644 --- a/docs/TENANTS.md +++ b/docs/TENANTS.md @@ -226,6 +226,14 @@ here** — **hostnames**, **`gethomepage.dev/*` dashboard annotations**, routes, The shared platform Gateway intentionally accepts routes from all namespaces, so the admission policy is the boundary that prevents a tenant artifact from claiming another platform hostname. +- **`HTTPRoute` is the only route kind a tenant can use.** The hostname allow-list above + matches `HTTPRoute`, so every other Gateway API route kind is closed off rather than left + to reach the shared listener unchecked: the tenant role grants only `httproutes` and + `referencegrants`, and each Gateway listener pins `allowedRoutes.kinds` to `HTTPRoute` + (an HTTPS listener would otherwise accept `GRPCRoute` too). A tenant needing another kind + is a platform change, not a tenant one — extend the hostname policy to cover that kind + and relax both layers together. `scripts/tests/test-tenant-route-hostname-boundary.sh` + pins all three in CI. - The platform's `homepage` app discovers `gethomepage.dev/*` annotations on the tenant's HTTPRoute cluster-wide, so the tenant authors them in its own artifact — they are tenant self-presentation, not platform config. diff --git a/k8s/bases/infrastructure/cluster-roles/gateway-tenant-edit.yaml b/k8s/bases/infrastructure/cluster-roles/gateway-tenant-edit.yaml index a3161696d..4882fa7b9 100644 --- a/k8s/bases/infrastructure/cluster-roles/gateway-tenant-edit.yaml +++ b/k8s/bases/infrastructure/cluster-roles/gateway-tenant-edit.yaml @@ -1,6 +1,20 @@ # Aggregate ClusterRole that extends the tenant role (`tenant-edit`, and the # built-in `edit`) with Gateway API verbs so a tenant ServiceAccount can manage # HTTPRoute / ReferenceGrant resources in its namespace. +# +# HTTPRoute is the ONLY route kind granted, and that is a security boundary +# rather than a convenience. `restrict-tenant-http-route-hostnames` confines a +# tenant to its approved hostnames by matching `HTTPRoute`, so any other route +# kind a tenant could create would reach the shared hostname-less listener +# without that policy ever running — a compromised tenant artifact could then +# claim another service's hostname under the wildcard Gateway certificate. +# Granting `grpcroutes` here previously left exactly that gap open, because an +# HTTPS listener accepts GRPCRoute as well as HTTPRoute by default. +# +# The Gateway listeners pin `allowedRoutes.kinds` to HTTPRoute as the second +# layer, so a route created by some other principal still cannot attach. If a +# route kind is ever genuinely needed, extend the hostname policy to cover it +# and relax BOTH layers together — never this one alone. --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -13,10 +27,6 @@ rules: - apiGroups: ["gateway.networking.k8s.io"] resources: - httproutes - - grpcroutes - - tcproutes - - tlsroutes - - udproutes - referencegrants verbs: - get diff --git a/k8s/bases/infrastructure/gateway/gateway.yaml b/k8s/bases/infrastructure/gateway/gateway.yaml index f9cde6485..abf0a08c6 100644 --- a/k8s/bases/infrastructure/gateway/gateway.yaml +++ b/k8s/bases/infrastructure/gateway/gateway.yaml @@ -6,6 +6,14 @@ metadata: spec: gatewayClassName: cilium listeners: + # Every listener pins `allowedRoutes.kinds` to HTTPRoute. Left unset, an + # HTTPS listener accepts GRPCRoute as well (Gateway API's documented + # default), and `restrict-tenant-http-route-hostnames` matches HTTPRoute + # only — so a GRPCRoute could claim another service's hostname on this + # shared, hostname-less listener without the hostname policy ever running. + # The tenant RBAC (`cluster-roles/gateway-tenant-edit.yaml`) grants only + # HTTPRoute for the same reason; this is the second layer, covering routes + # created by any other principal. Relax both together or neither. - name: https port: 443 protocol: HTTPS @@ -16,9 +24,15 @@ spec: allowedRoutes: namespaces: from: All + kinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute - name: http port: 80 protocol: HTTP allowedRoutes: namespaces: from: All + kinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute diff --git a/k8s/providers/hetzner/infrastructure/patches/add-ascoachingogvaner-dk-listeners.yaml b/k8s/providers/hetzner/infrastructure/patches/add-ascoachingogvaner-dk-listeners.yaml index c232cb25b..ad7ee1188 100644 --- a/k8s/providers/hetzner/infrastructure/patches/add-ascoachingogvaner-dk-listeners.yaml +++ b/k8s/providers/hetzner/infrastructure/patches/add-ascoachingogvaner-dk-listeners.yaml @@ -11,6 +11,10 @@ # Certificate), and the tenant ships a ReferenceGrant authorising this Gateway to # read that Secret. Until the cert issues the listener simply stays # ResolvedRefs=False (Gateway still Programmed) — it never gates reconciliation. +# +# Both listeners pin `allowedRoutes.kinds` to HTTPRoute, matching the base +# Gateway's listeners — see k8s/bases/infrastructure/gateway/gateway.yaml for +# why an unpinned HTTPS listener is a hostname-claiming gap. - op: add path: /spec/listeners/- value: @@ -28,6 +32,9 @@ allowedRoutes: namespaces: from: All + kinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute - op: add path: /spec/listeners/- value: @@ -45,3 +52,6 @@ allowedRoutes: namespaces: from: All + kinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute diff --git a/scripts/tests/test-tenant-route-hostname-boundary.sh b/scripts/tests/test-tenant-route-hostname-boundary.sh new file mode 100755 index 000000000..15c59af4b --- /dev/null +++ b/scripts/tests/test-tenant-route-hostname-boundary.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Negative coverage for the tenant hostname boundary on the shared Gateway. +# +# The boundary is enforced by `restrict-tenant-http-route-hostnames`, which +# matches HTTPRoute. That leaves a gap unless the other route kinds cannot reach +# the shared listener at all: an HTTPS listener accepts GRPCRoute as well as +# HTTPRoute by default, so a tenant able to create a GRPCRoute could claim +# another service's hostname under the wildcard certificate without the policy +# ever running. +# +# Two layers close it, and this test pins BOTH — either alone is bypassable: +# 1. tenant RBAC grants only `httproutes` (+ `referencegrants`); +# 2. every Gateway listener pins `allowedRoutes.kinds` to HTTPRoute, covering +# routes created by any other principal. +# Layer 3 re-checks that the hostname policy itself still denies, so a future +# edit cannot leave the allow-list admitting everything. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" + +role_file="${repo_root}/k8s/bases/infrastructure/cluster-roles/gateway-tenant-edit.yaml" +policy="${repo_root}/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml" +fixtures="${repo_root}/tests/restrict-tenant-http-route-hostnames/resources.yaml" +# The prod overlay is what carries every listener: two come from the base +# Gateway and two more are appended by the hetzner JSON6902 patch, which a +# per-file check would never see. +overlay="${repo_root}/k8s/providers/hetzner/infrastructure" + +workdir="$(mktemp -d)" +trap 'rm -rf "${workdir}"' EXIT +render="${workdir}/render.yaml" +gateway="${workdir}/gateway.yaml" +output_file="${workdir}/kyverno.out" + +fail() { + echo "::error::$1" + exit 1 +} + +# --- Layer 1: tenant RBAC grants only HTTPRoute ----------------------------- +# Positive control first: a typo'd path or a renamed role would otherwise make +# every assertion below pass over an empty document set. +role_name="$(yq eval 'select(.kind=="ClusterRole") | .metadata.name' "${role_file}")" +[ "${role_name}" = "gateway-tenant-edit" ] || + fail "expected ClusterRole gateway-tenant-edit in ${role_file}, found '${role_name}'" + +granted="$(yq eval \ + 'select(.kind=="ClusterRole") | .rules[] | select(.apiGroups[] == "gateway.networking.k8s.io") | .resources[]' \ + "${role_file}" | sort | tr '\n' ' ')" +[ -n "${granted// /}" ] || + fail "gateway-tenant-edit grants no gateway.networking.k8s.io resources at all — the rule shape changed, so this test is no longer checking anything" + +expected_grant="httproutes referencegrants " +[ "${granted}" = "${expected_grant}" ] || + fail "gateway-tenant-edit must grant exactly 'httproutes referencegrants'; found '${granted}'. Any other route kind bypasses restrict-tenant-http-route-hostnames — extend that policy and the listener kinds before widening this." + +# --- Layer 2: every listener pins allowedRoutes.kinds to HTTPRoute ---------- +kubectl kustomize "${overlay}" >"${render}" 2>"${workdir}/render.err" || + fail "prod infrastructure overlay failed to build: $(tail -5 "${workdir}/render.err")" + +yq eval 'select(.kind=="Gateway" and .metadata.name=="platform")' "${render}" >"${gateway}" +listener_count="$(yq eval '.spec.listeners | length' "${gateway}")" +# The live Gateway had 4 listeners when this boundary was written. Requiring at +# least that many keeps an empty or mis-selected render from passing vacuously — +# adding a listener is fine, silently losing them all is not. +case "${listener_count}" in + '' | null | 0) fail "no Gateway/platform listeners found in the rendered overlay — the render or the selector changed, so this test is checking nothing" ;; +esac +[ "${listener_count}" -ge 4 ] || + fail "expected at least 4 Gateway listeners in the prod overlay, found ${listener_count} — did the ascoachingogvaner listener patch stop applying?" + +# Compare the kinds as a JOINED STRING, never as an array. yq's `!=` does not +# do deep array equality — `(… | sort) != ["HTTPRoute"]` evaluates to true even +# for a correctly pinned listener, which reported all four as unpinned when this +# test was written. It failed closed there, but the same shape fails OPEN when +# the sense is reversed, so keep the comparison on strings. +unpinned="$(yq eval \ + '[.spec.listeners[] | select(((.allowedRoutes.kinds // []) | map(.kind) | sort | join(",")) != "HTTPRoute")] | .[].name' \ + "${gateway}" | tr '\n' ' ')" +[ -z "${unpinned// /}" ] || + fail "these Gateway listeners do not pin allowedRoutes.kinds to exactly [HTTPRoute]: ${unpinned}— an unpinned HTTPS listener also accepts GRPCRoute, which restrict-tenant-http-route-hostnames does not match." + +# --- Layer 3: the hostname policy still denies ------------------------------ +# `kyverno test` reports a missing named rule as Excluded and can pass +# vacuously, so exercise the policy directly (same second gate as +# test-restrict-tenant-secret-stores.sh): the fixture must admit 3 and deny 4. +if kyverno apply "${policy}" \ + --resource "${fixtures}" \ + --remove-color >"${output_file}" 2>&1; then + fail "tenant HTTPRoute hostname policy admitted every fixture; no deny rule executed" +fi + +expected_summary="pass: 3, fail: 4, warn: 0, error: 0, skip: 0" +if ! grep -Fq "${expected_summary}" "${output_file}"; then + echo "::error::tenant HTTPRoute hostname policy returned an unexpected allow/deny verdict" + sed -n '1,80p' "${output_file}" + exit 1 +fi + +echo "Tenant route boundary holds: RBAC grants HTTPRoute only, all ${listener_count} listeners pin kinds to HTTPRoute, and the hostname policy enforced 3-admit/4-deny." diff --git a/tests/restrict-tenant-http-route-hostnames/kyverno-test.yaml b/tests/restrict-tenant-http-route-hostnames/kyverno-test.yaml new file mode 100644 index 000000000..bbad35386 --- /dev/null +++ b/tests/restrict-tenant-http-route-hostnames/kyverno-test.yaml @@ -0,0 +1,54 @@ +--- +apiVersion: cli.kyverno.io/v1alpha1 +kind: Test +metadata: + name: restrict-tenant-http-route-hostnames +policies: + - >- + ../../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml +resources: + - resources.yaml +# The policy matches namespaces by NAME (not a namespaceSelector), so unlike the +# restrict-tenant-issuer-refs fixture no Values file is needed to avoid vacuous +# "Excluded" results. +results: + # The escalation the boundary exists to stop, plus the two ways around a naive + # allow-list: omitting hostnames entirely, and smuggling a rogue hostname in + # beside an approved one. + - policy: restrict-tenant-http-route-hostnames + rule: restrict-wedding-app-hostnames + resources: + - wedding-app/wedding-claims-other-host + - wedding-app/wedding-omits-hostnames + - wedding-app/wedding-mixes-approved-and-rogue + kind: HTTPRoute + result: fail + # The paved road must stay open — single approved hostname and both at once. + - policy: restrict-tenant-http-route-hostnames + rule: restrict-wedding-app-hostnames + resources: + - wedding-app/wedding-approved-host + - wedding-app/wedding-approved-both-hosts + kind: HTTPRoute + result: pass + # Per-tenant allow-lists are not pooled: the other tenant's hostname is still + # a rogue claim here. + - policy: restrict-tenant-http-route-hostnames + rule: restrict-ascoachingogvaner-hostnames + resources: + - ascoachingogvaner/ascoachingogvaner-claims-other-tenant-host + kind: HTTPRoute + result: fail + - policy: restrict-tenant-http-route-hostnames + rule: restrict-ascoachingogvaner-hostnames + resources: + - ascoachingogvaner/ascoachingogvaner-approved-host + kind: HTTPRoute + result: pass + # Out of scope: platform namespaces are untouched by the tenant boundary. + - policy: restrict-tenant-http-route-hostnames + rule: restrict-wedding-app-hostnames + resources: + - kube-system/platform-gateway-route + kind: HTTPRoute + result: skip diff --git a/tests/restrict-tenant-http-route-hostnames/resources.yaml b/tests/restrict-tenant-http-route-hostnames/resources.yaml new file mode 100644 index 000000000..a50c4dd28 --- /dev/null +++ b/tests/restrict-tenant-http-route-hostnames/resources.yaml @@ -0,0 +1,153 @@ +--- +# The escalation the boundary exists to stop: a tenant HTTPRoute claiming +# another platform service's hostname on the shared wildcard-cert listener. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wedding-claims-other-host + namespace: wedding-app +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - vault.platform.devantler.tech + rules: + - backendRefs: + - name: wedding-app + port: 80 +--- +# hostnames omitted entirely. The Gateway listener is hostname-less, so a route +# with no hostnames matches EVERY host reaching it — the widest possible claim, +# and the case an allow-list alone would miss (an empty list has no element +# outside the allow-list). +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wedding-omits-hostnames + namespace: wedding-app +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + rules: + - backendRefs: + - name: wedding-app + port: 80 +--- +# Smuggling: one approved hostname alongside a rogue one. A rule checking only +# the first entry, or merely that SOME hostname is approved, would admit this. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wedding-mixes-approved-and-rogue + namespace: wedding-app +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - wedding.platform.devantler.tech + - vault.platform.devantler.tech + rules: + - backendRefs: + - name: wedding-app + port: 80 +--- +# The paved road: the tenant's own approved public hostname. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wedding-approved-host + namespace: wedding-app +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - wedding.platform.devantler.tech + rules: + - backendRefs: + - name: wedding-app + port: 80 +--- +# Both approved hostnames at once (prod domain + the local .lan name). +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wedding-approved-both-hosts + namespace: wedding-app +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - wedding.platform.devantler.tech + - wedding.platform.lan + rules: + - backendRefs: + - name: wedding-app + port: 80 +--- +# The second tenant, claiming a hostname belonging to the first. Proves the +# per-tenant allow-lists are not pooled into one shared set. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: ascoachingogvaner-claims-other-tenant-host + namespace: ascoachingogvaner +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - wedding.platform.devantler.tech + rules: + - backendRefs: + - name: ascoachingogvaner + port: 80 +--- +# The second tenant's paved road, on its own custom domain. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: ascoachingogvaner-approved-host + namespace: ascoachingogvaner +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - ascoachingogvaner.dk + - www.ascoachingogvaner.dk + rules: + - backendRefs: + - name: ascoachingogvaner + port: 80 +--- +# Out of scope: a platform-owned route in a platform namespace keeps its own +# hostname. Confirms the policy is scoped to tenant namespaces and is not a +# cluster-wide hostname allow-list. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: platform-gateway-route + namespace: kube-system +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - vault.platform.devantler.tech + rules: + - backendRefs: + - name: oauth2-proxy + port: 80 From 5cab71da5db071f5e6c42696f46195c1941bb73e Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 16 Aug 2026 01:41:17 +0200 Subject: [PATCH 3/7] fix(security): deny tenant HTTPRoutes with no hostname allow-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-tenant rules are keyed by namespace name, which is fail-open: a tenant with no rule is unrestricted and nothing signals it. doggy-countdown was in exactly that state — an active tenant bound to tenant-edit, matched by no rule, free to claim any hostname on the shared wildcard listener. Add its rule (simba.platform.devantler.tech, from its own deploy/httproute.yaml) and a default-deny for any other ksail-managed tenant namespace, so onboarding the next tenant fails closed with a message naming the fix instead of silently reopening the gap. --- docs/TENANTS.md | 6 ++ .../restrict-tenant-http-route-hostnames.yaml | 62 +++++++++++++++++++ .../test-tenant-route-hostname-boundary.sh | 14 +++-- .../kyverno-test.yaml | 47 ++++++++++++-- .../resources.yaml | 57 +++++++++++++++++ .../values.yaml | 29 +++++++++ 6 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 tests/restrict-tenant-http-route-hostnames/values.yaml diff --git a/docs/TENANTS.md b/docs/TENANTS.md index 18dc2ce8b..eab68a9e8 100644 --- a/docs/TENANTS.md +++ b/docs/TENANTS.md @@ -226,6 +226,12 @@ here** — **hostnames**, **`gethomepage.dev/*` dashboard annotations**, routes, The shared platform Gateway intentionally accepts routes from all namespaces, so the admission policy is the boundary that prevents a tenant artifact from claiming another platform hostname. +- **A tenant with no rule of its own is denied by default.** Adding the allow-list entry is + therefore part of onboarding, not an optional hardening step: until a tenant namespace has + a rule naming its approved hostnames, its HTTPRoutes are refused at admission with a + message saying exactly that. The alternative — enumerating only some tenants — is + fail-open, and `doggy-countdown` ran that way, able to claim any hostname because no rule + matched its namespace. - **`HTTPRoute` is the only route kind a tenant can use.** The hostname allow-list above matches `HTTPRoute`, so every other Gateway API route kind is closed off rather than left to reach the shared listener unchecked: the tenant role grants only `httproutes` and diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml index 0eefa772a..c805c1574 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml @@ -65,3 +65,65 @@ spec: - ascoachingogvaner.dk - www.ascoachingogvaner.dk - ascoachingogvaner.platform.lan + - name: restrict-doggy-countdown-hostnames + match: + any: + - resources: + kinds: + - HTTPRoute + namespaces: + - doggy-countdown + validate: + message: "doggy-countdown HTTPRoutes may only use approved doggy-countdown hostnames." + deny: + conditions: + any: + - key: "{{ request.object.spec.hostnames || `[]` }}" + operator: Equals + value: [] + - key: "{{ request.object.spec.hostnames || `[]` }}" + operator: AnyNotIn + value: + - simba.platform.devantler.tech + # Default-deny for any OTHER tenant namespace. + # + # The rules above are an allow-list keyed by namespace NAME, which is + # fail-OPEN: a tenant with no rule of its own is not restricted at all, and + # nothing signals that. doggy-countdown sat in exactly that state — an + # active tenant, bound to `tenant-edit` (so able to create HTTPRoutes) and + # matched by no rule, therefore free to claim any hostname on the shared + # wildcard listener. Adding its rule fixes that one tenant; this rule fixes + # the CLASS, so onboarding the next tenant cannot silently reopen it. + # + # Scope: `app.kubernetes.io/managed-by: ksail` is the tenant-namespace label + # (the same selector restrict-tenant-issuer-refs and + # restrict-tenant-secret-stores use). It is carried by tenant namespaces + # only — the platform's own app namespaces (actual-budget, homepage, + # whoami, backstage, headlamp …) do not have it, so their platform-authored + # HTTPRoutes are untouched. + # + # Onboarding a tenant that serves HTTP now REQUIRES adding a rule above with + # its approved hostnames. That is the intended cost: the failure is a loud, + # immediate admission denial naming the fix, not a silent gap. + - name: restrict-unlisted-tenant-hostnames + match: + any: + - resources: + kinds: + - HTTPRoute + namespaceSelector: + matchLabels: + app.kubernetes.io/managed-by: ksail + exclude: + any: + - resources: + namespaces: + - wedding-app + - ascoachingogvaner + - doggy-countdown + validate: + message: >- + This tenant namespace has no approved hostname allow-list, so its + HTTPRoutes are denied by default. Add a rule for it to + restrict-tenant-http-route-hostnames naming its approved hostnames. + deny: {} diff --git a/scripts/tests/test-tenant-route-hostname-boundary.sh b/scripts/tests/test-tenant-route-hostname-boundary.sh index 15c59af4b..af1195b3c 100755 --- a/scripts/tests/test-tenant-route-hostname-boundary.sh +++ b/scripts/tests/test-tenant-route-hostname-boundary.sh @@ -13,7 +13,9 @@ # 2. every Gateway listener pins `allowedRoutes.kinds` to HTTPRoute, covering # routes created by any other principal. # Layer 3 re-checks that the hostname policy itself still denies, so a future -# edit cannot leave the allow-list admitting everything. +# edit cannot leave the allow-list admitting everything — including its +# default-deny rule, without which a tenant carrying no allow-list of its own is +# unrestricted rather than refused. set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -22,6 +24,9 @@ repo_root="$(cd "${script_dir}/../.." && pwd)" role_file="${repo_root}/k8s/bases/infrastructure/cluster-roles/gateway-tenant-edit.yaml" policy="${repo_root}/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml" fixtures="${repo_root}/tests/restrict-tenant-http-route-hostnames/resources.yaml" +# The default-deny rule matches on a namespaceSelector, which the CLI only +# honours when the namespace labels are declared here. +values="${repo_root}/tests/restrict-tenant-http-route-hostnames/values.yaml" # The prod overlay is what carries every listener: two come from the base # Gateway and two more are appended by the hetzner JSON6902 patch, which a # per-file check would never see. @@ -84,18 +89,19 @@ unpinned="$(yq eval \ # --- Layer 3: the hostname policy still denies ------------------------------ # `kyverno test` reports a missing named rule as Excluded and can pass # vacuously, so exercise the policy directly (same second gate as -# test-restrict-tenant-secret-stores.sh): the fixture must admit 3 and deny 4. +# test-restrict-tenant-secret-stores.sh): the fixture must admit 4 and deny 6. if kyverno apply "${policy}" \ --resource "${fixtures}" \ + --values-file "${values}" \ --remove-color >"${output_file}" 2>&1; then fail "tenant HTTPRoute hostname policy admitted every fixture; no deny rule executed" fi -expected_summary="pass: 3, fail: 4, warn: 0, error: 0, skip: 0" +expected_summary="pass: 4, fail: 6, warn: 0, error: 0, skip: 0" if ! grep -Fq "${expected_summary}" "${output_file}"; then echo "::error::tenant HTTPRoute hostname policy returned an unexpected allow/deny verdict" sed -n '1,80p' "${output_file}" exit 1 fi -echo "Tenant route boundary holds: RBAC grants HTTPRoute only, all ${listener_count} listeners pin kinds to HTTPRoute, and the hostname policy enforced 3-admit/4-deny." +echo "Tenant route boundary holds: RBAC grants HTTPRoute only, all ${listener_count} listeners pin kinds to HTTPRoute, and the hostname policy enforced 4-admit/6-deny." diff --git a/tests/restrict-tenant-http-route-hostnames/kyverno-test.yaml b/tests/restrict-tenant-http-route-hostnames/kyverno-test.yaml index bbad35386..c21ad08a3 100644 --- a/tests/restrict-tenant-http-route-hostnames/kyverno-test.yaml +++ b/tests/restrict-tenant-http-route-hostnames/kyverno-test.yaml @@ -8,9 +8,10 @@ policies: ../../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-tenant-http-route-hostnames.yaml resources: - resources.yaml -# The policy matches namespaces by NAME (not a namespaceSelector), so unlike the -# restrict-tenant-issuer-refs fixture no Values file is needed to avoid vacuous -# "Excluded" results. +# Declares the namespace labels the default-deny rule's namespaceSelector matches +# on. Without it that rule reports "Excluded" for every resource and its fail +# case passes vacuously. +variables: values.yaml results: # The escalation the boundary exists to stop, plus the two ways around a naive # allow-list: omitting hostnames entirely, and smuggling a rogue hostname in @@ -45,10 +46,48 @@ results: - ascoachingogvaner/ascoachingogvaner-approved-host kind: HTTPRoute result: pass - # Out of scope: platform namespaces are untouched by the tenant boundary. + # The third tenant, whose rule was missing entirely. + - policy: restrict-tenant-http-route-hostnames + rule: restrict-doggy-countdown-hostnames + resources: + - doggy-countdown/doggy-claims-other-host + kind: HTTPRoute + result: fail + - policy: restrict-tenant-http-route-hostnames + rule: restrict-doggy-countdown-hostnames + resources: + - doggy-countdown/doggy-approved-host + kind: HTTPRoute + result: pass + # The fail-open class: a tenant namespace with no allow-list of its own is + # denied by default instead of being silently unrestricted. + - policy: restrict-tenant-http-route-hostnames + rule: restrict-unlisted-tenant-hostnames + resources: + - new-tenant/unlisted-tenant-route + kind: HTTPRoute + result: fail + # ...and the default-deny must NOT catch the tenants that do have one, or it + # would break every listed tenant's paved road. + - policy: restrict-tenant-http-route-hostnames + rule: restrict-unlisted-tenant-hostnames + resources: + - wedding-app/wedding-approved-host + - ascoachingogvaner/ascoachingogvaner-approved-host + - doggy-countdown/doggy-approved-host + kind: HTTPRoute + result: skip + # Out of scope: platform namespaces are untouched by the tenant boundary — + # by the per-tenant rules and by the default-deny alike. - policy: restrict-tenant-http-route-hostnames rule: restrict-wedding-app-hostnames resources: - kube-system/platform-gateway-route kind: HTTPRoute result: skip + - policy: restrict-tenant-http-route-hostnames + rule: restrict-unlisted-tenant-hostnames + resources: + - kube-system/platform-gateway-route + kind: HTTPRoute + result: skip diff --git a/tests/restrict-tenant-http-route-hostnames/resources.yaml b/tests/restrict-tenant-http-route-hostnames/resources.yaml index a50c4dd28..aa49bbee2 100644 --- a/tests/restrict-tenant-http-route-hostnames/resources.yaml +++ b/tests/restrict-tenant-http-route-hostnames/resources.yaml @@ -151,3 +151,60 @@ spec: - backendRefs: - name: oauth2-proxy port: 80 +--- +# The third tenant's paved road. Its rule was missing entirely until the +# default-deny rule was added alongside it, leaving this namespace free to +# claim any hostname. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: doggy-approved-host + namespace: doggy-countdown +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - simba.platform.devantler.tech + rules: + - backendRefs: + - name: doggy-countdown + port: 80 +--- +# The third tenant claiming a platform hostname. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: doggy-claims-other-host + namespace: doggy-countdown +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - vault.platform.devantler.tech + rules: + - backendRefs: + - name: doggy-countdown + port: 80 +--- +# A tenant onboarded with NO hostname rule of its own. Before the default-deny +# rule this was admitted unconditionally — the fail-open gap. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: unlisted-tenant-route + namespace: new-tenant +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - vault.platform.devantler.tech + rules: + - backendRefs: + - name: new-tenant + port: 80 diff --git a/tests/restrict-tenant-http-route-hostnames/values.yaml b/tests/restrict-tenant-http-route-hostnames/values.yaml new file mode 100644 index 000000000..395245eec --- /dev/null +++ b/tests/restrict-tenant-http-route-hostnames/values.yaml @@ -0,0 +1,29 @@ +--- +# The default-deny rule (restrict-unlisted-tenant-hostnames) matches on a +# namespaceSelector, and the Kyverno CLI does not read namespace labels from +# Namespace objects in resources.yaml. Without this file that rule is reported +# "Excluded" for every resource and the fail case passes vacuously — the same +# trap documented in tests/restrict-tenant-issuer-refs/values.yaml. +apiVersion: cli.kyverno.io/v1alpha1 +kind: Values +metadata: + name: values +namespaceSelector: + # The three tenants with an explicit hostname allow-list. + - name: wedding-app + labels: + app.kubernetes.io/managed-by: ksail + - name: ascoachingogvaner + labels: + app.kubernetes.io/managed-by: ksail + - name: doggy-countdown + labels: + app.kubernetes.io/managed-by: ksail + # A tenant onboarded WITHOUT a hostname rule — the fail-open case the + # default-deny rule exists to close. + - name: new-tenant + labels: + app.kubernetes.io/managed-by: ksail + # A platform namespace: no ksail label, so the tenant boundary never applies. + - name: kube-system + labels: {} From 42a13b3e67a4b0e89925c4fd62fc338bc067b4d9 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 16 Aug 2026 01:51:55 +0200 Subject: [PATCH 4/7] chore(security): re-approve the authorization surface after narrowing tenant route grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrowing gateway-tenant-edit and adding the tenant hostname policy move the rendered authorization surface, so the guard correctly refused the old fingerprint. The delta is two documents and only one is RBAC: gateway-tenant-edit drops grpcroutes/tcproutes/tlsroutes/udproutes with zero additions, identical verbs, apiGroup and aggregation labels; the added ClusterPolicy only denies. Measured by rendering all five authorization overlays from main and from this head — 71 RBAC documents each side, identity sets identical, exactly one differing. Reproduced under both renderer controls rather than copied from CI: main reproduces its prior constant and this tree reproduces the new one. --- scripts/validate-eks-ci-role-policy/main.go | 36 ++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/scripts/validate-eks-ci-role-policy/main.go b/scripts/validate-eks-ci-role-policy/main.go index 84dcdefbf..3c50a2f23 100644 --- a/scripts/validate-eks-ci-role-policy/main.go +++ b/scripts/validate-eks-ci-role-policy/main.go @@ -415,7 +415,41 @@ const ( // exception needed to admit prune:false; validateUnifiPruneExemption pins that // rule's complete exception set to flux-system/flux-system and unifi/unifi. // No identity, binding, resource kind, or remaining verb moved. -const expectedRenderedSurfaceSHA = "1c4c1986a7b891b184d70a19bec6a85f33c0ea4c0ac174b26501000e38582cd1" +// +// This value covers closing the non-HTTPRoute path to the shared Gateway +// listener. The delta is exactly two documents, and only one of them is RBAC: +// +// ClusterRole gateway-tenant-edit resources narrowed +// ClusterPolicy restrict-tenant-http-route-hostnames added +// +// The ClusterRole is a STRICT NARROWING: `grpcroutes`, `tcproutes`, `tlsroutes` +// and `udproutes` are removed, leaving `httproutes` + `referencegrants`. The +// set difference in the ADDITION direction is empty, the seven verbs are +// byte-identical, the single apiGroup is unchanged, and both aggregation labels +// are unchanged — so no identity gains anything. The tenant hostname policy +// matches HTTPRoute only, so those four kinds were a path to the shared +// hostname-less listener that the policy never ran on. +// +// Measured by rendering the five authorization overlays from `main` and from +// this head and diffing the canonical documents: 71 RBAC documents on each +// side, identity sets identical (Role 10, ClusterRole 22, RoleBinding 15, +// ClusterRoleBinding 10, ServiceAccount 14 — unchanged), and exactly ONE RBAC +// document differs in content, the ClusterRole above. Nothing is added, +// removed, or renamed anywhere else in the surface. The added ClusterPolicy is +// the admission boundary itself (ClusterPolicy 26 -> 27); it grants nothing and +// only denies, and scripts/tests/test-tenant-route-hostname-boundary.sh pins +// both the narrowed grant set and the policy's admit/deny verdict. +// +// The fingerprint was read from the required job's own output on the approved +// renderer, and then independently REPRODUCED locally under two controls rather +// than copied on trust: the `main` tree reproduces its own prior constant +// (1c4c1986…), and this tree reproduces the value below. Both controls passing +// is what makes the delta above admissible evidence — a local render that could +// not reproduce a known-good constant would say nothing about what CI hashed. +// (Local kubectl was v1.36.1 against the pinned v1.36.2, with the same embedded +// Kustomize v5.8.1; that patch difference demonstrably does not move this +// surface, but the controls are what establish it, not the version numbers.) +const expectedRenderedSurfaceSHA = "55533168c5f38be31381db2f900094b9ce8e6abbb38229359f74de2d12fee102" // authorizationOverlayPaths lists every independently reconciled production // layer where an object can grant privileges to the aws/aws service account. From bfc34801407dafeb43219c7bd0aa1d05d22b9313 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 16 Aug 2026 14:47:51 +0200 Subject: [PATCH 5/7] docs: clarify tenant route boundary coverage --- docs/TENANTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TENANTS.md b/docs/TENANTS.md index cb8d2476f..cee305fd2 100644 --- a/docs/TENANTS.md +++ b/docs/TENANTS.md @@ -238,8 +238,8 @@ here** — **hostnames**, **`gethomepage.dev/*` dashboard annotations**, routes, `referencegrants`, and each Gateway listener pins `allowedRoutes.kinds` to `HTTPRoute` (an HTTPS listener would otherwise accept `GRPCRoute` too). A tenant needing another kind is a platform change, not a tenant one — extend the hostname policy to cover that kind - and relax both layers together. `scripts/tests/test-tenant-route-hostname-boundary.sh` - pins all three in CI. + and relax both layers together. CI runs the rendered two-layer route-kind boundary test + alongside the canonical Kyverno policy fixtures, so neither half can widen silently. - The platform's `homepage` app discovers `gethomepage.dev/*` annotations on the tenant's HTTPRoute cluster-wide, so the tenant authors them in its own artifact — they are tenant self-presentation, not platform config. From 03df5f161b74b909aed87d87baa95b42b526dab4 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sun, 16 Aug 2026 23:58:17 +0200 Subject: [PATCH 6/7] fix(security): record the measured authorization fingerprint for the merge result The branch's previous value was rendered against an older main and did not describe the merge result. Records the value both the required CI job and a local render measured, with the reviewed delta unchanged. --- scripts/validate-eks-ci-role-policy/main.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/scripts/validate-eks-ci-role-policy/main.go b/scripts/validate-eks-ci-role-policy/main.go index 56d8a88cf..5d7ecaa6c 100644 --- a/scripts/validate-eks-ci-role-policy/main.go +++ b/scripts/validate-eks-ci-role-policy/main.go @@ -588,13 +588,10 @@ const ( // every surviving selected object; #2741 removes only the already-protected // Headlamp PVC identity and retains the authorization-neutral changes above. // -// PENDING RE-MEASUREMENT for #2713, merged with exact main 6ebcb24f. The value -// below is main's approved fingerprint and does not yet describe this merge -// result; the branch's own earlier value was rendered against main 6d926e42 and -// no longer describes it either, so neither is approved for this head. The local -// toolchain is kubectl v1.36.1 against the pinned v1.36.2, so -// validateRendererVersion refuses it and the aggregate must be read from the -// required job's own output on the approved renderer before this PR can merge. +// Measured for #2713 merged with exact main 6ebcb24f. Two independent renderers +// agree on this value: the required CI job on the approved toolchain (run +// 31974280947) and a local render. The branch's own earlier value was rendered +// against main 6d926e42 and never described this merge result. // // The reviewed reasoning for the change itself is unaffected by the merge. The // complete authorization delta changes exactly ONE selected entry: @@ -610,7 +607,7 @@ const ( // removal of built-in edit aggregation; neither is duplicated by this change. // Gateway listener-kind pins are outside this authorization projection and are // covered by scripts/tests/test-tenant-route-hostname-boundary.sh. -const expectedRenderedSurfaceSHA = "6e6753583bbffa59ce236412f63f27e3d77d03739742ef0a3a34596eb96c5f2a" +const expectedRenderedSurfaceSHA = "9a9a010230ad7b63610c0920e39aa0b5067b248384356818af01c59c06f6abc7" // authorizationOverlayPaths lists every independently reconciled production // layer where an object can grant privileges to the aws/aws service account. From 90cf208efc4405cc5bacbe27a1321ca6029b7984 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 17 Aug 2026 13:16:38 +0200 Subject: [PATCH 7/7] fix(ci): record the measured authorization surface digest for the merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous value was main's, carried through the merge unmeasured, so `🔐 Validate EKS Authorization` rejected it exactly as the comment beside it predicted. This records the digest the merged surface actually renders to. Two independent renderers agree: the required CI job (run 32019353067) and a local render on kubectl v1.36.1 / kustomize v5.8.1. Conservation: the whole-surface fingerprint is the only control that moved. Both renderers report zero per-identity mismatches and zero missing resources, so every individually approved entry — both parents' deltas included — is byte-identical to its recorded value. Both report the same 35 unresolved-substitution notes, which the validator emits as diagnostics rather than a control. That symmetry also corrects the assumption recorded before the measurement: the approved toolchain reports the same notes and the same digest, so a local render is a genuine second renderer here, not a degraded one. --- scripts/validate-eks-ci-role-policy/main.go | 28 ++++++++++++++------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/scripts/validate-eks-ci-role-policy/main.go b/scripts/validate-eks-ci-role-policy/main.go index 65783b719..092a4625f 100644 --- a/scripts/validate-eks-ci-role-policy/main.go +++ b/scripts/validate-eks-ci-role-policy/main.go @@ -652,15 +652,25 @@ const ( // grant-bearing accounting recorded for each carries over unchanged and only the // whole-surface digest moves. // -// The value below is main's, carried through the merge UNMEASURED against the -// merge result, so the required `🔐 Validate EKS Authorization` job is expected -// to reject it and report the digest the merged surface actually renders to. -// That reported digest is what replaces this value, in a follow-up commit that -// records the conservation counts behind it. It is deliberately not guessed -// here: a local render cannot approve it, because without the CI substitution -// inputs the render reports unresolved Flux substitutions and is incomplete, and -// the two-independent-renderer protocol requires the approved toolchain. -const expectedRenderedSurfaceSHA = "295c44e37dcb09bfa1dd83d7f0eba975b529035c8618be9b65e41d9c20d6157b" +// Measured against the merge result at c790f999. Two independent renderers agree +// on the value below: the required `🔐 Validate EKS Authorization` job (run +// 32019353067) and a local render on kubectl v1.36.1 / kustomize v5.8.1. +// +// Conservation behind the new digest: the whole-surface fingerprint is the ONLY +// control that moved. Both renderers report zero per-identity mismatches and +// zero missing resources against expectedRenderedHashes, so every individually +// approved entry — including both parents' deltas — is byte-identical to its +// recorded value and only the aggregate over the entry set changed. Both also +// report the same 35 unresolved-substitution notes, which are diagnostic rather +// than a control: a resource carrying `${…}` is forced into the aggregate, so +// its literal text is already covered by this digest. +// +// That symmetry corrects the assumption recorded before the measurement, which +// held that a local render could not approve this because it would be incomplete +// without the CI substitution inputs. The approved toolchain reports the same 35 +// notes and the same digest, so the two renders are equivalent here and the +// local one is a genuine second renderer rather than a degraded copy. +const expectedRenderedSurfaceSHA = "b8235416c8452d2a123eb9279d64a4d6adc11625b9ed3be6801cd8abd1794a78" // authorizationOverlayPaths lists every independently reconciled production // layer where an object can grant privileges to the aws/aws service account.