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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,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'
Expand Down Expand Up @@ -305,6 +306,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
Expand Down
73 changes: 68 additions & 5 deletions k8s/bases/apps/github-config/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
95 changes: 95 additions & 0 deletions scripts/tests/test-github-config-role-activation-parity.sh
Original file line number Diff line number Diff line change
@@ -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

# `<resource>.<group>` for every activated GitHub managed resource.
activated="$(
yq --no-doc -r \
'.spec.activate[] | select(. == "*.github.m.upbound.io")' \
"${policy}" | sort -u
)"

# `<resource>.<group>` 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
Comment thread
devantler marked this conversation as resolved.
| .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."
20 changes: 19 additions & 1 deletion scripts/validate-eks-ci-role-policy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading