diff --git a/docs/TENANTS.md b/docs/TENANTS.md index fb7029b13c..a6d4ca7216 100644 --- a/docs/TENANTS.md +++ b/docs/TENANTS.md @@ -375,7 +375,11 @@ here** — **hostnames**, **`gethomepage.dev/*` dashboard annotations**, routes, 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. + self-presentation, not platform config. Homepage links the card to the route's first + hostname plus its first rule's first path, so a route whose first rule matches a sub-path + (an `/api` or `/mcp` bypass, say) must set `gethomepage.dev/href`; admission denies it + otherwise + ([`require-homepage-href-for-non-root-routes.yaml`](../k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml)). Use an existing tenant's `deploy/httproute.yaml` (e.g. `ascoachingogvaner`, which owns its hostnames *and* its dashboard annotations) as the reference. diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml new file mode 100644 index 0000000000..6d24314336 --- /dev/null +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml @@ -0,0 +1,107 @@ +# Keeps every Homepage card pointing at the service's front page. +# +# Why: unless a route sets gethomepage.dev/href, Homepage builds the card link +# itself, from the route's first hostname plus the path of its FIRST rule's +# FIRST match. Rule order is routing-neutral in the Gateway API, where the most +# specific match wins whatever its position, so nothing in a route reads as +# wrong when a narrow rule is listed first. Homepage is the only consumer of +# that order, and it fails silently: the card still renders, healthy, and links +# somewhere else. +# +# That is how the Coroot card broke. platform#5881 put an `Exact /mcp` rule +# (the agents' MCP endpoint, which bypasses SSO) ahead of the catch-all, so the +# card linked to /mcp, and a browser without a bearer token got Coroot's 401 +# "unauthorized" instead of the UI. A RegularExpression first path or a route +# without hostnames is worse: Homepage builds no link at all. +# +# So a route that opts into the dashboard may leave the link to Homepage only +# when the derived link is the site root; any other route must name its link +# explicitly with gethomepage.dev/href. The API server defaults an omitted rule +# list, match list or path to `PathPrefix /`, so an omitted one reads as the +# root here as well. +# +# Scope: HTTPRoutes in every namespace, platform and tenant alike, with +# background scanning on so existing drift surfaces in the PolicyReports. +# CREATE and UPDATE only, never DELETE, so a route can always be removed +# (#4732). Ingress is deliberately not matched: neither this repository nor the +# cluster serves one, and the tenant role grants only HTTPRoutes. Reading an +# Ingress's link needs its `http` field, and `ksail workload validate` skips +# any policy whose expressions name `http` (it cannot tell the field from the +# CEL http library), so covering Ingress would cost the offline evaluation that +# lets a pull request fail on a violating route before admission does. +apiVersion: policies.kyverno.io/v1 +kind: ValidatingPolicy +metadata: + name: require-homepage-href-for-non-root-routes + annotations: + policies.kyverno.io/title: Require Homepage Href For Non-Root Routes + policies.kyverno.io/category: Best Practices + policies.kyverno.io/severity: low + policies.kyverno.io/subject: HTTPRoute + policies.kyverno.io/minversion: 1.17.0 + policies.kyverno.io/description: >- + Requires every HTTPRoute that opts into Homepage discovery + (gethomepage.dev/enabled: "true") to set gethomepage.dev/href unless the + link Homepage would derive from it is the site root, so reordering a + route's rules cannot silently move its dashboard card to a sub-path. +spec: + failurePolicy: Fail + validationActions: + - Deny + evaluation: + background: + enabled: true + matchConstraints: + resourceRules: + - apiGroups: ["gateway.networking.k8s.io"] + apiVersions: ["*"] + operations: ["CREATE", "UPDATE"] + resources: ["httproutes"] + matchConditions: + - name: opted-into-homepage + expression: >- + object.metadata.?annotations.orValue({})[?'gethomepage.dev/enabled'].orValue('') == 'true' + variables: + # Homepage treats an empty href as absent, so this does too. + - name: href + expression: >- + object.metadata.?annotations.orValue({})[?'gethomepage.dev/href'].orValue('') + # https://, and no link at all + # without a hostname or for a RegularExpression path. A wildcard first + # hostname (`*.example.com`) yields a link no browser can open. The scheme + # comes from the Gateway named by parentRefs[0], and Homepage throws reading + # that entry when parentRefs is omitted or empty. The API server defaults + # only an OMITTED rule list or match list; an explicitly empty one stays + # empty, and Homepage then fails reading `[0]` of it, so omitted reads as + # the root and empty does not. + - name: linksToRoot + expression: >- + size(object.spec.?parentRefs.orValue([])) > 0 && + size(object.spec.?hostnames.orValue([])) > 0 && + !object.spec.hostnames[0].startsWith('*') && + ( + !object.spec.?rules.hasValue() || + ( + size(object.spec.rules) > 0 && + ( + !object.spec.rules[0].?matches.hasValue() || + ( + size(object.spec.rules[0].matches) > 0 && + object.spec.rules[0].matches[0].?path.?value.orValue('/') == '/' && + object.spec.rules[0].matches[0].?path.?type.orValue('PathPrefix') != 'RegularExpression' + ) + ) + ) + ) + validations: + - expression: >- + variables.href != '' || variables.linksToRoot + message: >- + An HTTPRoute with gethomepage.dev/enabled="true" must set + gethomepage.dev/href unless its first hostname and its first rule's + first path already form the site root. Homepage builds the card link + from exactly those two fields, so a route whose first rule matches a + sub-path (such as an /mcp or /api bypass) sends the card there, and a + missing or wildcard hostname, a missing parentRef, a RegularExpression + path, or an explicitly empty rule or match list leaves the card with no + usable link. diff --git a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml index 849a69eac4..bad35e2b6b 100644 --- a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml +++ b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml @@ -18,6 +18,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/require-homepage-href-for-non-root-routes.yaml - best-practices/restrict-github-team-external-identity.yaml - best-practices/restrict-github-team-management.yaml - best-practices/restrict-arc-openbao-listener.yaml diff --git a/k8s/bases/infrastructure/controllers/coroot/http-route.yaml b/k8s/bases/infrastructure/controllers/coroot/http-route.yaml index 3f3a30a932..a432c398e0 100644 --- a/k8s/bases/infrastructure/controllers/coroot/http-route.yaml +++ b/k8s/bases/infrastructure/controllers/coroot/http-route.yaml @@ -31,6 +31,13 @@ metadata: # uses for the Cilium and OpenBao routes. Homepage fetches icons client-side, # so the cluster egress NetworkPolicy doesn't block it. gethomepage.dev/icon: https://raw.githubusercontent.com/coroot/coroot/main/front/public/icon.svg + # Card link. Without it Homepage builds the link from the hostname plus the + # FIRST rule's first path match, and the first rule below is the /mcp + # bypass, so the card opened Coroot's MCP endpoint, which answers a browser + # without a bearer token with 401 "unauthorized" (#5881 added that rule). + # Pin the UI root so rule order cannot move the link again; the + # require-homepage-href-for-non-root-routes policy enforces this. + gethomepage.dev/href: https://observability.${domain} spec: parentRefs: - name: platform diff --git a/tests/policy-failure-actions.json b/tests/policy-failure-actions.json index ed8f8186fe..32b35c3175 100644 --- a/tests/policy-failure-actions.json +++ b/tests/policy-failure-actions.json @@ -7,6 +7,7 @@ "audit-privileged-rbac": "Enforce", "disallow-latest-tag": "Audit", "restrict-homepage-service-groups": "Enforce", + "require-homepage-href-for-non-root-routes": "Enforce", "restrict-tenant-issuer-refs": "Enforce", "restrict-tenant-network-policies/cnp-ingress-sources-must-be-constrained": "Enforce", "restrict-tenant-network-policies/cnp-egress-destinations-must-be-constrained": "Enforce", diff --git a/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml b/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml new file mode 100644 index 0000000000..f5e3188cd6 --- /dev/null +++ b/tests/require-homepage-href-for-non-root-routes/kyverno-test.yaml @@ -0,0 +1,51 @@ +apiVersion: cli.kyverno.io/v1alpha1 +kind: Test +metadata: + name: require-homepage-href-for-non-root-routes +policies: + - >- + ../../k8s/bases/infrastructure/cluster-policies/best-practices/require-homepage-href-for-non-root-routes.yaml +resources: + - resources.yaml + # Exercise the shipped Coroot route, whose /mcp rule comes first: it passes + # only while it keeps its explicit href. + - ../../k8s/bases/infrastructure/controllers/coroot/http-route.yaml +results: + # --- derived link is the site root, or an explicit href is set --- + - policy: require-homepage-href-for-non-root-routes + resources: + - whoami/root-prefix-first + - whoami/exact-root-first + - whoami/omitted-matches + - whoami/omitted-rules + - whoami/omitted-path + - observability/subpath-first-with-href + - whoami/wildcard-hostname-with-href + - whoami/empty-matches-with-href + - whoami/no-parent-refs-with-href + - observability/coroot + kind: HTTPRoute + result: pass + # --- a sub-path, a regex, a missing or wildcard hostname, no parentRef, or an empty list, with no href --- + - policy: require-homepage-href-for-non-root-routes + resources: + - observability/subpath-first-without-href + - whoami/prefix-subpath-first + - whoami/root-is-second-match + - whoami/regex-root-first + - whoami/no-hostnames + - whoami/empty-href + - whoami/wildcard-hostname + - whoami/empty-matches + - whoami/empty-rules + - whoami/no-parent-refs + - whoami/empty-parent-refs + kind: HTTPRoute + result: fail + # --- the precondition's negative control: routes that never opted in --- + - policy: require-homepage-href-for-non-root-routes + resources: + - whoami/not-opted-in + - whoami/discovery-disabled + kind: HTTPRoute + result: skip diff --git a/tests/require-homepage-href-for-non-root-routes/resources.yaml b/tests/require-homepage-href-for-non-root-routes/resources.yaml new file mode 100644 index 0000000000..24da563f27 --- /dev/null +++ b/tests/require-homepage-href-for-non-root-routes/resources.yaml @@ -0,0 +1,495 @@ +# --- derived link is the site root, or an explicit href is set: pass --------- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: root-prefix-first + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: whoami + port: 80 + - matches: + - path: + type: Exact + value: /api + backendRefs: + - name: whoami-api + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: exact-root-first + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: Exact + value: / + backendRefs: + - name: whoami + port: 80 +--- +# The API server defaults an omitted match list to `PathPrefix /`; the CLI +# applies no defaults, so this is the pre-defaulting shape of a catch-all. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: omitted-matches + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: omitted-path + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: + - headers: + - name: x-canary + value: "true" + backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: subpath-first-with-href + namespace: observability + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: https://observability.platform.lan +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - observability.platform.lan + rules: + - matches: + - path: + type: Exact + value: /mcp + backendRefs: + - name: coroot-mcp + port: 8080 + - backendRefs: + - name: oauth2-proxy + namespace: oauth2-proxy + port: 80 +--- +# The API server defaults an omitted rule list to one `PathPrefix /` rule. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: omitted-rules + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan +--- +# The cases below would have no usable derived link, and an href fixes each. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wildcard-hostname-with-href + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: https://whoami.platform.lan +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - "*.platform.lan" + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: empty-matches-with-href + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: https://whoami.platform.lan +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: [] + backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: no-parent-refs-with-href + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: https://whoami.platform.lan +spec: + hostnames: + - whoami.platform.lan + rules: + - backendRefs: + - name: whoami + port: 80 +--- +# --- derived link is a sub-path or missing, and no href: fail ---------------- +# The Coroot route as platform#5881 shipped it: the card linked to /mcp. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: subpath-first-without-href + namespace: observability + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - observability.platform.lan + rules: + - matches: + - path: + type: Exact + value: /mcp + backendRefs: + - name: coroot-mcp + port: 8080 + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: oauth2-proxy + namespace: oauth2-proxy + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: prefix-subpath-first + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: PathPrefix + value: /api + backendRefs: + - name: whoami-api + port: 80 +--- +# A second match on the first rule does not count: Homepage reads matches[0]. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: root-is-second-match + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: PathPrefix + value: /docs + - path: + type: PathPrefix + value: / + backendRefs: + - name: whoami + port: 80 +--- +# Homepage builds no link for a RegularExpression path, even one matching "/". +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: regex-root-first + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: RegularExpression + value: / + backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: no-hostnames + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: whoami + port: 80 +--- +# Homepage treats an empty href as unset and falls back to the derived link. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: empty-href + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" + gethomepage.dev/href: "" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: PathPrefix + value: /api + backendRefs: + - name: whoami-api + port: 80 +--- +# Homepage would link to https://*.platform.lan/, which no browser can open. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: wildcard-hostname + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - "*.platform.lan" + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: whoami + port: 80 +--- +# An explicitly empty list is not defaulted: Homepage fails reading matches[0]. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: empty-matches + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: [] + backendRefs: + - name: whoami + port: 80 +--- +# Likewise for an explicitly empty rule list: Homepage fails reading rules[0]. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: empty-rules + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: [] +--- +# Homepage reads parentRefs[0] for the scheme and throws when there is none. +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: no-parent-refs + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + hostnames: + - whoami.platform.lan + rules: + - backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: empty-parent-refs + namespace: whoami + annotations: + gethomepage.dev/enabled: "true" +spec: + parentRefs: [] + hostnames: + - whoami.platform.lan + rules: + - backendRefs: + - name: whoami + port: 80 +--- +# --- the precondition's negative control: routes that never opted in -------- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: not-opted-in + namespace: whoami +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: Exact + value: /mcp + backendRefs: + - name: whoami + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: discovery-disabled + namespace: whoami + annotations: + gethomepage.dev/enabled: "false" +spec: + parentRefs: + - name: platform + namespace: kube-system + sectionName: https + hostnames: + - whoami.platform.lan + rules: + - matches: + - path: + type: PathPrefix + value: /api/send + backendRefs: + - name: whoami + port: 80