From 60876cfc7d70c09c67eb4f250acc3d7ec20dd532 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 12 Sep 2026 13:30:17 +0200 Subject: [PATCH] feat(tenants): follow trusted release streams Signed-off-by: Nikolai Emil Damm --- .../ascoachingogvaner/oci-repository.yaml | 2 +- .../apps/wedding-app/oci-repository.yaml | 2 +- scripts/guard-oci-repository-verify.sh | 28 +++++++++++++++++-- .../tests/test-guard-oci-repository-verify.sh | 23 +++++++++++++++ ...test-publish-workflow-signing-revisions.sh | 6 ++-- scripts/validate-eks-ci-role-policy/main.go | 19 ++++++++++++- 6 files changed, 73 insertions(+), 7 deletions(-) diff --git a/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml b/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml index 6789b7477..2425cb762 100644 --- a/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml +++ b/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml @@ -9,7 +9,7 @@ metadata: spec: interval: 1m ref: - tag: 1.13.4 + semver: ">=1.0.0" url: oci://ghcr.io/devantler-tech/ascoachingogvaner/manifests secretRef: name: ghcr-auth diff --git a/k8s/bases/apps/wedding-app/oci-repository.yaml b/k8s/bases/apps/wedding-app/oci-repository.yaml index 1bb40c185..0c01e5c70 100644 --- a/k8s/bases/apps/wedding-app/oci-repository.yaml +++ b/k8s/bases/apps/wedding-app/oci-repository.yaml @@ -9,7 +9,7 @@ metadata: spec: interval: 1m ref: - tag: 1.15.10 + semver: ">=1.0.0" url: oci://ghcr.io/devantler-tech/wedding-app/manifests secretRef: name: ghcr-auth diff --git a/scripts/guard-oci-repository-verify.sh b/scripts/guard-oci-repository-verify.sh index b572e51db..16e81ee04 100755 --- a/scripts/guard-oci-repository-verify.sh +++ b/scripts/guard-oci-repository-verify.sh @@ -21,6 +21,10 @@ # reads as narrowed; alternation belongs inside the one subject regex, where the # subject guards judge it. # - that entry names a non-empty `issuer` and a non-empty `subject`. +# - the trusted Wedding and AS Coaching tenant artifacts follow the governed +# `>=1.0.0` semver stream. The platform owns their signer and namespace +# boundaries; restoring a tag or digest would reintroduce a platform PR for +# every tenant release and is therefore rejected by the rendered-tree guard. # # IT JUDGES THE KUSTOMIZE BUILD, NOT THE SOURCE FILES — MEASURED, NOT PREFERRED. A first # version scanned `k8s/**/*.yaml` with yq and was bypassed five ways in one review @@ -82,6 +86,13 @@ readonly REQUIRED_URL_PREFIX='oci://ghcr.io/devantler-tech/' # the literal regex text the manifests carry (the `.` escaped), not evaluated. readonly REQUIRED_ISSUER='^https://token\.actions\.githubusercontent\.com$' readonly REQUIRED_SUBJECT_PREFIX='^https://github\.com/devantler-tech/' +readonly REQUIRED_TENANT_STREAM_SEMVER='>=1.0.0' +readonly WEDDING_STREAM_URL='oci://ghcr.io/devantler-tech/wedding-app/manifests' +readonly ASCOACHING_STREAM_URL='oci://ghcr.io/devantler-tech/ascoachingogvaner/manifests' +# KRO preserves this placeholder in the rendered ResourceGraphDefinition; every +# Tenant instance resolves it to its own trusted repository name at runtime. +# shellcheck disable=SC2016 +readonly TEMPLATED_TENANT_STREAM_URL='oci://ghcr.io/devantler-tech/${schema.spec.name}/manifests' # Where the cluster overlays and the Flux roots they name live (seam for the test's # discovery fixture; the roots are resolved relative to this directory). readonly K8S_DIR="${OCI_VERIFY_K8S_DIR:-$REPO_ROOT/k8s}" @@ -218,6 +229,7 @@ fi # `read`'s whitespace splitting (which shifted every column right of the first empty one # and produced wrong-reason messages). `-` is decoded back to empty below. # url name has_verify provider identities_type identities_count incomplete_entries +# issuer subject ref_type ref_key_count ref_semver readonly YQ_ROWS='[.. | select(type == "!!map" and .kind == "OCIRepository")] | .[] | [ @@ -230,7 +242,10 @@ readonly YQ_ROWS='[.. | select(type == "!!map" and .kind == "OCIRepository")] (([.spec.verify.matchOIDCIdentity | select(type == "!!seq") | .[] | select(((.issuer // "") == "") or ((.subject // "") == ""))] | length) | tostring), ((.spec.verify.matchOIDCIdentity | select(type == "!!seq") | .[0].issuer) // ""), - ((.spec.verify.matchOIDCIdentity | select(type == "!!seq") | .[0].subject) // "") + ((.spec.verify.matchOIDCIdentity | select(type == "!!seq") | .[0].subject) // ""), + (.spec.ref | type), + ((((.spec.ref | select(type == "!!map") | keys | length) // 0)) | tostring), + ((.spec.ref | select(type == "!!map") | .semver) // "") ] | map(sub("^$", "-")) | join(" ")' @@ -260,10 +275,11 @@ while IFS= read -r root; do fail "$label: yq could not read the render, so its OCIRepositories are UNKNOWN: $(tr '\n' ' ' <"$work/rows.err")" continue fi - while IFS=$'\t' read -r url name has_verify provider ids_type ids_count incomplete issuer subject; do + while IFS=$'\t' read -r url name has_verify provider ids_type ids_count incomplete issuer subject ref_type ref_key_count ref_semver; do [ -n "$url" ] || continue url="$(decode "$url")"; name="$(decode "$name")"; provider="$(decode "$provider")" ids_type="$(decode "$ids_type")"; issuer="$(decode "$issuer")"; subject="$(decode "$subject")" + ref_type="$(decode "$ref_type")"; ref_semver="$(decode "$ref_semver")" [ -n "$url$name" ] || continue url="$(normalise_url "$url")" # The second pattern is the literal characters `${` — a Flux substitution marker, @@ -281,6 +297,14 @@ while IFS= read -r root; do *) continue ;; esac in_scope=$((in_scope + 1)) + case "$url" in + "$WEDDING_STREAM_URL" | "$ASCOACHING_STREAM_URL" | "$TEMPLATED_TENANT_STREAM_URL") + if [ "$ref_type" != "!!map" ] || [ "$ref_key_count" -ne 1 ] || [ "$ref_semver" != "$REQUIRED_TENANT_STREAM_SEMVER" ]; then + fail "$label: OCIRepository $name ($url) must follow the governed release stream with exactly spec.ref.semver: $REQUIRED_TENANT_STREAM_SEMVER; tag, digest, mixed and missing selectors require a platform boundary change" + continue + fi + ;; + esac if reason="$(exempt_reason "$url")"; then seen_exempt="$seen_exempt$url " diff --git a/scripts/tests/test-guard-oci-repository-verify.sh b/scripts/tests/test-guard-oci-repository-verify.sh index d84956949..21b613c26 100755 --- a/scripts/tests/test-guard-oci-repository-verify.sh +++ b/scripts/tests/test-guard-oci-repository-verify.sh @@ -119,6 +119,29 @@ assert_renders() { root="$(fresh_root green)" expect_accepted 'a verified in-scope OCIRepository with one identity is accepted' "$root" +# --- GREEN: trusted tenants may follow the platform-owned release boundary --- +root="$(fresh_root trusted-stream)" +repo_doc wedding-app 'oci://ghcr.io/devantler-tech/wedding-app/manifests' "$(good_verify)" >"$root/wedding.yaml"; add_resource "$root" wedding.yaml +expect_accepted 'a trusted tenant using the governed semver stream is accepted' "$root" + +# --- RED: trusted tenant mobility must not silently regress to platform pins --- +root="$(fresh_root trusted-stream-tag)" +repo_doc wedding-app 'oci://ghcr.io/devantler-tech/wedding-app/manifests' "$(good_verify)" >"$root/wedding.yaml"; add_resource "$root" wedding.yaml +yq -i '.spec.ref = {"tag": "v1.15.11"}' "$root/wedding.yaml" +expect_refused 'a trusted tenant fixed tag is refused by name' "$root" 'OCIRepository wedding-app (oci://ghcr.io/devantler-tech/wedding-app/manifests) must follow the governed release stream with exactly spec.ref.semver: >=1.0.0' + +root="$(fresh_root trusted-stream-digest)" +repo_doc ascoachingogvaner 'oci://ghcr.io/devantler-tech/ascoachingogvaner/manifests' "$(good_verify)" >"$root/ascoachingogvaner.yaml"; add_resource "$root" ascoachingogvaner.yaml +yq -i '.spec.ref = {"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' "$root/ascoachingogvaner.yaml" +expect_refused 'a trusted tenant digest pin is refused by name' "$root" 'OCIRepository ascoachingogvaner (oci://ghcr.io/devantler-tech/ascoachingogvaner/manifests) must follow the governed release stream with exactly spec.ref.semver: >=1.0.0' + +root="$(fresh_root templated-trusted-stream-tag)" +# shellcheck disable=SC2016 # KRO placeholder is intentionally literal. +repo_doc '${schema.spec.name}' 'oci://ghcr.io/devantler-tech/${schema.spec.name}/manifests' "$(good_verify)" >"$root/tenant-template.yaml"; add_resource "$root" tenant-template.yaml +yq -i '.spec.ref = {"tag": "v1.0.0"}' "$root/tenant-template.yaml" +# shellcheck disable=SC2016 # Expected diagnostic preserves the KRO placeholder. +expect_refused 'a templated trusted tenant fixed tag is refused by name' "$root" 'OCIRepository ${schema.spec.name} (oci://ghcr.io/devantler-tech/${schema.spec.name}/manifests) must follow the governed release stream with exactly spec.ref.semver: >=1.0.0' + # --- RED: no spec.verify at all (AC1) --- root="$(fresh_root noverify)" repo_doc bare 'oci://ghcr.io/devantler-tech/bare/manifests' '' >"$root/bare.yaml"; add_resource "$root" bare.yaml diff --git a/scripts/tests/test-publish-workflow-signing-revisions.sh b/scripts/tests/test-publish-workflow-signing-revisions.sh index ce72d5156..e7f1b98ad 100755 --- a/scripts/tests/test-publish-workflow-signing-revisions.sh +++ b/scripts/tests/test-publish-workflow-signing-revisions.sh @@ -978,8 +978,10 @@ cat >"$origin_stub" <=1.0.0. Their +// Cosign verification provider, issuer and platform-bounded workflow subjects +// remain unchanged, and the rendered guard rejects restoring a tag or digest. +// +// CONSERVATION, read from this validator under the SHA256-verified kubectl +// v1.36.2 renderer: exactly this aggregate changed. It reported ZERO +// `unapproved rendered `, ZERO `missing rendered authorization +// resource` and ZERO `duplicate rendered`; every pinned per-resource identity +// still passes. No Role, ClusterRole, binding, ServiceAccount, subject, verb, +// wildcard, AWS identity or permission changes. The unresolved Flux +// substitutions are the normal diagnostics emitted alongside an aggregate +// mismatch. +// +// Previous aggregate: 5b85be735c3d7d32e2bf6dce91c0e73435986c169234f2d72984617e9dc25a65. +const expectedRenderedSurfaceSHA = "814debc4fdfaf76be14273992a9bc982fb30a548fc3fa3f55baea6213e5582a1" // authorizationOverlayPaths lists every independently reconciled production // layer where an object can grant privileges to the aws/aws service account.