From 2b9d2e0578fb152e89e06688e4c24efce30c6884 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 6 Aug 2026 17:28:34 +0200 Subject: [PATCH 1/3] fix(rbac): enumerate github-config managed-resource kinds Replace the resources wildcard on the tenant Role with the kinds the ManagedResourceActivationPolicy activates. Part of #2787 --- k8s/bases/apps/github-config/role.yaml | 73 ++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/k8s/bases/apps/github-config/role.yaml b/k8s/bases/apps/github-config/role.yaml index 131fb3c6f..695d5f0f0 100644 --- a/k8s/bases/apps/github-config/role.yaml +++ b/k8s/bases/apps/github-config/role.yaml @@ -9,14 +9,24 @@ # verbs over the namespaced provider-upjet-github managed-resource API groups, # and only within the github-config namespace. Because the managed resources are # namespaced (Crossplane v2 `*.github.m.upbound.io`), a plain Role + RoleBinding -# suffices — no ClusterRole/ClusterRoleBinding, no cluster RBAC. Widen apiGroups -# here only as new GitHub MR groups are activated in the -# ManagedResourceActivationPolicy. +# suffices — no ClusterRole/ClusterRoleBinding, no cluster RBAC. +# +# Each managed-resource rule names its resources explicitly rather than `*`, so +# the Role grants exactly the kinds the ManagedResourceActivationPolicy turns +# into CRDs and nothing else. That parity is the invariant: extend BOTH this +# file and +# k8s/providers/hetzner/infrastructure/crossplane/managed-resource-activation-policy.yaml +# when a new GitHub MR kind comes under management. +# scripts/tests/test-github-config-role-activation-parity.sh fails the build +# naming the missing kind if the two ever disagree, so the omission surfaces in +# CI instead of as a runtime apply failure inside the tenant's Flux +# Kustomization. # # The .github artifact the SA applies also contains the namespaced ProviderConfig # (github.m.upbound.io) and the GitHub App credential ExternalSecret # (external-secrets.io), so the SA needs verbs on those two groups too — still -# confined to this namespace. +# confined to this namespace. Neither is a managed resource, so neither +# participates in the activation-policy parity check above. apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: @@ -27,12 +37,65 @@ metadata: rules: - apiGroups: - repo.github.m.upbound.io + resources: + - repositories + - defaultbranches + - branchprotections + - repositoryrulesets + - issuelabels + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: - team.github.m.upbound.io + resources: + - teams + - teammemberships + - teamrepositories + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: - actions.github.m.upbound.io + resources: + - repositorypermissions + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: - enterprise.github.m.upbound.io + resources: + - organizationrulesets + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + # The tenant applies the namespaced ProviderConfig that binds its managed + # resources to the GitHub App credential. ProviderConfigUsage is written by + # the provider controller, never by this ServiceAccount, so it stays out. + - apiGroups: - github.m.upbound.io resources: - - "*" + - providerconfigs verbs: - get - list From 156a38d3f2a44703b3ed9e0773b96a3fdc5f9bfc Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 6 Aug 2026 17:33:13 +0200 Subject: [PATCH 2/3] test(rbac): fail CI when the github-config Role and MRAP diverge Add a parity check asserting the tenant Role grants exactly the managed-resource kinds the ManagedResourceActivationPolicy activates, in both directions, and wire it into the k8s-gated CI job. Part of #2787 --- .github/workflows/ci.yaml | 11 +++ ...st-github-config-role-activation-parity.sh | 95 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100755 scripts/tests/test-github-config-role-activation-parity.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 367638274..afef72c71 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,6 +80,7 @@ jobs: - 'scripts/validate-naming.py' - 'scripts/validate-embedded-json.py' - '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-kyverno-admission-vpa.sh' - 'scripts/tests/kyverno-admission-vpa-rules.yaml' @@ -303,6 +304,16 @@ jobs: # serializes it as a string and leaves the role unenforced. run: bash scripts/tests/test-openbao-oidc-role.sh + - name: 🔑 Validate github-config Role covers the activated MR kinds + if: needs.changes.outputs.k8s == 'true' + # The tenant Role enumerates managed-resource kinds instead of `*`, so + # activating a kind without granting it would fail at apply time inside + # a separately published OCI artifact. Catch the divergence here. Bash, + # yq and the runner's kubectl; no cluster or secrets required. + run: | + shellcheck scripts/tests/test-github-config-role-activation-parity.sh + bash scripts/tests/test-github-config-role-activation-parity.sh + - name: ⚖️ Install Kyverno CLI if: needs.changes.outputs.k8s == 'true' # Pinned to the version the cluster actually runs (the kyverno Helm diff --git a/scripts/tests/test-github-config-role-activation-parity.sh b/scripts/tests/test-github-config-role-activation-parity.sh new file mode 100755 index 000000000..fb20c1e75 --- /dev/null +++ b/scripts/tests/test-github-config-role-activation-parity.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The github-config tenant Role must grant exactly the managed-resource kinds +# the ManagedResourceActivationPolicy turns into CRDs. +# +# Activating a kind without granting it leaves the tenant's Flux Kustomization +# failing at apply time, inside a separately published OCI artifact, with an +# RBAC error that names the ServiceAccount rather than this file. Granting a +# kind that is not activated re-widens the tenant's authority past the +# namespaced surface the Role exists to bound. Both directions are checked. +# +# The bare `github.m.upbound.io` group is deliberately out of scope: it carries +# the ProviderConfig, which is not a managed resource and never appears in the +# activation policy. The `*.github.m.upbound.io` suffix match excludes it. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" +role="${repo_root}/k8s/bases/apps/github-config/role.yaml" +policy="${repo_root}/k8s/providers/hetzner/infrastructure/crossplane/managed-resource-activation-policy.yaml" + +for f in "${role}" "${policy}"; do + if [[ ! -f "${f}" ]]; then + echo "::error::missing input for the github-config RBAC parity check: ${f}" + exit 1 + fi +done + +# `.` for every activated GitHub managed resource. +activated="$( + yq --no-doc -r \ + '.spec.activate[] | select(. == "*.github.m.upbound.io")' \ + "${policy}" | sort -u +)" + +# `.` for every managed-resource kind the Role grants. +# shellcheck disable=SC2016 # $g is a yq variable; the shell must not expand it. +granted="$( + yq --no-doc -r \ + '.rules[] + | select(.apiGroups[] == "*.github.m.upbound.io") + | .apiGroups[] as $g + | .resources[] + "." + $g' \ + "${role}" | sort -u +)" + +# Fail closed on a vacuous read. A path typo, a renamed field, or a yq upgrade +# that changes the expression's meaning would otherwise make BOTH sides empty +# and the comparison below pass while testing nothing. +activated_count="$(printf '%s\n' "${activated}" | grep -c . || true)" +granted_count="$(printf '%s\n' "${granted}" | grep -c . || true)" + +if ((activated_count < 5)); then + echo "::error::read ${activated_count} activated GitHub managed resources from ${policy}; expected at least 5 — the query matched nothing meaningful" + exit 1 +fi + +if ((granted_count < 5)); then + echo "::error::read ${granted_count} granted GitHub managed resources from ${role}; expected at least 5 — the query matched nothing meaningful" + exit 1 +fi + +# A wildcard would satisfy every parity comparison below while granting the +# whole group, so reject it explicitly rather than relying on the set diff. +if printf '%s\n' "${granted}" | grep -q '^\*\.'; then + echo "::error::${role} grants a resources wildcard on a GitHub managed-resource group; enumerate the activated kinds instead" + exit 1 +fi + +missing="$(comm -23 <(printf '%s\n' "${activated}") <(printf '%s\n' "${granted}"))" +extra="$(comm -13 <(printf '%s\n' "${activated}") <(printf '%s\n' "${granted}"))" + +status=0 + +if [[ -n "${missing}" ]]; then + status=1 + while IFS= read -r kind; do + [[ -n "${kind}" ]] || continue + echo "::error::${kind} is activated but the github-config Role does not grant it; add its resource name to the matching apiGroup rule in k8s/bases/apps/github-config/role.yaml" + done <<<"${missing}" +fi + +if [[ -n "${extra}" ]]; then + status=1 + while IFS= read -r kind; do + [[ -n "${kind}" ]] || continue + echo "::error::the github-config Role grants ${kind}, which no ManagedResourceActivationPolicy activates; remove it or activate the kind in k8s/providers/hetzner/infrastructure/crossplane/managed-resource-activation-policy.yaml" + done <<<"${extra}" +fi + +if ((status != 0)); then + exit "${status}" +fi + +echo "github-config Role grants exactly the ${activated_count} activated GitHub managed-resource kinds." From 6bec724f09753c7d9bff7bbabd550a64ec0803dc Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 6 Aug 2026 17:44:30 +0200 Subject: [PATCH 3/3] chore(security): re-approve the EKS authorization surface pin The tenant Role narrowing moves the aggregate rendered-surface fingerprint. Re-measured per the constant's own re-approval ritual: 515 documents both sides, membership set difference zero in both directions, 133 grant-bearing documents unchanged, every aws-bearing line byte-identical. Part of #2787 --- scripts/validate-eks-ci-role-policy/main.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/validate-eks-ci-role-policy/main.go b/scripts/validate-eks-ci-role-policy/main.go index 3a0dd1962..dec611657 100644 --- a/scripts/validate-eks-ci-role-policy/main.go +++ b/scripts/validate-eks-ci-role-policy/main.go @@ -218,6 +218,24 @@ const ( // cosign matcher tightening on the then-four live trust rules: 503 documents on // both sides, four changed `subject:` lines, no grant-bearing object moved.) // +// This value covers narrowing the github-config tenant Role from a resources +// wildcard to the managed-resource kinds its ManagedResourceActivationPolicy +// activates. Measured against main 72fe7919: 515 documents on both sides, with +// membership IDENTICAL — set difference in BOTH directions over +// apiVersion|kind|namespace|name returned zero, so nothing was added, removed +// or renamed. Exactly ONE entry's content moved: +// +// rbac.authorization.k8s.io/v1 Role github-config/github-config-managed-resources +// +// Its rendered delta replaces `resources: ['*']` on five +// `*.github.m.upbound.io` groups with the ten activated kinds, and adds +// `providerconfigs` for the ProviderConfig the tenant applies. It is strictly a +// tightening: every resource the new rules admit, the wildcard already admitted. +// The surface carries 133 Role / ClusterRole / RoleBinding / ClusterRoleBinding +// / ServiceAccount documents on BOTH sides, and every `aws`-bearing line is +// byte-identical across the two trees, so nothing granted to the aws/aws +// service account this validator exists to protect is touched. +// // NOTE for whoever re-approves this next: an opaque ciphertext in the surface // moves on ANY re-encryption — this value also absorbed a SOPS version bump // (3.13.2 -> 3.13.3) — so a routine secret rotation reds this gate with no @@ -227,7 +245,7 @@ const ( // kubectl render diff — render k8s/providers/hetzner/{apps,infrastructure, // infrastructure/controllers} plus k8s/clusters/prod/{bootstrap,} for both // trees and diff them. -const expectedRenderedSurfaceSHA = "6af27989825d60e4139a4d8754ff6e4be4ed5e20b75181dacade8653c14cc5aa" +const expectedRenderedSurfaceSHA = "e28f84558cdc773eafbde8d04a819e1623b839ea4e743625e875f6bc3790cae2" // authorizationOverlayPaths lists every independently reconciled production // layer where an object can grant privileges to the aws/aws service account.