diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0e8119e54..fe25c543c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -118,6 +118,9 @@ jobs: - 'scripts/tests/test-headlamp-plugin-removal.sh' - 'scripts/tests/test-github-config-role-activation-parity.sh' - 'scripts/tests/test-restrict-tenant-secret-stores.sh' + # The Homepage layout and the policy's allow-list are one contract: + # either side changing alone is the drift this test exists to catch. + - 'scripts/tests/test-restrict-homepage-service-groups.sh' - 'scripts/tests/test-kyverno-umami-mutation-rbac.sh' - 'scripts/tests/test-kyverno-admission-vpa.sh' - 'scripts/tests/kyverno-admission-vpa-rules.yaml' @@ -436,6 +439,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-restrict-homepage-service-groups.sh + bash scripts/tests/test-restrict-homepage-service-groups.sh shellcheck scripts/tests/test-kyverno-umami-mutation-rbac.sh bash scripts/tests/test-kyverno-umami-mutation-rbac.sh shellcheck scripts/tests/test-actual-budget-auth-route.sh diff --git a/k8s/bases/apps/homepage/config-map.yaml b/k8s/bases/apps/homepage/config-map.yaml index 1430b80fa..fc7e37ba6 100644 --- a/k8s/bases/apps/homepage/config-map.yaml +++ b/k8s/bases/apps/homepage/config-map.yaml @@ -57,9 +57,22 @@ data: target: _blank # Layout order also controls render order on the page. Groups not listed # here still appear (Homepage falls back to alphabetical) but get a generic - # icon — keep this in sync with the gethomepage.dev/group annotations on - # the HTTPRoutes under k8s/bases/apps/** and k8s/**/infrastructure/** (incl. - # provider overlays, e.g. Longhorn on Hetzner) and the service entries below. + # icon AND sort below every listed group — bookmarks included — so an + # undeclared group silently lands at the bottom of the page. + # + # The SERVICE groups below are enforced at admission by the Kyverno policy + # cluster-policies/best-practices/restrict-homepage-service-groups.yaml: a + # route annotated gethomepage.dev/enabled="true" may only name a group + # declared here. Adding a service group means adding it there too, in the + # same PR — that policy carries the matching allow-list. Homepage itself has + # no way to filter discovery by group, which is why the binding lives in + # admission rather than in this file. + # + # Still keep this in sync with the gethomepage.dev/group annotations on the + # HTTPRoutes under k8s/bases/apps/** and k8s/**/infrastructure/** (incl. + # provider overlays, e.g. Longhorn on Hetzner) and the service entries below + # — and note tenant repositories carry their own routes, so a group can be + # claimed from outside this repository. layout: # First on the page: the Internal Developer Platform (Backstage and any # future self-service portals). Keep this entry first so the IDP is the @@ -77,6 +90,13 @@ data: icon: mdi-stethoscope Device Management: icon: mdi-cellphone-cog + # Sits immediately before the two providers it declares: Crossplane + # provisions the Hetzner cloud and the UniFi network, so "declare it" reads + # ahead of "here is the provider console". + Infrastructure as Code: + # `crossplane` is not in simple-icons (verified 404); use an MDI icon, as + # the Observability entry above already has to. + icon: mdi-file-cog Cloud: icon: si-hetzner Network: diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml new file mode 100644 index 000000000..8a7f63930 --- /dev/null +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml @@ -0,0 +1,110 @@ +# Keeps every Homepage-discovered service inside a group the dashboard actually +# declares. +# +# Why: Homepage's group ORDER, icon and position come from the `layout` block in +# k8s/bases/apps/homepage/config-map.yaml, but the group NAME is chosen by an +# annotation on a route — often in another repository entirely. Nothing bound the +# two surfaces, so a route could invent a group, and Homepage would silently +# accept it: an undeclared group still renders, but falls to the BOTTOM of the +# page (after every declared group, bookmarks included) with a generic icon. +# The failure is cosmetic-looking and completely silent, which is why it went +# unnoticed in two places at once — `Fun` and `Infrastructure as Code` both +# rendered below the bookmark rail because neither was declared. +# +# Homepage has no native way to restrict discovery to declared groups (its +# Kubernetes config offers only `gethomepage.dev/instance` filtering), so the +# binding has to happen at admission. This is that binding: a route may only +# name a group the dashboard declares. +# +# Scope: every namespace, and deliberately NOT limited to tenant-applied +# resources — the platform's own routes are just as able to invent a group, and +# `Infrastructure as Code` (a platform route, in this repo) is exactly how this +# was found. There is no userInfo matching here, so background scanning stays on +# and existing drift surfaces in the PolicyReports rather than only at admission. +# +# SERVICE groups only. The bookmark groups in the same layout block (GitHub, +# Media, Banking, …) are intentionally absent: Homepage renders services and +# bookmarks as separate sections, so the config-map keeps the two name sets +# distinct, and a service claiming a bookmark group's name would paint that +# heading twice with different contents under each. +# +# KEEP IN SYNC with the `layout` block in k8s/bases/apps/homepage/config-map.yaml +# — adding a group there means adding it here, in the same PR. That is a +# reviewable, fail-loud edit: a route naming an undeclared group is rejected at +# admission with the list below, instead of silently sinking to the bottom of the +# dashboard. The list is duplicated rather than read from the ConfigMap on +# purpose: a Kyverno context lookup would make every annotated route in the +# cluster un-appliable if that ConfigMap ever became unreadable, trading a silent +# cosmetic bug for a reconciliation outage. +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: restrict-homepage-service-groups + annotations: + policies.kyverno.io/title: Restrict Homepage Service Groups + policies.kyverno.io/category: Best Practices + policies.kyverno.io/severity: low + policies.kyverno.io/subject: HTTPRoute, Ingress + policies.kyverno.io/minversion: 1.13.0 + policies.kyverno.io/description: >- + Requires every route that opts into Homepage discovery + (gethomepage.dev/enabled: "true") to name a service group declared in the + dashboard's layout, so a discovered service cannot invent a group and sink + to the bottom of the page with a generic icon. +spec: + # Rule-level `validate.failureAction` below, not the spec-level + # `validationFailureAction`: Kyverno's own v1 CRD describes that one as + # "Deprecated, use validationFailureAction under the validate rule instead." + # The eleven older policies here still use the deprecated form — it is honoured + # on the deployed v1.18.2 — and migrating them is tracked separately; a new + # policy should not add to that debt. + rules: + - name: declared-service-group + match: + any: + - resources: + kinds: + # HTTPRoute is what this platform actually uses. Ingress is + # Homepage's other discovery source and is included so a future + # Ingress cannot bypass the guard. + - HTTPRoute + - Ingress + preconditions: + all: + # Only routes that opt into the dashboard. Everything else is none of + # this policy's business. + - key: "{{ request.object.metadata.annotations.\"gethomepage.dev/enabled\" || 'false' }}" + operator: Equals + value: "true" + validate: + failureAction: Enforce + message: >- + A route with gethomepage.dev/enabled="true" must set + gethomepage.dev/group to a service group declared in the Homepage + layout (k8s/bases/apps/homepage/config-map.yaml): Internal Developer + Platform, Kubernetes, Observability, Security, Diagnostics, Device + Management, Infrastructure as Code, Cloud, Network, Finance, Analytics, + Personal Sites, or Customer Sites. An undeclared group still renders, + but sorts below every declared group with a generic icon. Bookmark + group names are deliberately not accepted here. + deny: + conditions: + any: + # The 'MISSING' sentinel is not in the allow-list, so an absent + # annotation is denied by the same condition as a misspelled one. + - key: "{{ request.object.metadata.annotations.\"gethomepage.dev/group\" || 'MISSING' }}" + operator: NotIn + value: + - Internal Developer Platform + - Kubernetes + - Observability + - Security + - Diagnostics + - Device Management + - Infrastructure as Code + - Cloud + - Network + - Finance + - Analytics + - Personal Sites + - Customer Sites diff --git a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml index ed2c293ce..2885fbbbb 100644 --- a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml +++ b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml @@ -12,6 +12,7 @@ resources: - best-practices/disable-default-sa-automount.yaml - best-practices/disallow-latest-tag.yaml - best-practices/propagate-reloader-to-flagger-primary.yaml + - best-practices/restrict-homepage-service-groups.yaml - best-practices/restrict-tenant-issuer-refs.yaml - best-practices/restrict-tenant-route-hostnames.yaml - best-practices/restrict-tenant-secret-stores.yaml diff --git a/scripts/tests/test-restrict-homepage-service-groups.sh b/scripts/tests/test-restrict-homepage-service-groups.sh new file mode 100755 index 000000000..0c3931362 --- /dev/null +++ b/scripts/tests/test-restrict-homepage-service-groups.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# The Homepage layout and the Kyverno allow-list are two lists that must agree. +# The policy exists because an undeclared group fails silently; a duplicated +# allow-list that drifts would fail just as silently, so this pins them together. +# +# Service groups are derived STRUCTURALLY — every layout key that is not a +# bookmark group — rather than by reading the section comment, so moving or +# rewording that comment cannot quietly change what this test believes. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" +config_map="${repo_root}/k8s/bases/apps/homepage/config-map.yaml" +policy="${repo_root}/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml" + +for f in "${config_map}" "${policy}"; do + [ -r "$f" ] || { + echo "::error::missing or unreadable: $f" + exit 1 + } +done + +# --- 1. the rule must ENFORCE, via the non-deprecated field ------------------- +action="$(yq -r '.spec.rules[] | select(.name == "declared-service-group") | .validate.failureAction // "unset"' "${policy}")" +if [ "${action}" != "Enforce" ]; then + echo "::error::declared-service-group must set validate.failureAction: Enforce (found: ${action})" + exit 1 +fi + +# Kyverno's v1 CRD calls the spec-level field deprecated. A new policy using it +# would still work, but this keeps the modern form from silently regressing. +if [ "$(yq -r '.spec.validationFailureAction // "unset"' "${policy}")" != "unset" ]; then + echo "::error::policy uses the deprecated spec.validationFailureAction; use validate.failureAction" + exit 1 +fi + +# --- 2. allow-list == the layout's SERVICE groups ---------------------------- +settings="$(yq -r '.data."settings.yaml"' "${config_map}")" +bookmarks="$(yq -r '.data."bookmarks.yaml"' "${config_map}")" +if [ -z "${settings}" ] || [ "${settings}" = "null" ]; then + echo "::error::settings.yaml missing from the ConfigMap" + exit 1 +fi +if [ -z "${bookmarks}" ] || [ "${bookmarks}" = "null" ]; then + echo "::error::bookmarks.yaml missing from the ConfigMap" + exit 1 +fi + +layout_groups="$(printf '%s\n' "${settings}" | yq -r '.layout | keys | .[]' | sort -u)" +bookmark_groups="$(printf '%s\n' "${bookmarks}" | yq -r '.[] | keys | .[0]' | sort -u)" +service_groups="$(comm -23 <(printf '%s\n' "${layout_groups}") <(printf '%s\n' "${bookmark_groups}"))" + +policy_groups="$(yq -r ' + .spec.rules[] | select(.name == "declared-service-group") + | .validate.deny.conditions.any[] | .value[]' "${policy}" | sort -u)" + +# Proof-of-life: an empty side would make the comparison pass vacuously. +for pair in "layout:${layout_groups}" "bookmark:${bookmark_groups}" "service:${service_groups}" "policy:${policy_groups}"; do + name="${pair%%:*}" + body="${pair#*:}" + n="$(printf '%s\n' "${body}" | grep -c . || true)" + if [ "${n}" -lt 1 ]; then + echo "::error::extracted zero ${name} groups — the parse broke, so this check would pass vacuously" + exit 1 + fi +done + +if ! diff_out="$(diff <(printf '%s\n' "${service_groups}") <(printf '%s\n' "${policy_groups}"))"; then + echo "::error::Homepage layout service groups and the Kyverno allow-list disagree." + echo " '<' = declared in the layout but not allowed by the policy" + echo " '>' = allowed by the policy but not declared in the layout" + printf '%s\n' "${diff_out}" + exit 1 +fi + +# A bookmark group must never be accepted for a service: Homepage renders the two +# as separate sections, so a shared name paints one heading twice. +while IFS= read -r b; do + [ -n "$b" ] || continue + if printf '%s\n' "${policy_groups}" | grep -qxF -- "$b"; then + echo "::error::policy allows bookmark group '${b}' as a service group" + exit 1 + fi +done <- + ../../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-homepage-service-groups.yaml +resources: + - resources.yaml +results: + # --- declared service groups pass --- + - policy: restrict-homepage-service-groups + rule: declared-service-group + resources: + - observability/declared-group + - crossview/infrastructure-as-code-group + kind: HTTPRoute + result: pass + + # --- undeclared, missing, bookmark-named and mis-cased groups are denied --- + - policy: restrict-homepage-service-groups + rule: declared-service-group + resources: + - doggy-countdown/undeclared-group + - whoami/missing-group + - whoami/bookmark-group-name + - whoami/wrong-case-group + kind: HTTPRoute + result: fail + + # --- the precondition's negative control: routes that never opted in --- + - policy: restrict-homepage-service-groups + rule: declared-service-group + resources: + - whoami/not-opted-in + - whoami/discovery-disabled + kind: HTTPRoute + result: skip + + # --- Ingress is guarded identically --- + - policy: restrict-homepage-service-groups + rule: declared-service-group + resources: + - whoami/ingress-undeclared-group + kind: Ingress + result: fail diff --git a/tests/restrict-homepage-service-groups/resources.yaml b/tests/restrict-homepage-service-groups/resources.yaml new file mode 100644 index 000000000..10258638d --- /dev/null +++ b/tests/restrict-homepage-service-groups/resources.yaml @@ -0,0 +1,121 @@ +--- +# A declared service group on a platform-owned route — the ordinary pass case. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: declared-group + namespace: observability + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/name: Coroot + gethomepage.dev/group: Observability +spec: + hostnames: + - observability.platform.devantler.tech +--- +# The group this change adds. Fails before the layout entry exists, passes after. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: infrastructure-as-code-group + namespace: crossview + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/name: Crossview + gethomepage.dev/group: Infrastructure as Code +spec: + hostnames: + - crossview.platform.devantler.tech +--- +# The exact drift that motivated the policy: an undeclared group, which Homepage +# accepts silently and renders below the bookmark rail. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: undeclared-group + namespace: doggy-countdown + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/name: Simba Countdown + gethomepage.dev/group: Fun +spec: + hostnames: + - simba.platform.devantler.tech +--- +# Opted into discovery but named no group at all. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: missing-group + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/name: Whoami +spec: + hostnames: + - whoami.platform.devantler.tech +--- +# A BOOKMARK group name used for a service. Declared in the layout, but under the +# bookmark half — accepting it would paint that heading twice. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: bookmark-group-name + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/name: Not A Service Group + gethomepage.dev/group: Banking +spec: + hostnames: + - bookmark.platform.devantler.tech +--- +# Case matters: Homepage matches the layout key exactly. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wrong-case-group + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/group: personal sites +spec: + hostnames: + - case.platform.devantler.tech +--- +# Not opted into the dashboard — the policy must not touch it, even though it +# carries no group at all. This is the precondition's negative control. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: not-opted-in + namespace: whoami +spec: + hostnames: + - internal.platform.devantler.tech +--- +# Explicitly disabled discovery, with a bogus group. Still none of our business. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: discovery-disabled + namespace: whoami + annotations: + gethomepage.dev/enabled: "false" + gethomepage.dev/group: Nonsense +spec: + hostnames: + - disabled.platform.devantler.tech +--- +# Homepage's other discovery source, guarded the same way. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress-undeclared-group + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/group: Fun +spec: + rules: + - host: ingress.platform.devantler.tech