Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions k8s/bases/apps/homepage/config-map.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Comment thread
devantler marked this conversation as resolved.
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions scripts/tests/test-restrict-homepage-service-groups.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
${bookmark_groups}
EOF

# The deny message names the valid groups so a rejected apply does not have to go
# read the layout. That is a THIRD copy of the list, so pin it too — otherwise the
# admission error goes stale exactly when a group is added, which is when someone
# is most likely to be reading it.
message="$(yq -r '
.spec.rules[] | select(.name == "declared-service-group") | .validate.message' "${policy}")"
if [ -z "${message}" ] || [ "${message}" = "null" ]; then
echo "::error::the rule has no validate.message"
exit 1
fi
while IFS= read -r g; do
[ -n "${g}" ] || continue
if ! printf '%s' "${message}" | tr '\n' ' ' | grep -qF -- "${g}"; then
echo "::error::deny message does not name the allowed group '${g}'"
exit 1
fi
done <<EOF
${service_groups}
EOF

count="$(printf '%s\n' "${service_groups}" | grep -c .)"
echo "Homepage layout, Kyverno allow-list and deny message agree on ${count} service groups; rule enforces."
47 changes: 47 additions & 0 deletions tests/restrict-homepage-service-groups/kyverno-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
name: restrict-homepage-service-groups
policies:
- >-
../../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
Loading
Loading