From 62e5d8fd2c380353b11bde71f59087d18f9fd748 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 23 Oct 2026 12:01:17 +0200 Subject: [PATCH 1/8] feat(tenants): support verified source-only bootstrap --- .github/workflows/ci.yaml | 2 + docs/tenant-source-bootstrap.md | 16 + docs/validation.md | 10 +- .../tenant/resource-graph-definition.yaml | 20 +- scripts/guard-oci-repository-verify.sh | 11 + scripts/guard-shared-publish-workflow-pin.sh | 20 +- scripts/rgd-template-static-scan-baseline.tsv | 6 +- scripts/tenant-source-constructors.lib.sh | 43 ++ ...st-publish-workflow-canonical-approvals.sh | 4 + .../tests/test-tenant-source-constructors.sh | 87 ++++ scripts/tests/test-webapp-compiler.sh | 27 ++ scripts/tests/webapp-compiler/tenant_test.go | 442 ++++++++++++++++++ 12 files changed, 678 insertions(+), 10 deletions(-) create mode 100644 scripts/tenant-source-constructors.lib.sh create mode 100644 scripts/tests/test-tenant-source-constructors.sh create mode 100644 scripts/tests/webapp-compiler/tenant_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ac25cf4a4a..8a843abc3a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1222,6 +1222,8 @@ jobs: run: | shellcheck scripts/guard-oci-repository-verify.sh shellcheck scripts/tests/test-guard-oci-repository-verify.sh + shellcheck scripts/tenant-source-constructors.lib.sh scripts/tests/test-tenant-source-constructors.sh + bash scripts/tests/test-tenant-source-constructors.sh bash scripts/tests/test-guard-oci-repository-verify.sh ./scripts/guard-oci-repository-verify.sh diff --git a/docs/tenant-source-bootstrap.md b/docs/tenant-source-bootstrap.md index a316efc2d6..78953b9af2 100644 --- a/docs/tenant-source-bootstrap.md +++ b/docs/tenant-source-bootstrap.md @@ -87,6 +87,22 @@ acceptance. Pages and the private portal remain unchanged. ## Observe before activating reconciliation +A generic `Tenant` can bootstrap only its verified source with +`spec.reconcileApplication: false`. This omits both application Kustomizations, +including the SOPS variant, rather than creating suspended children. Set +`spec.source.digest` and `spec.source.publisherRevision` together: the former is +an exact `sha256:` digest and the latter the reviewed maintained publisher's +40-character lowercase commit. Neither setting creates a consumer approval. +Publication evidence, approval generation and live source verification remain +separate requirements. + +Omitting the pair preserves the normal semver selector and existing publisher +policy. Omitting `reconcileApplication` preserves application reconciliation. +After verification, a source-only Tenant can change false to true. Admission +refuses true to false, including active objects stored before this field +existed: disabling an active tenant requires a separately reviewed lifecycle +procedure, not pruning its workloads through this bootstrap switch. + After the reviewed source is deployed, confirm `Ready=True`, `SourceVerified=True` and a current `observedGeneration`. Its `status.artifact.revision` must equal the declared manifest digest. `status.artifact.digest` is a different identity: Flux's diff --git a/docs/validation.md b/docs/validation.md index 14782157cf..cf5adbc547 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -23,13 +23,21 @@ CI path filters. ## WebApp compiler -Run the committed WebApp graph through the pinned KRO compiler: +Run the committed WebApp and Tenant graphs through the pinned KRO compiler: ```bash bash scripts/tests/test-webapp-compiler.sh bash scripts/tests/test-webapp-compiler-wiring.sh ``` +Tenant controls compile the complete graph and submit its generated API schema +to the real API server. They cover source-only creation, paired digest and +publisher inputs, unchanged omitted defaults, later application activation and +refusal to disable active tenants, including objects stored under the earlier +schema. These checks do not prove a live controller has reconciled a tenant. +`scripts/tests/test-tenant-source-constructors.sh` separately exercises the actual +publication guards against constrained constructors and mutated graph contexts. + The fixture downloads checksum-verified Kubernetes 1.36.2 API-server assets and starts a disposable, loopback-only API server. It explicitly refuses an existing cluster. The KRO 0.9.2 CRD applies the actual graph-admission defaults; the Gateway diff --git a/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml b/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml index f5a462a88c..62eb584501 100644 --- a/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml +++ b/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml @@ -70,6 +70,9 @@ spec: # parent field entirely, but `has()` on a whole optional object is not # exercised anywhere else in this file. types: + TenantSource: + digest: 'string | default="" maxLength=71 pattern="^$|^sha256:[0-9a-f]{64}$"' + publisherRevision: 'string | default="" maxLength=40 pattern="^$|^[0-9a-f]{40}$"' TenantResourceQuota: enabled: boolean | default=false # Total across all pods in the namespace (both request AND limit — @@ -103,6 +106,14 @@ spec: # Opt-in own-namespace WebApp reconciliation. Admission also binds the # parent and generated child ownership; no existing tenant opts in here. webApp: boolean | default=false + # Digest-addressed bootstrap uses an exact maintained publisher revision. + # The separate consumer approval guard authorizes that revision; syntax + # validation here never grants deployment or publisher authority. + source: >- + TenantSource | default={} validation="(!has(self.digest) || self.digest == '') == (!has(self.publisherRevision) || self.publisherRevision == '')" + # Source-only bootstrap is an initial state, not a pause switch. Turning + # an active child off would remove it from KRO and can prune workloads. + reconcileApplication: 'boolean | default=true validation="self || !oldSelf"' # Standard multi-tenancy guardrails (#1932 follow-up #2522): a # namespace-wide ResourceQuota and a per-container default LimitRange. # Both default to `enabled: false` — no behaviour change for existing @@ -279,8 +290,7 @@ spec: app.kubernetes.io/managed-by: ksail spec: interval: 1m - ref: - semver: ">=1.0.0" + ref: '${schema.spec.source.digest == "" ? {"semver": ">=1.0.0"} : {"digest": schema.spec.source.digest}}' url: oci://ghcr.io/devantler-tech/${schema.spec.name}/manifests secretRef: name: ghcr-auth @@ -298,7 +308,7 @@ spec: # identities are not supported at this time"), so any future # alternation must stay inside one subject regex. - issuer: '^https://token\.actions\.githubusercontent\.com$' - subject: '^https://github\.com/devantler-tech/(actions/\.github/workflows/publish-app\.yaml@[0-9a-f]{40}|\.github/\.github/workflows/publish-app\.yaml@(0c7b6c3509b73adbc7537e030935bb01987e625b|467f07582436a26b62cf4aa756423512f85ba0c0))$' + subject: '${schema.spec.source.publisherRevision == "" ? "^https://github\\.com/devantler-tech/(actions/\\.github/workflows/publish-app\\.yaml@[0-9a-f]{40}|\\.github/\\.github/workflows/publish-app\\.yaml@(0c7b6c3509b73adbc7537e030935bb01987e625b|467f07582436a26b62cf4aa756423512f85ba0c0))$" : "^https://github\\.com/devantler-tech/\\.github/\\.github/workflows/publish-app\\.yaml@" + schema.spec.source.publisherRevision + "$"}' # --- Flux Kustomization (one of two variants by `sops`) --------------- # The impersonating Kustomization that reconciles the tenant's own artifact # under its namespaced SA (bound to the `tenant-edit` role). Two variants because the SOPS `decryption` @@ -307,7 +317,7 @@ spec: # on a tenant with no `sops-age` Secret would wedge reconciliation. - id: kustomization includeWhen: - - ${!schema.spec.sops} + - ${schema.spec.reconcileApplication && !schema.spec.sops} template: apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization @@ -344,7 +354,7 @@ spec: targetNamespace: ${tenantNamespace.metadata.name} - id: kustomizationSops includeWhen: - - ${schema.spec.sops} + - ${schema.spec.reconcileApplication && schema.spec.sops} template: apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization diff --git a/scripts/guard-oci-repository-verify.sh b/scripts/guard-oci-repository-verify.sh index a533e5dd2f..98d772303c 100755 --- a/scripts/guard-oci-repository-verify.sh +++ b/scripts/guard-oci-repository-verify.sh @@ -80,6 +80,8 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" readonly REPO_ROOT +# shellcheck source=scripts/tenant-source-constructors.lib.sh +source "$REPO_ROOT/scripts/tenant-source-constructors.lib.sh" readonly REQUIRED_URL_PREFIX='oci://ghcr.io/devantler-tech/' # The identity every devantler-tech artifact is signed under: GitHub Actions keyless # signing, with a subject regex anchored on a devantler-tech GitHub path. Compared as @@ -297,6 +299,15 @@ while IFS= read -r root; do *) continue ;; esac in_scope=$((in_scope + 1)) + # Only the complete constrained Tenant profile may construct these values. + # Validate its supplied branch too, before judging the unchanged default. + if [[ "$url" == "$TEMPLATED_TENANT_STREAM_URL" && "$ref_type" == '!!str' ]]; then + if ! subject="$(tenant_source_default_subject "$work/render.yaml" "$subject")"; then + fail "$label: invalid Tenant source constructor" + continue + fi + ref_type='!!map'; ref_key_count=1; ref_semver="$REQUIRED_TENANT_STREAM_SEMVER" + fi 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 diff --git a/scripts/guard-shared-publish-workflow-pin.sh b/scripts/guard-shared-publish-workflow-pin.sh index 82464ff7c0..d938ff6b63 100755 --- a/scripts/guard-shared-publish-workflow-pin.sh +++ b/scripts/guard-shared-publish-workflow-pin.sh @@ -86,7 +86,7 @@ readonly REPO_ROOT # Either publisher family, with an optional group opener (#4502), so a two-family subject # and a canonical-only one are FOUND and judged rather than left outside the scan. Kept # textually parallel to FAMILY_SUBJECT_PATTERN in publish-workflow-approved-revisions.lib.sh. -readonly SUBJECT_PATTERN='(subject|subjectRegex|subjectRegExp):[[:space:]]*.?\^?https://github\\?\.com/devantler-tech/[(]?(actions|\\?\.github)/\\?\.github/workflows/publish-(app|manifests)\\?\.yaml@' +readonly SUBJECT_PATTERN='(subject|subjectRegex|subjectRegExp):[[:space:]]*(.?\^?https://github\\?\.com/devantler-tech/[(]?(actions|\\?\.github)/\\?\.github/workflows/publish-(app|manifests)\\?\.yaml@|.?[$][{]schema[.]spec[.]source[.]publisherRevision)' # The workflow identity of either family with no key name, no organisation prefix and no # line structure, for the independent discovery below. The canonical half of a two-family @@ -735,6 +735,24 @@ EOF continue fi + # shellcheck disable=SC2016 # Match the literal CEL expression opener. + if [[ "$subject" == '${'* ]]; then + if [[ ! -r "$REPO_ROOT/scripts/tenant-source-constructors.lib.sh" ]]; then + printf '%s: invalid Tenant source constructor: reader is missing\n' "$location" >&2 + status=1 + continue + fi + # shellcheck source=scripts/tenant-source-constructors.lib.sh + source "$REPO_ROOT/scripts/tenant-source-constructors.lib.sh" + if ! subject="$(tenant_source_default_subject "${location%:*}" "$subject")"; then + printf '%s: invalid Tenant source constructor\n' "$location" >&2 + status=1 + continue + fi + judge_family_subject "$location" "$subject" || status=1 + continue + fi + # A TWO-FAMILY SUBJECT IS READ BY ITS OWN STRICT PARSE (#4502). It carries two `@` by # design, so it is dispatched before the single-identity rule below rather than by # relaxing that rule: every other subject is still held to exactly one. diff --git a/scripts/rgd-template-static-scan-baseline.tsv b/scripts/rgd-template-static-scan-baseline.tsv index b39c9cb697..6b3f3b602e 100644 --- a/scripts/rgd-template-static-scan-baseline.tsv +++ b/scripts/rgd-template-static-scan-baseline.tsv @@ -15,7 +15,7 @@ # The parameterized WebApp identities are additionally scanned after actual KRO resolution by # test-webapp-compiled-scan.sh: omitted defaults still report writable root and UID/GID <= 10000. # Disappearing low-ID rows below are not clearance for those compatibility defaults. -1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml RGD-CONTENT SHA256 4b4f077ffee9276ede0339daf8e1a5e2f8adeb7d8c8705aabc3e872393c855b0 +1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml RGD-CONTENT SHA256 fe532e1311cf30c6a5f9ebea9592deb426d9a887caa9d3c4fcd31ae43522afa4 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/defaultDeny.yaml KSV-0039 LOW CAUSE-SHA256:6246c5e7ede528806f1f84a2b466b1a22c061b7d7f2ad38e18dc4c722ea7b32b 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/defaultDeny.yaml KSV-0040 LOW CAUSE-SHA256:6246c5e7ede528806f1f84a2b466b1a22c061b7d7f2ad38e18dc4c722ea7b32b 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/ghcrAuth.yaml KSV-0039 LOW CAUSE-SHA256:f3c7ced815c76aaba6407cf691461a7e46b0037f888f32d760286b0fde1bd286 @@ -26,8 +26,8 @@ 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/kustomizationSops.yaml KSV-0040 LOW CAUSE-SHA256:59d21ae5decd83ec9c1e66036a71db7aa29ca574b4e3ef0fb803a8b45d7fac61 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/limitRange.yaml KSV-0039 LOW CAUSE-SHA256:c37b17db55af250264e52c9089e25decadde834c9ebae540db73774a4f2b5702 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/limitRange.yaml KSV-0040 LOW CAUSE-SHA256:c37b17db55af250264e52c9089e25decadde834c9ebae540db73774a4f2b5702 -1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/ociRepository.yaml KSV-0039 LOW CAUSE-SHA256:09f3a16081e9973d851ad50ebed089365dc8d9832c2d4f7ecffbe3199d93cff7 -1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/ociRepository.yaml KSV-0040 LOW CAUSE-SHA256:09f3a16081e9973d851ad50ebed089365dc8d9832c2d4f7ecffbe3199d93cff7 +1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/ociRepository.yaml KSV-0039 LOW CAUSE-SHA256:c0b63be11d05b36a13e5e0d4305b8dacc1a7c02e7e7c1308122ccd2f650343c2 +1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/ociRepository.yaml KSV-0040 LOW CAUSE-SHA256:c0b63be11d05b36a13e5e0d4305b8dacc1a7c02e7e7c1308122ccd2f650343c2 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/resourceQuota.yaml KSV-0039 LOW CAUSE-SHA256:9e7f4d1bd690474236d5c0a694dce8767ed63f3b41bd94a1741ff5a8d072a8a4 1 k8s/bases/infrastructure/resource-graph-definitions/webapp/resource-graph-definition.yaml RGD-CONTENT SHA256 d72726a713827e5fb8091793ea3411f3bfb4dd5136488c11ba6e5ae9dd23a9a1 1 k8s/bases/infrastructure/resource-graph-definitions/webapp/resource-graph-definition.yaml.resources/deployment.yaml KSV-0013 MEDIUM CAUSE-SHA256:c801aedba500c27744aa5111215741ee7a4703a4968fb26c3e42a1fd33423767 diff --git a/scripts/tenant-source-constructors.lib.sh b/scripts/tenant-source-constructors.lib.sh new file mode 100644 index 0000000000..9d5ac519e5 --- /dev/null +++ b/scripts/tenant-source-constructors.lib.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# The only dynamic publisher/selector profile admitted by the generic Tenant. +# This reads both exact constructors and their complete schema input domain; +# it never evaluates arbitrary CEL or grants a consumer publisher approval. +tenant_source_default_subject() ( + set -o pipefail + local file="$1" observed="${2:-}" ref expected_subject default_subject source_type digest_type publisher_type lifecycle + # shellcheck disable=SC2016 # Literal CEL, never shell expansion. + ref='${schema.spec.source.digest == "" ? {"semver": ">=1.0.0"} : {"digest": schema.spec.source.digest}}' + # shellcheck disable=SC2016 # Literal CEL, never shell expansion. + expected_subject='${schema.spec.source.publisherRevision == "" ? "^https://github\\.com/devantler-tech/(actions/\\.github/workflows/publish-app\\.yaml@[0-9a-f]{40}|\\.github/\\.github/workflows/publish-app\\.yaml@(0c7b6c3509b73adbc7537e030935bb01987e625b|467f07582436a26b62cf4aa756423512f85ba0c0))$" : "^https://github\\.com/devantler-tech/\\.github/\\.github/workflows/publish-app\\.yaml@" + schema.spec.source.publisherRevision + "$"}' + default_subject='^https://github\.com/devantler-tech/(actions/\.github/workflows/publish-app\.yaml@[0-9a-f]{40}|\.github/\.github/workflows/publish-app\.yaml@(0c7b6c3509b73adbc7537e030935bb01987e625b|467f07582436a26b62cf4aa756423512f85ba0c0))$' + source_type="TenantSource | default={} validation=\"(!has(self.digest) || self.digest == '') == (!has(self.publisherRevision) || self.publisherRevision == '')\"" + digest_type='string | default="" maxLength=71 pattern="^$|^sha256:[0-9a-f]{64}$"' + publisher_type='string | default="" maxLength=40 pattern="^$|^[0-9a-f]{40}$"' + lifecycle='boolean | default=true validation="self || !oldSelf"' + if [[ -n "$observed" && "$observed" != "$expected_subject" ]]; then + printf 'invalid Tenant source constructor: unexpected subject\n' >&2 + return 1 + fi + if ! yq -o=json -I=0 '.' "$file" | jq -se \ + --arg ref "$ref" --arg subject "$expected_subject" --arg source_type "$source_type" \ + --arg digest_type "$digest_type" --arg publisher_type "$publisher_type" --arg lifecycle "$lifecycle" ' + [ .[] | select(.apiVersion == "kro.run/v1alpha1" and .kind == "ResourceGraphDefinition" and .metadata.name == "tenant.kro.run") ] as $graphs + | ($graphs | length) == 1 + and ($graphs[0].spec.schema | .apiVersion == "v1alpha1" and .kind == "Tenant" and .scope == "Namespaced" + and .types.TenantSource == {digest: $digest_type, publisherRevision: $publisher_type} + and .spec.source == $source_type and .spec.reconcileApplication == $lifecycle) + and ([$graphs[0].spec.resources[] | select(.id == "ociRepository")] | length) == 1 + and ([$graphs[0].spec.resources[] | select(.id == "ociRepository")][0].template | + .apiVersion == "source.toolkit.fluxcd.io/v1" and .kind == "OCIRepository" + and .metadata.name == "${schema.spec.name}" and .metadata.namespace == "${tenantNamespace.metadata.name}" + and .spec.url == "oci://ghcr.io/devantler-tech/${schema.spec.name}/manifests" + and .spec.ref == $ref and .spec.verify == {provider: "cosign", matchOIDCIdentity: [{ + issuer: "^https://token\\.actions\\.githubusercontent\\.com$", subject: $subject + }]}) + and ([.[] | .. | objects | select(has("subject")) | select(.subject == $subject)] | length) == 1 + ' >/dev/null; then + printf 'invalid Tenant source constructor: graph context, paired domain or constructor differs\n' >&2 + return 1 + fi + printf '%s\n' "$default_subject" +) diff --git a/scripts/tests/test-publish-workflow-canonical-approvals.sh b/scripts/tests/test-publish-workflow-canonical-approvals.sh index dd1bfe6499..e9f1985077 100755 --- a/scripts/tests/test-publish-workflow-canonical-approvals.sh +++ b/scripts/tests/test-publish-workflow-canonical-approvals.sh @@ -18,6 +18,8 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" readonly REPO_ROOT +# shellcheck source=scripts/tenant-source-constructors.lib.sh +source "$REPO_ROOT/scripts/tenant-source-constructors.lib.sh" readonly GUARD="$REPO_ROOT/scripts/guard-publish-workflow-approved-revisions.sh" readonly WRITER="$REPO_ROOT/scripts/write-publish-workflow-matchers.sh" readonly REPORT="$REPO_ROOT/scripts/report-publish-workflow-signing-revisions.sh" @@ -544,6 +546,7 @@ fi # revision may join them; a wildcard or an unanchored alternation fails below. while IFS=$'\t' read -r profile file selector; do subject="$(yq -er "$selector" "$REPO_ROOT/$file")" + if [[ "$profile" == generic-tenant ]]; then subject="$(tenant_source_default_subject "$REPO_ROOT/$file" "$subject")"; fi while IFS=$'\t' read -r expected identity; do result=reject if printf '%s\n' "$identity" | grep -Eq -- "$subject"; then result=accept; fi @@ -581,6 +584,7 @@ PROFILES # registered tenants retain their independently reviewed approval records above. while IFS=$'\t' read -r profile file selector; do subject="$(yq -er "$selector" "$REPO_ROOT/$file")" + if [[ "$profile" == generic-tenant ]]; then subject="$(tenant_source_default_subject "$REPO_ROOT/$file" "$subject")"; fi while IFS=$'\t' read -r expected identity; do result=reject if printf '%s\n' "$identity" | grep -Eq -- "$subject"; then result=accept; fi diff --git a/scripts/tests/test-tenant-source-constructors.sh b/scripts/tests/test-tenant-source-constructors.sh new file mode 100644 index 0000000000..8907e378cf --- /dev/null +++ b/scripts/tests/test-tenant-source-constructors.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Exercise the actual OCI and publisher guards against the complete Tenant graph. +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +work="$(mktemp -d)" +trap 'rm -rf -- "$work"' EXIT +graph=k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml +failures=0 +new_case() { + case_root="$work/$1" + mkdir -p "$case_root/$(dirname "$graph")" "$case_root/scripts" + cp "$root/$graph" "$case_root/$graph" + cp "$root/scripts/guard-shared-publish-workflow-pin.sh" "$case_root/scripts/" + if [[ -f "$root/scripts/tenant-source-constructors.lib.sh" ]]; then + cp "$root/scripts/tenant-source-constructors.lib.sh" "$case_root/scripts/" + fi + printf '1\t%s\n' "$graph" >"$case_root/scripts/shared-publish-workflow-matchers.tsv" + printf 'resources:\n - resource-graph-definition.yaml\n' >"$case_root/$(dirname "$graph")/kustomization.yaml" + : >"$case_root/exemptions.tsv" +} +check_guards() { + local label="$1" want="$2" guard out rc + for guard in oci publisher; do + rc=0 + if [[ "$guard" == oci ]]; then + out="$(OCI_VERIFY_ROOTS="$case_root/$(dirname "$graph")" OCI_VERIFY_EXEMPTIONS_FILE="$case_root/exemptions.tsv" OCI_VERIFY_EXPECTED_MIN=1 \ + bash "$root/scripts/guard-oci-repository-verify.sh" 2>&1)" || rc=$? + else + out="$(bash "$case_root/scripts/guard-shared-publish-workflow-pin.sh" 2>&1)" || rc=$? + fi + if [[ "$want" == accept && "$rc" == 0 ]]; then + printf 'ok: %s %s accepted\n' "$guard" "$label" + elif [[ "$want" == reject && "$rc" != 0 && "$out" == *'invalid Tenant source constructor'* ]]; then + printf 'ok: %s %s refused by constructor boundary\n' "$guard" "$label" + elif [[ "$want" == reject && "$guard" == oci && ( "$label" == foreign-source-url || "$label" == wrong-node-kind ) && "$rc" != 0 && "$out" == *'found 0 in-scope OCIRepositor(y/ies)'* ]]; then + # These mutations remove the only kind/URL in this isolated discovery root. + # The publisher guard independently rejects their complete graph context. + printf 'ok: oci %s refused by the discovery floor\n' "$label" + else + printf 'FAIL: %s %s expected %s, exit=%s: %s\n' "$guard" "$label" "$want" "$rc" "$out" >&2 + failures=$((failures + 1)) + fi + done +} +new_case committed +check_guards 'complete constrained graph' accept + +# A plausible complete document followed by a failed reader is not evidence. +# Invoke the library without caller pipefail so its own failure boundary is tested. +mkdir -p "$work/partial-reader" +real_yq="$(command -v yq)" +printf '#!/usr/bin/env bash\n%q "$@"\nexit 23\n' "$real_yq" >"$work/partial-reader/yq" +chmod +x "$work/partial-reader/yq" +if PATH="$work/partial-reader:$PATH" bash -c 'source "$1"; tenant_source_default_subject "$2"' \ + _ "$root/scripts/tenant-source-constructors.lib.sh" "$case_root/$graph" >"$work/partial.log" 2>&1; then + printf 'FAIL: partial reader failure was accepted\n' >&2 + failures=$((failures + 1)) +elif grep -q 'invalid Tenant source constructor' "$work/partial.log"; then + printf 'ok: partial reader failure refused independently of caller pipefail\n' +else + printf 'FAIL: partial reader refusal did not name the constructor boundary\n' >&2 + failures=$((failures + 1)) +fi +while IFS=$'\t' read -r label expression; do + [[ -n "$label" ]] || continue + new_case "$label" + yq -i "$expression" "$case_root/$graph" + check_guards "$label" reject +done <<'CASES' +wrong-graph-name .metadata.name = "other.kro.run" +wrong-generated-kind .spec.schema.kind = "Other" +wrong-scope .spec.schema.scope = "Cluster" +unbounded-digest .spec.schema.types.TenantSource.digest = "string" +unbounded-publisher .spec.schema.types.TenantSource.publisherRevision = "string" +missing-pair-rule .spec.schema.spec.source = "TenantSource | default={}" +unsafe-active-toggle .spec.schema.spec.reconcileApplication = "boolean | default=true" +extra-source-field .spec.schema.types.TenantSource.url = "string" +wrong-child-id (.spec.resources[] | select(.id == "ociRepository").id) = "otherSource" +foreign-source-url (.spec.resources[] | select(.id == "ociRepository").template.spec.url) = "oci://example.invalid/other" +foreign-namespace (.spec.resources[] | select(.id == "ociRepository").template.metadata.namespace) = "other" +tag-constructor (.spec.resources[] | select(.id == "ociRepository").template.spec.ref) = "${schema.spec.source.digest}" +wildcard-subject (.spec.resources[] | select(.id == "ociRepository").template.spec.verify.matchOIDCIdentity[0].subject) += "|.*" +second-identity (.spec.resources[] | select(.id == "ociRepository").template.spec.verify.matchOIDCIdentity) += [{"issuer":".*","subject":".*"}] +wrong-node-kind (.spec.resources[] | select(.id == "ociRepository").template.kind) = "Other" +CASES +printf '%s failure(s)\n' "$failures" +[[ "$failures" == 0 ]] diff --git a/scripts/tests/test-webapp-compiler.sh b/scripts/tests/test-webapp-compiler.sh index 5ca95e1203..0820535e61 100755 --- a/scripts/tests/test-webapp-compiler.sh +++ b/scripts/tests/test-webapp-compiler.sh @@ -19,6 +19,30 @@ gateway_profile="$(go -C "${fixture}" list -mod=readonly -m -json sigs.k8s.io/ga jq -e '.Replace == null' <<<"${gateway_profile}" >/dev/null || { echo 'FAIL: replaced HTTPRoute schema' >&2; exit 1; } gateway="$(go -C "${fixture}" mod download -json sigs.k8s.io/gateway-api | jq -er '.Dir | select(type == "string" and length > 0)')" +# Real dependency schemas, pinned to the reviewed Flux 2.9.6 fixture and current +# External Secrets chart. A fixture pin is not an observation of production. +fetch_schema() { + local url="$1" expected_digest="$2" output="$3" actual_digest + curl --proto '=https' --proto-redir '=https' --location --tlsv1.2 \ + --fail --silent --show-error --connect-timeout 10 --max-time 90 \ + --max-filesize 10000000 "$url" --output "$output" + actual_digest="$(shasum -a 256 "$output" | cut -d ' ' -f 1)" + [[ "$actual_digest" == "$expected_digest" ]] || { echo 'FAIL: dependency schema checksum changed' >&2; exit 1; } +} +[[ "$(yq -er '.spec.chart.spec.version' "${root}/k8s/bases/infrastructure/controllers/external-secrets/helm-release.yaml")" == 2.12.0 ]] || { + echo 'FAIL: review Tenant schema fixtures for the new External Secrets chart' >&2; exit 1; +} +fetch_schema 'https://raw.githubusercontent.com/fluxcd/source-controller/v1.9.6/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml' \ + 80c7c6e5c8de0764cc83acd4de5c74bea8e51186085304d22cecc5f2eb35500e "${work}/oci.yaml" +fetch_schema 'https://raw.githubusercontent.com/fluxcd/kustomize-controller/v1.9.6/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml' \ + da23ca0e6b4c80d3d26a91583137491e147ef4ef1e2208aece0d0af4ef851974 "${work}/kustomization.yaml" +fetch_schema 'https://raw.githubusercontent.com/external-secrets/external-secrets/v2.12.0/deploy/crds/bundle.yaml' \ + d4da9492a6906a3834bb1f5b7367575a29b5c43411017babc7a30728f465d1ba "${work}/external-secrets.yaml" +yq -o=json -I=0 '.' "${work}/external-secrets.yaml" | jq -se ' + [.[] | select(.kind == "CustomResourceDefinition" and .metadata.name == "externalsecrets.external-secrets.io")] + | if length == 1 then .[0] else error("expected exactly one actual ExternalSecret schema") end +' >"${work}/external-secret.json" + # Same reviewed envtest assets as the existing Kyverno admission fixture. case "$(uname -s)/$(uname -m)" in Linux/x86_64) @@ -40,4 +64,7 @@ PLATFORM_TEST_ROOT="${root}" \ KUBEBUILDER_ASSETS="${work}/controller-tools/envtest" \ GATEWAY_HTTPROUTE_CRD="${gateway}/config/crd/standard/gateway.networking.k8s.io_httproutes.yaml" \ KRO_GRAPH_CRD="${kro}/helm/crds/kro.run_resourcegraphdefinitions.yaml" \ + FLUX_OCI_CRD="${work}/oci.yaml" \ + FLUX_KUSTOMIZATION_CRD="${work}/kustomization.yaml" \ + EXTERNAL_SECRET_CRD="${work}/external-secret.json" \ go -C "${fixture}" test -mod=readonly -race -count=1 -timeout=4m -v . diff --git a/scripts/tests/webapp-compiler/tenant_test.go b/scripts/tests/webapp-compiler/tenant_test.go new file mode 100644 index 0000000000..8d4d9abf96 --- /dev/null +++ b/scripts/tests/webapp-compiler/tenant_test.go @@ -0,0 +1,442 @@ +package compiler_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + "time" + + kroapi "github.com/kubernetes-sigs/kro/api/v1alpha1" + "github.com/kubernetes-sigs/kro/pkg/features" + "github.com/kubernetes-sigs/kro/pkg/graph" + kroruntime "github.com/kubernetes-sigs/kro/pkg/runtime" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/discovery" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const tenantDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +const tenantPublisher = "467f07582436a26b62cf4aa756423512f85ba0c0" +const legacyTenantSubject = `^https://github\.com/devantler-tech/(actions/\.github/workflows/publish-app\.yaml@[0-9a-f]{40}|\.github/\.github/workflows/publish-app\.yaml@(0c7b6c3509b73adbc7537e030935bb01987e625b|467f07582436a26b62cf4aa756423512f85ba0c0))$` + +// TestCommittedTenant exercises the actual graph compiler and generated API. +// Desired objects are expression evidence, not Flux or signature verification. +func TestCommittedTenant(t *testing.T) { + root := os.Getenv("PLATFORM_TEST_ROOT") + if root == "" || os.Getenv("KUBEBUILDER_ASSETS") == "" { + t.Fatal("explicit source root and verified API-server assets are required") + } + if features.FeatureGate.Enabled(features.CELOmitFunction) { + t.Fatal("Tenant compilation must use the deployed omission feature gate") + } + var definition kroapi.ResourceGraphDefinition + readYAML(t, filepath.Join(root, "k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml"), &definition) + if definition.APIVersion != "kro.run/v1alpha1" || definition.Kind != "ResourceGraphDefinition" || definition.Name != "tenant.kro.run" { + t.Fatal("expected the committed Tenant graph") + } + var crds []*extv1.CustomResourceDefinition + for _, fixture := range []struct { + variable, name, group, version string + scope extv1.ResourceScope + }{ + {"KRO_GRAPH_CRD", "resourcegraphdefinitions.kro.run", "kro.run", "v1alpha1", extv1.ClusterScoped}, + {"FLUX_OCI_CRD", "ocirepositories.source.toolkit.fluxcd.io", "source.toolkit.fluxcd.io", "v1", extv1.NamespaceScoped}, + {"FLUX_KUSTOMIZATION_CRD", "kustomizations.kustomize.toolkit.fluxcd.io", "kustomize.toolkit.fluxcd.io", "v1", extv1.NamespaceScoped}, + {"EXTERNAL_SECRET_CRD", "externalsecrets.external-secrets.io", "external-secrets.io", "v1", extv1.NamespaceScoped}, + } { + var crd extv1.CustomResourceDefinition + readYAML(t, os.Getenv(fixture.variable), &crd) + if crd.Name != fixture.name || crd.Spec.Group != fixture.group || crd.Spec.Scope != fixture.scope { + t.Fatalf("wrong actual schema for %s: %s", fixture.variable, crd.Name) + } + if !slices.ContainsFunc(crd.Spec.Versions, func(version extv1.CustomResourceDefinitionVersion) bool { + return version.Name == fixture.version && version.Served && version.Schema != nil && version.Schema.OpenAPIV3Schema != nil + }) { + t.Fatalf("actual schema %s does not serve the compiled version %s", fixture.name, fixture.version) + } + crds = append(crds, &crd) + } + environment := newCompilerEnvironment(t, crds) + config, err := startCompilerEnvironment(environment) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Error(err) + } + }) + config.Timeout = 15 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + scheme := runtime.NewScheme() + if err := kroapi.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := extv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + apiClient, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + if err := apiClient.Create(ctx, &definition); err != nil { + t.Fatal(err) + } + discover, err := discovery.NewDiscoveryClientForConfig(config) + if err != nil { + t.Fatal(err) + } + for _, group := range []string{"source.toolkit.fluxcd.io/v1", "kustomize.toolkit.fluxcd.io/v1", "external-secrets.io/v1"} { + if err := wait.PollUntilContextCancel(ctx, 100*time.Millisecond, true, func(context.Context) (bool, error) { + paths, err := discover.OpenAPIV3().Paths() + if err != nil { + return false, err + } + path, ok := paths["apis/"+group] + if !ok { + return false, nil + } + _, err = path.Schema("application/json") + return err == nil, err + }); err != nil { + t.Fatalf("actual dependency OpenAPI %s: %v", group, err) + } + } + httpClient, err := rest.HTTPClientFor(config) + if err != nil { + t.Fatal(err) + } + builder, err := graph.NewBuilder(config, httpClient) + if err != nil { + t.Fatal(err) + } + compiled, err := builder.NewResourceGraphDefinition(&definition, graph.RGDConfig{MaxCollectionDimensionSize: 5}) + if err != nil { + t.Fatal(err) + } + if err := completeTenant(compiled); err != nil { + t.Fatal(err) + } + if err := apiClient.Create(ctx, compiled.CRD); err != nil { + t.Fatal(err) + } + if err := wait.PollUntilContextCancel(ctx, 100*time.Millisecond, true, func(context.Context) (bool, error) { + resources, err := discover.ServerResourcesForGroupVersion("kro.run/v1alpha1") + if err != nil { + return false, err + } + for _, resource := range resources.APIResources { + if resource.Name == "tenants" && resource.Kind == "Tenant" && resource.Namespaced { + return true, nil + } + } + return false, nil + }); err != nil { + t.Fatal(err) + } + apiClient, err = client.New(config, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + instance := func(name string, spec map[string]interface{}) *unstructured.Unstructured { + spec["name"] = "tenant-fixture" + return &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "kro.run/v1alpha1", "kind": "Tenant", + "metadata": map[string]interface{}{"name": name, "namespace": "default"}, "spec": spec, + }} + } + admit := func(input *unstructured.Unstructured) error { + return apiClient.Create(ctx, input, &client.CreateOptions{FieldValidation: "Strict", DryRun: []string{metav1.DryRunAll}}) + } + for _, sops := range []bool{false, true} { + t.Run(fmt.Sprintf("omitted-source-sops-%t", sops), func(t *testing.T) { + input := instance("default-fixture", map[string]interface{}{"sops": sops}) + if err := admit(input); err != nil { + t.Fatal(err) + } + objects, ignored := desiredTenant(t, compiled, input) + assertTenantChildren(t, objects, ignored, true, sops) + source := objects["ociRepository"] + ref, _, _ := unstructured.NestedMap(source.Object, "spec", "ref") + if !reflect.DeepEqual(ref, map[string]interface{}{"semver": ">=1.0.0"}) { + t.Fatalf("omitted source changed the existing selector: %v", ref) + } + assertTenantIdentity(t, source, legacyTenantSubject) + writeTenantFixture(t, fmt.Sprintf("default-%t", sops), objects) + }) + t.Run(fmt.Sprintf("pinned-source-only-sops-%t", sops), func(t *testing.T) { + input := instance("pinned-fixture", map[string]interface{}{ + "sops": sops, "reconcileApplication": false, + "source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher}, + }) + if err := admit(input); err != nil { + t.Fatalf("valid source-only input must be admitted: %v", err) + } + objects, ignored := desiredTenant(t, compiled, input) + assertTenantChildren(t, objects, ignored, false, sops) + source := objects["ociRepository"] + ref, _, _ := unstructured.NestedMap(source.Object, "spec", "ref") + if !reflect.DeepEqual(ref, map[string]interface{}{"digest": tenantDigest}) { + t.Fatalf("pinned source must have exactly the selected digest: %v", ref) + } + assertTenantIdentity(t, source, `^https://github\.com/devantler-tech/\.github/\.github/workflows/publish-app\.yaml@467f07582436a26b62cf4aa756423512f85ba0c0$`) + }) + } + for _, tc := range []struct { + name string + spec map[string]interface{} + }{ + {"digest without publisher", map[string]interface{}{"source": map[string]interface{}{"digest": tenantDigest}}}, + {"publisher without digest", map[string]interface{}{"source": map[string]interface{}{"publisherRevision": tenantPublisher}}}, + {"tag selector", map[string]interface{}{"source": map[string]interface{}{"digest": "v1.0.3", "publisherRevision": tenantPublisher}}}, + {"uppercase digest", map[string]interface{}{"source": map[string]interface{}{"digest": strings.ToUpper(tenantDigest), "publisherRevision": tenantPublisher}}}, + {"short digest", map[string]interface{}{"source": map[string]interface{}{"digest": "sha256:abc", "publisherRevision": tenantPublisher}}}, + {"branch publisher", map[string]interface{}{"source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": "main"}}}, + {"publisher regex", map[string]interface{}{"source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": "[0-9a-f]{40}"}}}, + {"source URL override", map[string]interface{}{"source": map[string]interface{}{"url": "oci://example.invalid/other"}}}, + {"identity override", map[string]interface{}{"source": map[string]interface{}{"subject": ".*"}}}, + {"wrong lifecycle type", map[string]interface{}{"reconcileApplication": "false"}}, + } { + t.Run(tc.name, func(t *testing.T) { + err := admit(instance("invalid-fixture", tc.spec)) + if !apierrors.IsInvalid(err) && !apierrors.IsBadRequest(err) { + t.Fatalf("expected strict API refusal, not success or an operational error: %v", err) + } + }) + } + for _, mode := range []string{"explicit", "omitted"} { + t.Run("cannot-prune-active-"+mode, func(t *testing.T) { + spec := map[string]interface{}{} + if mode == "explicit" { + spec["reconcileApplication"] = true + } + input := instance("active-"+mode, spec) + if err := apiClient.Create(ctx, input, &client.CreateOptions{FieldValidation: "Strict"}); err != nil { + t.Fatal(err) + } + input.Object["spec"].(map[string]interface{})["reconcileApplication"] = false + err := apiClient.Update(ctx, input, &client.UpdateOptions{FieldValidation: "Strict"}) + if !apierrors.IsInvalid(err) { + t.Fatalf("disabling an active child must be refused, not pruned: %v", err) + } + }) + } + t.Run("source-only-can-activate", func(t *testing.T) { + input := instance("activation-fixture", map[string]interface{}{ + "reconcileApplication": false, "source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher}, + }) + if err := apiClient.Create(ctx, input, &client.CreateOptions{FieldValidation: "Strict"}); err != nil { + t.Fatal(err) + } + input.Object["spec"].(map[string]interface{})["reconcileApplication"] = true + if err := apiClient.Update(ctx, input, &client.UpdateOptions{FieldValidation: "Strict"}); err != nil { + t.Fatal(err) + } + objects, ignored := desiredTenant(t, compiled, input) + assertTenantChildren(t, objects, ignored, true, false) + ref, _, _ := unstructured.NestedMap(objects["ociRepository"].Object, "spec", "ref") + if !reflect.DeepEqual(ref, map[string]interface{}{"digest": tenantDigest}) { + t.Fatal("activation must not change the selected source") + } + }) + t.Run("pre-upgrade-active-tenant-cannot-be-disabled", func(t *testing.T) { + // Store an actual legacy object under a schema without the new fields. + // Creating an omitted field under the new schema would only test defaults. + var current extv1.CustomResourceDefinition + if err := apiClient.Get(ctx, client.ObjectKey{Name: compiled.CRD.Name}, ¤t); err != nil { + t.Fatal(err) + } + legacy := current.DeepCopy() + for index := range legacy.Spec.Versions { + spec := legacy.Spec.Versions[index].Schema.OpenAPIV3Schema.Properties["spec"] + delete(spec.Properties, "source") + delete(spec.Properties, "reconcileApplication") + legacy.Spec.Versions[index].Schema.OpenAPIV3Schema.Properties["spec"] = spec + } + if err := apiClient.Update(ctx, legacy); err != nil { + t.Fatal(err) + } + if err := wait.PollUntilContextCancel(ctx, 100*time.Millisecond, true, func(context.Context) (bool, error) { + err := admit(instance("legacy-readiness", map[string]interface{}{"reconcileApplication": true})) + return apierrors.IsBadRequest(err), nil + }); err != nil { + t.Fatal(err) + } + input := instance("pre-upgrade-fixture", map[string]interface{}{}) + if err := apiClient.Create(ctx, input, &client.CreateOptions{FieldValidation: "Strict"}); err != nil { + t.Fatal(err) + } + if _, found, err := unstructured.NestedBool(input.Object, "spec", "reconcileApplication"); err != nil || found { + t.Fatalf("fixture was not stored before the new default existed: found=%t error=%v", found, err) + } + if err := apiClient.Get(ctx, client.ObjectKey{Name: compiled.CRD.Name}, ¤t); err != nil { + t.Fatal(err) + } + current.Spec = *compiled.CRD.Spec.DeepCopy() + if err := apiClient.Update(ctx, ¤t); err != nil { + t.Fatal(err) + } + if err := wait.PollUntilContextCancel(ctx, 100*time.Millisecond, true, func(context.Context) (bool, error) { + return admit(instance("upgrade-readiness", map[string]interface{}{"reconcileApplication": false})) == nil, nil + }); err != nil { + t.Fatal(err) + } + if err := apiClient.Get(ctx, client.ObjectKeyFromObject(input), input); err != nil { + t.Fatal(err) + } + objects, ignored := desiredTenant(t, compiled, input) + assertTenantChildren(t, objects, ignored, true, false) + input.Object["spec"].(map[string]interface{})["reconcileApplication"] = false + if err := apiClient.Update(ctx, input, &client.UpdateOptions{FieldValidation: "Strict"}); !apierrors.IsInvalid(err) { + t.Fatalf("legacy active tenant must not become a pruning toggle: %v", err) + } + }) + t.Run("partial-graph-is-not-complete", func(t *testing.T) { + input := definition.DeepCopy() + input.Spec.Resources = input.Spec.Resources[:1] + partial, err := builder.NewResourceGraphDefinition(input, graph.RGDConfig{MaxCollectionDimensionSize: 5}) + if err != nil { + t.Fatal(err) + } + if completeTenant(partial) == nil { + t.Fatal("one examined child cannot claim complete Tenant coverage") + } + }) +} + +func completeTenant(compiled *graph.Graph) error { + if compiled == nil || compiled.CRD == nil || compiled.CRD.Spec.Scope != extv1.NamespaceScoped || + compiled.CRD.Spec.Group != "kro.run" || compiled.CRD.Spec.Names.Kind != "Tenant" { + return fmt.Errorf("missing generated namespaced Tenant API") + } + var ids []string + for id := range compiled.Resources { + ids = append(ids, id) + } + slices.Sort(ids) + want := []string{"defaultDeny", "externalDnsGateway", "externalDnsHttproutes", "externalDnsNamespaces", "ghcrAuth", "kustomization", "kustomizationSops", "limitRange", "ociRepository", "resourceQuota", "roleBinding", "serviceAccount", "tenantNamespace", "webAppBinding"} + if !slices.Equal(ids, want) { + return fmt.Errorf("incomplete Tenant graph: %v", ids) + } + return nil +} + +func desiredTenant(t *testing.T, compiled *graph.Graph, input *unstructured.Unstructured) (map[string]*unstructured.Unstructured, []string) { + t.Helper() + resolved, err := kroruntime.FromGraph(compiled, graph.Config{MaxCollectionDimensionSize: 5}, kroruntime.WithInstance(input)) + if err != nil { + t.Fatal(err) + } + objects := map[string]*unstructured.Unstructured{} + var ignored []string + for _, node := range resolved.Nodes() { + ignore, err := node.IsIgnored() + if err != nil { + t.Fatalf("evaluate inclusion %s: %v", node.Spec.Meta.ID, err) + } + if ignore { + ignored = append(ignored, node.Spec.Meta.ID) + continue + } + desired, err := node.GetDesired() + if err != nil || len(desired) != 1 { + t.Fatalf("resolve actual Tenant child %s: count=%d error=%v", node.Spec.Meta.ID, len(desired), err) + } + objects[node.Spec.Meta.ID] = desired[0] + node.SetObserved(desired) + } + slices.Sort(ignored) + return objects, ignored +} + +func assertTenantChildren(t *testing.T, objects map[string]*unstructured.Unstructured, ignored []string, reconcile, sops bool) { + t.Helper() + want := []string{"defaultDeny", "ghcrAuth", "ociRepository", "roleBinding", "serviceAccount", "tenantNamespace"} + wantIgnored := []string{"externalDnsGateway", "externalDnsHttproutes", "externalDnsNamespaces", "limitRange", "resourceQuota", "webAppBinding"} + if reconcile { + if sops { + want = append(want, "kustomizationSops") + wantIgnored = append(wantIgnored, "kustomization") + } else { + want = append(want, "kustomization") + wantIgnored = append(wantIgnored, "kustomizationSops") + } + } else { + wantIgnored = append(wantIgnored, "kustomization", "kustomizationSops") + } + slices.Sort(want) + slices.Sort(wantIgnored) + var got []string + for id := range objects { + got = append(got, id) + } + slices.Sort(got) + if !slices.Equal(got, want) || !slices.Equal(ignored, wantIgnored) { + t.Fatalf("included=%v ignored=%v; want included=%v ignored=%v", got, ignored, want, wantIgnored) + } + for id, object := range objects { + if id == "tenantNamespace" { + if object.GetName() != "tenant-fixture" || object.GetLabels()["pod-security.kubernetes.io/enforce"] != "restricted" { + t.Fatal("tenant namespace or restricted policy changed") + } + } else if object.GetNamespace() != "tenant-fixture" { + t.Fatalf("child %s escaped the exact tenant namespace", id) + } + } + for _, id := range []string{"kustomization", "kustomizationSops"} { + if object := objects[id]; object != nil { + prune, _, _ := unstructured.NestedBool(object.Object, "spec", "prune") + force, _, _ := unstructured.NestedBool(object.Object, "spec", "force") + sa, _, _ := unstructured.NestedString(object.Object, "spec", "serviceAccountName") + if !prune || force || sa != "tenant-fixture" { + t.Fatal("activation changed prune, force or impersonation") + } + } + } +} + +func assertTenantIdentity(t *testing.T, source *unstructured.Unstructured, subject string) { + t.Helper() + url, _, _ := unstructured.NestedString(source.Object, "spec", "url") + verify, _, _ := unstructured.NestedMap(source.Object, "spec", "verify") + want := map[string]interface{}{"provider": "cosign", "matchOIDCIdentity": []interface{}{map[string]interface{}{ + "issuer": `^https://token\.actions\.githubusercontent\.com$`, "subject": subject, + }}} + if url != "oci://ghcr.io/devantler-tech/tenant-fixture/manifests" || !reflect.DeepEqual(verify, want) { + t.Fatalf("source escaped fixed URL or signer boundary: url=%s verify=%v", url, verify) + } +} + +func writeTenantFixture(t *testing.T, name string, objects map[string]*unstructured.Unstructured) { + t.Helper() + directory := os.Getenv("PLATFORM_COMPILED_TENANT_DIR") + if directory == "" { + return + } + if !filepath.IsAbs(directory) { + t.Fatal("an explicit absolute fixture output directory is required") + } + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(objects) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, name+".json"), data, 0600); err != nil { + t.Fatal(err) + } +} From 7b794e1a69302169a0fc032f77d18b0e63e5ef0c Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 23 Oct 2026 12:23:12 +0200 Subject: [PATCH 2/8] fix(tenants): bind bootstrap authorization approval --- scripts/validate-eks-ci-role-policy/approved-surface.txt | 2 +- scripts/validate-eks-ci-role-policy/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/validate-eks-ci-role-policy/approved-surface.txt b/scripts/validate-eks-ci-role-policy/approved-surface.txt index 1b4e915c12..87979ba9c2 100644 --- a/scripts/validate-eks-ci-role-policy/approved-surface.txt +++ b/scripts/validate-eks-ci-role-policy/approved-surface.txt @@ -104,7 +104,7 @@ iam.aws.m.upbound.io/v1beta1|Policy|aws|eks-ci-smoke-boundary 019210611429956763 iam.aws.m.upbound.io/v1beta1|Role|aws|eks-ci c40205b3f48d193e8be1a39abda738865b954c90733039a49854f0bac5844306 -kro.run/v1alpha1|ResourceGraphDefinition||tenant.kro.run 230562ae39b386fe6a5b4226eb0f1dff4078013855fdcc2dfc78b598077bbba0 +kro.run/v1alpha1|ResourceGraphDefinition||tenant.kro.run 85bd812d0647ece482348e405741ec164ff4ae4b0bc42dceaa2b1dad5690ae08 kustomize.toolkit.fluxcd.io/v1|Kustomization|ascoachingogvaner|ascoachingogvaner ac00ca29c04de8d2c30a045d583570536a97fc305a1b06c4300da018ecdfb864 diff --git a/scripts/validate-eks-ci-role-policy/main.go b/scripts/validate-eks-ci-role-policy/main.go index c618b47d90..3954d31258 100644 --- a/scripts/validate-eks-ci-role-policy/main.go +++ b/scripts/validate-eks-ci-role-policy/main.go @@ -289,7 +289,7 @@ var expectedRenderedHashes = map[resourceIdentity]string{ {apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRoleBinding", name: "crossview-view"}: "536a4baa1970100ea117d1655f80e06ed874e2248b75f33f161e8b44ca3df50c", {apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRoleBinding", name: "oidc-cluster-reader"}: "7d896404f02d6418c289065d73f9ad79345217d76c8d89eadca2c06e6066b487", {apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRoleBinding", name: "oidc-view"}: "4d07ba3a995cfc139351b4227739efeba9348777f7fe47ac69b87d08e70bd45f", - {apiVersion: "kro.run/v1alpha1", kind: "ResourceGraphDefinition", name: "tenant.kro.run"}: "ece1ec0a00fdbed29ef5bba180165e5a2f9b95e57c9a77ad56188ea14db13c12", + {apiVersion: "kro.run/v1alpha1", kind: "ResourceGraphDefinition", name: "tenant.kro.run"}: "50d77a54610006ed298f96a686f957d1a708fc610c4362a3127f33d3f866f802", {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "ascoachingogvaner", name: "ascoachingogvaner"}: "f921a4720a182b24f289c6a5e1ae684a174ed2f5a748c01cc6bc6bc14c644c03", {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "aws", name: "aws"}: "7bde9c682a81b752bdf9d2b14ce69ca1690008a39f2562d4887f8200447dea71", {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "flux-system", name: "apps"}: "b7dd0ad507a26f2ebd8ddf472a68a638b41dc2f2f1b476d0a6291f15332efe5d", From e05c6f2ddef8873684ea75985407e9a0c548dbea Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 23 Oct 2026 12:34:09 +0200 Subject: [PATCH 3/8] fix(tenants): preserve executable guard entry points --- scripts/tenant-source-constructors.lib.sh | 0 scripts/tests/test-tenant-source-constructors.sh | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/tenant-source-constructors.lib.sh mode change 100644 => 100755 scripts/tests/test-tenant-source-constructors.sh diff --git a/scripts/tenant-source-constructors.lib.sh b/scripts/tenant-source-constructors.lib.sh old mode 100644 new mode 100755 diff --git a/scripts/tests/test-tenant-source-constructors.sh b/scripts/tests/test-tenant-source-constructors.sh old mode 100644 new mode 100755 From 953d7d702dc951e5897b9ca4074ced351a6bf2e1 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 23 Oct 2026 12:50:30 +0200 Subject: [PATCH 4/8] fix(ci): follow the Tenant constructor helper in analysis --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 83ce2bd315..f0f7d8dcb8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1165,7 +1165,7 @@ jobs: shellcheck -x scripts/write-publish-workflow-matchers.sh shellcheck -x scripts/tests/test-publish-workflow-approved-revisions-guard.sh shellcheck scripts/tests/test-write-publish-workflow-matchers.sh - shellcheck scripts/tests/test-publish-workflow-canonical-approvals.sh + shellcheck -x scripts/tests/test-publish-workflow-canonical-approvals.sh bash scripts/tests/test-publish-workflow-approved-revisions-guard.sh bash scripts/tests/test-write-publish-workflow-matchers.sh bash scripts/tests/test-publish-workflow-canonical-approvals.sh From fae38557bae57ab5141d38c0730ca50aa089920b Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 23 Oct 2026 14:55:25 +0200 Subject: [PATCH 5/8] fix(ci): follow the sourced Tenant constructor in OCI checks --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f0f7d8dcb8..31dc25f90f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1220,7 +1220,7 @@ jobs: # artifact (#3558). Unconditional, like the guards above. - name: 🔐 Validate every devantler-tech OCIRepository verifies run: | - shellcheck scripts/guard-oci-repository-verify.sh + shellcheck -x scripts/guard-oci-repository-verify.sh shellcheck scripts/tests/test-guard-oci-repository-verify.sh shellcheck scripts/tenant-source-constructors.lib.sh scripts/tests/test-tenant-source-constructors.sh bash scripts/tests/test-tenant-source-constructors.sh From 89f96af344397be248617ab5eb16a555d0d667a3 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 23 Oct 2026 16:41:39 +0200 Subject: [PATCH 6/8] fix(tenants): require exact publication for source-only bootstrap Couple the unshipped bootstrap switch to its source pin, retain one-way activation and reject removal of an established pin. Keep the existing production consumer boundary closed and characterize pinned instances without granting publisher approval. --- docs/tenant-source-bootstrap.md | 17 +++++- .../tenant/resource-graph-definition.yaml | 18 +++--- scripts/rgd-template-static-scan-baseline.tsv | 2 +- scripts/tenant-source-constructors.lib.sh | 6 +- ...t-guard-consumer-discovery-conservation.sh | 21 +++++++ .../tests/test-tenant-source-constructors.sh | 2 +- scripts/tests/webapp-compiler/tenant_test.go | 56 +++++++++++++++---- .../approved-surface-history.md | 26 +++++++++ .../approved-surface.txt | 2 +- scripts/validate-eks-ci-role-policy/main.go | 2 +- 10 files changed, 122 insertions(+), 30 deletions(-) diff --git a/docs/tenant-source-bootstrap.md b/docs/tenant-source-bootstrap.md index 78953b9af2..706398a78e 100644 --- a/docs/tenant-source-bootstrap.md +++ b/docs/tenant-source-bootstrap.md @@ -88,7 +88,8 @@ acceptance. Pages and the private portal remain unchanged. ## Observe before activating reconciliation A generic `Tenant` can bootstrap only its verified source with -`spec.reconcileApplication: false`. This omits both application Kustomizations, +`spec.source.reconcileApplication: false`. This requires an exact source pair and +omits both application Kustomizations, including the SOPS variant, rather than creating suspended children. Set `spec.source.digest` and `spec.source.publisherRevision` together: the former is an exact `sha256:` digest and the latter the reviewed maintained publisher's @@ -96,12 +97,22 @@ an exact `sha256:` digest and the latter the reviewed maintained publisher's Publication evidence, approval generation and live source verification remain separate requirements. -Omitting the pair preserves the normal semver selector and existing publisher -policy. Omitting `reconcileApplication` preserves application reconciliation. +The production consumer-conservation guard currently rejects every rendered Tenant +instance because its generated source has no concrete per-consumer attribution in +the ordinary matcher inventory. The generic graph's syntax check does not authorize +any publisher revision. First-consumer onboarding must establish that attribution +and canonical approval before extending this closed boundary; Platform #5247 owns +the business-site registration. Neither source-only nor active instances are +production-admitted by this graph change alone. + +Omitting the source object preserves the normal semver selector and existing +publisher policy. Omitting `source.reconcileApplication` preserves application reconciliation. After verification, a source-only Tenant can change false to true. Admission refuses true to false, including active objects stored before this field existed: disabling an active tenant requires a separately reviewed lifecycle procedure, not pruning its workloads through this bootstrap switch. +Once an exact source is selected, removing its pin is refused; activation cannot +silently change the observed publication into a floating version range. After the reviewed source is deployed, confirm `Ready=True`, `SourceVerified=True` and a current `observedGeneration`. Its `status.artifact.revision` must equal the diff --git a/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml b/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml index 62eb584501..ade26fd308 100644 --- a/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml +++ b/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml @@ -73,6 +73,8 @@ spec: TenantSource: digest: 'string | default="" maxLength=71 pattern="^$|^sha256:[0-9a-f]{64}$"' publisherRevision: 'string | default="" maxLength=40 pattern="^$|^[0-9a-f]{40}$"' + # An active child cannot be removed through this bootstrap control. + reconcileApplication: 'boolean | default=true validation="self || !oldSelf"' TenantResourceQuota: enabled: boolean | default=false # Total across all pods in the namespace (both request AND limit — @@ -107,13 +109,13 @@ spec: # parent and generated child ownership; no existing tenant opts in here. webApp: boolean | default=false # Digest-addressed bootstrap uses an exact maintained publisher revision. - # The separate consumer approval guard authorizes that revision; syntax - # validation here never grants deployment or publisher authority. + # This checks syntax, not consumer approval. The mandatory production + # conservation guard refuses all Tenant instances until reviewed consumer + # onboarding adds exact attribution and approval; this graph grants none. + # Keep lifecycle and pinning in one validation scope. Source-only mode + # requires an exact publication; an established pin cannot become a range. source: >- - TenantSource | default={} validation="(!has(self.digest) || self.digest == '') == (!has(self.publisherRevision) || self.publisherRevision == '')" - # Source-only bootstrap is an initial state, not a pause switch. Turning - # an active child off would remove it from KRO and can prune workloads. - reconcileApplication: 'boolean | default=true validation="self || !oldSelf"' + TenantSource | default={} validation="((!has(self.digest) || self.digest == '') == (!has(self.publisherRevision) || self.publisherRevision == '')) && (!has(self.reconcileApplication) || self.reconcileApplication || (has(self.digest) && self.digest != ''))" validation="!has(oldSelf.digest) || oldSelf.digest == '' || (has(self.digest) && self.digest != '')" # Standard multi-tenancy guardrails (#1932 follow-up #2522): a # namespace-wide ResourceQuota and a per-container default LimitRange. # Both default to `enabled: false` — no behaviour change for existing @@ -317,7 +319,7 @@ spec: # on a tenant with no `sops-age` Secret would wedge reconciliation. - id: kustomization includeWhen: - - ${schema.spec.reconcileApplication && !schema.spec.sops} + - ${schema.spec.source.reconcileApplication && !schema.spec.sops} template: apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization @@ -354,7 +356,7 @@ spec: targetNamespace: ${tenantNamespace.metadata.name} - id: kustomizationSops includeWhen: - - ${schema.spec.reconcileApplication && schema.spec.sops} + - ${schema.spec.source.reconcileApplication && schema.spec.sops} template: apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization diff --git a/scripts/rgd-template-static-scan-baseline.tsv b/scripts/rgd-template-static-scan-baseline.tsv index 6b3f3b602e..586a81a785 100644 --- a/scripts/rgd-template-static-scan-baseline.tsv +++ b/scripts/rgd-template-static-scan-baseline.tsv @@ -15,7 +15,7 @@ # The parameterized WebApp identities are additionally scanned after actual KRO resolution by # test-webapp-compiled-scan.sh: omitted defaults still report writable root and UID/GID <= 10000. # Disappearing low-ID rows below are not clearance for those compatibility defaults. -1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml RGD-CONTENT SHA256 fe532e1311cf30c6a5f9ebea9592deb426d9a887caa9d3c4fcd31ae43522afa4 +1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml RGD-CONTENT SHA256 ce3d2ed0939787279638da98c3d8fe13fb7fd1bee4d6639bc6d7e699053bb308 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/defaultDeny.yaml KSV-0039 LOW CAUSE-SHA256:6246c5e7ede528806f1f84a2b466b1a22c061b7d7f2ad38e18dc4c722ea7b32b 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/defaultDeny.yaml KSV-0040 LOW CAUSE-SHA256:6246c5e7ede528806f1f84a2b466b1a22c061b7d7f2ad38e18dc4c722ea7b32b 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/ghcrAuth.yaml KSV-0039 LOW CAUSE-SHA256:f3c7ced815c76aaba6407cf691461a7e46b0037f888f32d760286b0fde1bd286 diff --git a/scripts/tenant-source-constructors.lib.sh b/scripts/tenant-source-constructors.lib.sh index 9d5ac519e5..f71e0abf82 100755 --- a/scripts/tenant-source-constructors.lib.sh +++ b/scripts/tenant-source-constructors.lib.sh @@ -10,7 +10,7 @@ tenant_source_default_subject() ( # shellcheck disable=SC2016 # Literal CEL, never shell expansion. expected_subject='${schema.spec.source.publisherRevision == "" ? "^https://github\\.com/devantler-tech/(actions/\\.github/workflows/publish-app\\.yaml@[0-9a-f]{40}|\\.github/\\.github/workflows/publish-app\\.yaml@(0c7b6c3509b73adbc7537e030935bb01987e625b|467f07582436a26b62cf4aa756423512f85ba0c0))$" : "^https://github\\.com/devantler-tech/\\.github/\\.github/workflows/publish-app\\.yaml@" + schema.spec.source.publisherRevision + "$"}' default_subject='^https://github\.com/devantler-tech/(actions/\.github/workflows/publish-app\.yaml@[0-9a-f]{40}|\.github/\.github/workflows/publish-app\.yaml@(0c7b6c3509b73adbc7537e030935bb01987e625b|467f07582436a26b62cf4aa756423512f85ba0c0))$' - source_type="TenantSource | default={} validation=\"(!has(self.digest) || self.digest == '') == (!has(self.publisherRevision) || self.publisherRevision == '')\"" + source_type="TenantSource | default={} validation=\"((!has(self.digest) || self.digest == '') == (!has(self.publisherRevision) || self.publisherRevision == '')) && (!has(self.reconcileApplication) || self.reconcileApplication || (has(self.digest) && self.digest != ''))\" validation=\"!has(oldSelf.digest) || oldSelf.digest == '' || (has(self.digest) && self.digest != '')\"" digest_type='string | default="" maxLength=71 pattern="^$|^sha256:[0-9a-f]{64}$"' publisher_type='string | default="" maxLength=40 pattern="^$|^[0-9a-f]{40}$"' lifecycle='boolean | default=true validation="self || !oldSelf"' @@ -24,8 +24,8 @@ tenant_source_default_subject() ( [ .[] | select(.apiVersion == "kro.run/v1alpha1" and .kind == "ResourceGraphDefinition" and .metadata.name == "tenant.kro.run") ] as $graphs | ($graphs | length) == 1 and ($graphs[0].spec.schema | .apiVersion == "v1alpha1" and .kind == "Tenant" and .scope == "Namespaced" - and .types.TenantSource == {digest: $digest_type, publisherRevision: $publisher_type} - and .spec.source == $source_type and .spec.reconcileApplication == $lifecycle) + and .types.TenantSource == {digest: $digest_type, publisherRevision: $publisher_type, reconcileApplication: $lifecycle} + and .spec.source == $source_type and (.spec | has("reconcileApplication") | not)) and ([$graphs[0].spec.resources[] | select(.id == "ociRepository")] | length) == 1 and ([$graphs[0].spec.resources[] | select(.id == "ociRepository")][0].template | .apiVersion == "source.toolkit.fluxcd.io/v1" and .kind == "OCIRepository" diff --git a/scripts/tests/test-guard-consumer-discovery-conservation.sh b/scripts/tests/test-guard-consumer-discovery-conservation.sh index 2aa0a74377..bcadde842c 100755 --- a/scripts/tests/test-guard-consumer-discovery-conservation.sh +++ b/scripts/tests/test-guard-consumer-discovery-conservation.sh @@ -2611,6 +2611,27 @@ printf ' - tenant.yaml\n' >>"$root/k8s/providers/prod/apps/kustomization.yaml" expect_refusal 'an instance of an RGD with a templated subject is refused' "$root" \ 'production renders 1 Tenant instance(s)' +# A syntactically valid exact publisher is not a canonical approval. Even these +# pinned source-only and active instances remain outside the admitted inventory. +for reconcile_application in false true; do + root="$(fixture "rgd-pinned-tenant-$reconcile_application")" + cat >"$root/k8s/providers/prod/apps/tenant.yaml" <>"$root/k8s/providers/prod/apps/kustomization.yaml" + expect_refusal "an exact-pinned Tenant (reconcile=$reconcile_application) is not production-admitted" "$root" \ + 'production renders 1 Tenant instance(s)' +done + # (i) An RGD that templates an OCIRepository but names no schema kind: its instances # cannot be counted, so it cannot be judged. root="$(fixture rgd-no-kind)" diff --git a/scripts/tests/test-tenant-source-constructors.sh b/scripts/tests/test-tenant-source-constructors.sh index 8907e378cf..c5531e054f 100755 --- a/scripts/tests/test-tenant-source-constructors.sh +++ b/scripts/tests/test-tenant-source-constructors.sh @@ -73,7 +73,7 @@ wrong-scope .spec.schema.scope = "Cluster" unbounded-digest .spec.schema.types.TenantSource.digest = "string" unbounded-publisher .spec.schema.types.TenantSource.publisherRevision = "string" missing-pair-rule .spec.schema.spec.source = "TenantSource | default={}" -unsafe-active-toggle .spec.schema.spec.reconcileApplication = "boolean | default=true" +unsafe-active-toggle .spec.schema.types.TenantSource.reconcileApplication = "boolean | default=true" extra-source-field .spec.schema.types.TenantSource.url = "string" wrong-child-id (.spec.resources[] | select(.id == "ociRepository").id) = "otherSource" foreign-source-url (.spec.resources[] | select(.id == "ociRepository").template.spec.url) = "oci://example.invalid/other" diff --git a/scripts/tests/webapp-compiler/tenant_test.go b/scripts/tests/webapp-compiler/tenant_test.go index 8d4d9abf96..8dd0418373 100644 --- a/scripts/tests/webapp-compiler/tenant_test.go +++ b/scripts/tests/webapp-compiler/tenant_test.go @@ -179,8 +179,8 @@ func TestCommittedTenant(t *testing.T) { }) t.Run(fmt.Sprintf("pinned-source-only-sops-%t", sops), func(t *testing.T) { input := instance("pinned-fixture", map[string]interface{}{ - "sops": sops, "reconcileApplication": false, - "source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher}, + "sops": sops, + "source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher, "reconcileApplication": false}, }) if err := admit(input); err != nil { t.Fatalf("valid source-only input must be admitted: %v", err) @@ -199,6 +199,9 @@ func TestCommittedTenant(t *testing.T) { name string spec map[string]interface{} }{ + {"source-only without exact publication", map[string]interface{}{"source": map[string]interface{}{"reconcileApplication": false}}}, + {"source-only with empty publication", map[string]interface{}{"source": map[string]interface{}{"digest": "", "publisherRevision": "", "reconcileApplication": false}}}, + {"obsolete top-level lifecycle", map[string]interface{}{"reconcileApplication": false}}, {"digest without publisher", map[string]interface{}{"source": map[string]interface{}{"digest": tenantDigest}}}, {"publisher without digest", map[string]interface{}{"source": map[string]interface{}{"publisherRevision": tenantPublisher}}}, {"tag selector", map[string]interface{}{"source": map[string]interface{}{"digest": "v1.0.3", "publisherRevision": tenantPublisher}}}, @@ -208,7 +211,7 @@ func TestCommittedTenant(t *testing.T) { {"publisher regex", map[string]interface{}{"source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": "[0-9a-f]{40}"}}}, {"source URL override", map[string]interface{}{"source": map[string]interface{}{"url": "oci://example.invalid/other"}}}, {"identity override", map[string]interface{}{"source": map[string]interface{}{"subject": ".*"}}}, - {"wrong lifecycle type", map[string]interface{}{"reconcileApplication": "false"}}, + {"wrong lifecycle type", map[string]interface{}{"source": map[string]interface{}{"reconcileApplication": "false"}}}, } { t.Run(tc.name, func(t *testing.T) { err := admit(instance("invalid-fixture", tc.spec)) @@ -221,13 +224,15 @@ func TestCommittedTenant(t *testing.T) { t.Run("cannot-prune-active-"+mode, func(t *testing.T) { spec := map[string]interface{}{} if mode == "explicit" { - spec["reconcileApplication"] = true + spec["source"] = map[string]interface{}{"reconcileApplication": true} } input := instance("active-"+mode, spec) if err := apiClient.Create(ctx, input, &client.CreateOptions{FieldValidation: "Strict"}); err != nil { t.Fatal(err) } - input.Object["spec"].(map[string]interface{})["reconcileApplication"] = false + if err := unstructured.SetNestedMap(input.Object, map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher, "reconcileApplication": false}, "spec", "source"); err != nil { + t.Fatal(err) + } err := apiClient.Update(ctx, input, &client.UpdateOptions{FieldValidation: "Strict"}) if !apierrors.IsInvalid(err) { t.Fatalf("disabling an active child must be refused, not pruned: %v", err) @@ -236,12 +241,14 @@ func TestCommittedTenant(t *testing.T) { } t.Run("source-only-can-activate", func(t *testing.T) { input := instance("activation-fixture", map[string]interface{}{ - "reconcileApplication": false, "source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher}, + "source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher, "reconcileApplication": false}, }) if err := apiClient.Create(ctx, input, &client.CreateOptions{FieldValidation: "Strict"}); err != nil { t.Fatal(err) } - input.Object["spec"].(map[string]interface{})["reconcileApplication"] = true + if err := unstructured.SetNestedField(input.Object, true, "spec", "source", "reconcileApplication"); err != nil { + t.Fatal(err) + } if err := apiClient.Update(ctx, input, &client.UpdateOptions{FieldValidation: "Strict"}); err != nil { t.Fatal(err) } @@ -252,6 +259,30 @@ func TestCommittedTenant(t *testing.T) { t.Fatal("activation must not change the selected source") } }) + for _, mode := range []string{"omitted", "empty object", "null", "empty pair"} { + t.Run("pinned-source-cannot-become-floating-"+mode, func(t *testing.T) { + input := instance("pin-removal-"+strings.ReplaceAll(mode, " ", "-"), map[string]interface{}{ + "source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher}, + }) + if err := apiClient.Create(ctx, input, &client.CreateOptions{FieldValidation: "Strict"}); err != nil { + t.Fatal(err) + } + spec := input.Object["spec"].(map[string]interface{}) + switch mode { + case "omitted": + delete(spec, "source") + case "empty object": + spec["source"] = map[string]interface{}{} + case "null": + spec["source"] = nil + case "empty pair": + spec["source"] = map[string]interface{}{"digest": "", "publisherRevision": ""} + } + if err := apiClient.Update(ctx, input, &client.UpdateOptions{FieldValidation: "Strict"}); !apierrors.IsInvalid(err) { + t.Fatalf("removing the selected publication via %s must be refused: %v", mode, err) + } + }) + } t.Run("pre-upgrade-active-tenant-cannot-be-disabled", func(t *testing.T) { // Store an actual legacy object under a schema without the new fields. // Creating an omitted field under the new schema would only test defaults. @@ -263,14 +294,13 @@ func TestCommittedTenant(t *testing.T) { for index := range legacy.Spec.Versions { spec := legacy.Spec.Versions[index].Schema.OpenAPIV3Schema.Properties["spec"] delete(spec.Properties, "source") - delete(spec.Properties, "reconcileApplication") legacy.Spec.Versions[index].Schema.OpenAPIV3Schema.Properties["spec"] = spec } if err := apiClient.Update(ctx, legacy); err != nil { t.Fatal(err) } if err := wait.PollUntilContextCancel(ctx, 100*time.Millisecond, true, func(context.Context) (bool, error) { - err := admit(instance("legacy-readiness", map[string]interface{}{"reconcileApplication": true})) + err := admit(instance("legacy-readiness", map[string]interface{}{"source": map[string]interface{}{"reconcileApplication": true}})) return apierrors.IsBadRequest(err), nil }); err != nil { t.Fatal(err) @@ -279,7 +309,7 @@ func TestCommittedTenant(t *testing.T) { if err := apiClient.Create(ctx, input, &client.CreateOptions{FieldValidation: "Strict"}); err != nil { t.Fatal(err) } - if _, found, err := unstructured.NestedBool(input.Object, "spec", "reconcileApplication"); err != nil || found { + if _, found, err := unstructured.NestedBool(input.Object, "spec", "source", "reconcileApplication"); err != nil || found { t.Fatalf("fixture was not stored before the new default existed: found=%t error=%v", found, err) } if err := apiClient.Get(ctx, client.ObjectKey{Name: compiled.CRD.Name}, ¤t); err != nil { @@ -290,7 +320,7 @@ func TestCommittedTenant(t *testing.T) { t.Fatal(err) } if err := wait.PollUntilContextCancel(ctx, 100*time.Millisecond, true, func(context.Context) (bool, error) { - return admit(instance("upgrade-readiness", map[string]interface{}{"reconcileApplication": false})) == nil, nil + return admit(instance("upgrade-readiness", map[string]interface{}{"source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher, "reconcileApplication": false}})) == nil, nil }); err != nil { t.Fatal(err) } @@ -299,7 +329,9 @@ func TestCommittedTenant(t *testing.T) { } objects, ignored := desiredTenant(t, compiled, input) assertTenantChildren(t, objects, ignored, true, false) - input.Object["spec"].(map[string]interface{})["reconcileApplication"] = false + if err := unstructured.SetNestedMap(input.Object, map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher, "reconcileApplication": false}, "spec", "source"); err != nil { + t.Fatal(err) + } if err := apiClient.Update(ctx, input, &client.UpdateOptions{FieldValidation: "Strict"}); !apierrors.IsInvalid(err) { t.Fatalf("legacy active tenant must not become a pruning toggle: %v", err) } diff --git a/scripts/validate-eks-ci-role-policy/approved-surface-history.md b/scripts/validate-eks-ci-role-policy/approved-surface-history.md index eadb92bc0f..f8aa480be1 100644 --- a/scripts/validate-eks-ci-role-policy/approved-surface-history.md +++ b/scripts/validate-eks-ci-role-policy/approved-surface-history.md @@ -3794,3 +3794,29 @@ No consumer pin, publisher mode, artifact URL, RBAC, IAM policy, credential or permission grant changes. Deploy this acceptance before any of these consumers moves its caller to v8.0.0. Offline checks and this record do not establish deployed acceptance or a successful publication by the new signer. + +## 2026-10-23 — Couple Tenant source-only bootstrap to its exact publication + +For #5259, the new, unshipped bootstrap switch moves into +`spec.source.reconcileApplication`. The API now requires an exact digest and +publisher pair for source-only mode, refuses removal of an established pin, and +retains the one-way activation rule, including for legacy stored objects. +Default active output, RBAC, IAM, consumer approvals and credentials do not change. + +Against `2c1896ff31`, the authorization ledger changes exactly one existing entry: +ResourceGraphDefinition tenant.kro.run moves from +`85bd812d0647ece482348e405741ec164ff4ae4b0bc42dceaa2b1dad5690ae08` to +`57991b34a106277418f96c81e458a640a8a648b8a89717d2e049d8de86f79639`. +Its reviewed core fingerprint moves from +`50d77a54610006ed298f96a686f957d1a708fc610c4362a3127f33d3f866f802` to +`2ea76ac2c5cb6795c0b9ff8766cba31409569db318307b6ccd1987a2038e1e34`. +The static-scan ratchet changes only this graph's content fingerprint; rendered +instances and all security findings are unchanged. + +The pinned KRO compiler and Kubernetes API tests exercise missing/partial pins, +four pin-removal forms, activation, SOPS, and legacy objects. The mandatory +production conservation guard remains closed to every Tenant instance, including +exact-pinned source-only instances. These schema checks are not publisher approval +or production onboarding; #5247 must establish concrete consumer attribution and +canonical approval before that boundary can be extended. This record does not +claim deployment, live source verification or application activation. diff --git a/scripts/validate-eks-ci-role-policy/approved-surface.txt b/scripts/validate-eks-ci-role-policy/approved-surface.txt index 87979ba9c2..ae043cf7fe 100644 --- a/scripts/validate-eks-ci-role-policy/approved-surface.txt +++ b/scripts/validate-eks-ci-role-policy/approved-surface.txt @@ -104,7 +104,7 @@ iam.aws.m.upbound.io/v1beta1|Policy|aws|eks-ci-smoke-boundary 019210611429956763 iam.aws.m.upbound.io/v1beta1|Role|aws|eks-ci c40205b3f48d193e8be1a39abda738865b954c90733039a49854f0bac5844306 -kro.run/v1alpha1|ResourceGraphDefinition||tenant.kro.run 85bd812d0647ece482348e405741ec164ff4ae4b0bc42dceaa2b1dad5690ae08 +kro.run/v1alpha1|ResourceGraphDefinition||tenant.kro.run 57991b34a106277418f96c81e458a640a8a648b8a89717d2e049d8de86f79639 kustomize.toolkit.fluxcd.io/v1|Kustomization|ascoachingogvaner|ascoachingogvaner ac00ca29c04de8d2c30a045d583570536a97fc305a1b06c4300da018ecdfb864 diff --git a/scripts/validate-eks-ci-role-policy/main.go b/scripts/validate-eks-ci-role-policy/main.go index 3954d31258..a97dcb8497 100644 --- a/scripts/validate-eks-ci-role-policy/main.go +++ b/scripts/validate-eks-ci-role-policy/main.go @@ -289,7 +289,7 @@ var expectedRenderedHashes = map[resourceIdentity]string{ {apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRoleBinding", name: "crossview-view"}: "536a4baa1970100ea117d1655f80e06ed874e2248b75f33f161e8b44ca3df50c", {apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRoleBinding", name: "oidc-cluster-reader"}: "7d896404f02d6418c289065d73f9ad79345217d76c8d89eadca2c06e6066b487", {apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRoleBinding", name: "oidc-view"}: "4d07ba3a995cfc139351b4227739efeba9348777f7fe47ac69b87d08e70bd45f", - {apiVersion: "kro.run/v1alpha1", kind: "ResourceGraphDefinition", name: "tenant.kro.run"}: "50d77a54610006ed298f96a686f957d1a708fc610c4362a3127f33d3f866f802", + {apiVersion: "kro.run/v1alpha1", kind: "ResourceGraphDefinition", name: "tenant.kro.run"}: "2ea76ac2c5cb6795c0b9ff8766cba31409569db318307b6ccd1987a2038e1e34", {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "ascoachingogvaner", name: "ascoachingogvaner"}: "f921a4720a182b24f289c6a5e1ae684a174ed2f5a748c01cc6bc6bc14c644c03", {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "aws", name: "aws"}: "7bde9c682a81b752bdf9d2b14ce69ca1690008a39f2562d4887f8200447dea71", {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "flux-system", name: "apps"}: "b7dd0ad507a26f2ebd8ddf472a68a638b41dc2f2f1b476d0a6291f15332efe5d", From f3cf269807be62b9bb928d1d41070101ab9737c6 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 23 Oct 2026 18:45:04 +0200 Subject: [PATCH 7/8] fix(ci): bind tenant schemas to reviewed External Secrets 2.13 --- scripts/tests/test-webapp-compiler.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test-webapp-compiler.sh b/scripts/tests/test-webapp-compiler.sh index 0820535e61..9e395f7319 100755 --- a/scripts/tests/test-webapp-compiler.sh +++ b/scripts/tests/test-webapp-compiler.sh @@ -29,14 +29,14 @@ fetch_schema() { actual_digest="$(shasum -a 256 "$output" | cut -d ' ' -f 1)" [[ "$actual_digest" == "$expected_digest" ]] || { echo 'FAIL: dependency schema checksum changed' >&2; exit 1; } } -[[ "$(yq -er '.spec.chart.spec.version' "${root}/k8s/bases/infrastructure/controllers/external-secrets/helm-release.yaml")" == 2.12.0 ]] || { +[[ "$(yq -er '.spec.chart.spec.version' "${root}/k8s/bases/infrastructure/controllers/external-secrets/helm-release.yaml")" == 2.13.0 ]] || { echo 'FAIL: review Tenant schema fixtures for the new External Secrets chart' >&2; exit 1; } fetch_schema 'https://raw.githubusercontent.com/fluxcd/source-controller/v1.9.6/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml' \ 80c7c6e5c8de0764cc83acd4de5c74bea8e51186085304d22cecc5f2eb35500e "${work}/oci.yaml" fetch_schema 'https://raw.githubusercontent.com/fluxcd/kustomize-controller/v1.9.6/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml' \ da23ca0e6b4c80d3d26a91583137491e147ef4ef1e2208aece0d0af4ef851974 "${work}/kustomization.yaml" -fetch_schema 'https://raw.githubusercontent.com/external-secrets/external-secrets/v2.12.0/deploy/crds/bundle.yaml' \ +fetch_schema 'https://raw.githubusercontent.com/external-secrets/external-secrets/v2.13.0/deploy/crds/bundle.yaml' \ d4da9492a6906a3834bb1f5b7367575a29b5c43411017babc7a30728f465d1ba "${work}/external-secrets.yaml" yq -o=json -I=0 '.' "${work}/external-secrets.yaml" | jq -se ' [.[] | select(.kind == "CustomResourceDefinition" and .metadata.name == "externalsecrets.external-secrets.io")] From 7125286dcd15ae63bb0a0fffcf5b898a235c571b Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 23 Oct 2026 19:29:35 +0200 Subject: [PATCH 8/8] test(tenant): use current KRO graph configuration API --- scripts/tests/webapp-compiler/compiler_test.go | 2 +- scripts/tests/webapp-compiler/tenant_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/tests/webapp-compiler/compiler_test.go b/scripts/tests/webapp-compiler/compiler_test.go index ceeef89b1d..f5654d39cc 100644 --- a/scripts/tests/webapp-compiler/compiler_test.go +++ b/scripts/tests/webapp-compiler/compiler_test.go @@ -137,7 +137,7 @@ func TestCommittedWebApp(t *testing.T) { } compile := func(t *testing.T, input *kroapi.ResourceGraphDefinition) (*graph.Graph, error) { t.Helper() - return builder.NewResourceGraphDefinition(input, graph.RGDConfig{MaxCollectionDimensionSize: 5}) + return builder.NewResourceGraphDefinition(input, graph.Config{MaxCollectionDimensionSize: 5}) } t.Run("committed graph includes all three children and namespaced schema", func(t *testing.T) { got, err := compile(t, definition) diff --git a/scripts/tests/webapp-compiler/tenant_test.go b/scripts/tests/webapp-compiler/tenant_test.go index 8dd0418373..284f28b238 100644 --- a/scripts/tests/webapp-compiler/tenant_test.go +++ b/scripts/tests/webapp-compiler/tenant_test.go @@ -123,7 +123,7 @@ func TestCommittedTenant(t *testing.T) { if err != nil { t.Fatal(err) } - compiled, err := builder.NewResourceGraphDefinition(&definition, graph.RGDConfig{MaxCollectionDimensionSize: 5}) + compiled, err := builder.NewResourceGraphDefinition(&definition, graph.Config{MaxCollectionDimensionSize: 5}) if err != nil { t.Fatal(err) } @@ -339,7 +339,7 @@ func TestCommittedTenant(t *testing.T) { t.Run("partial-graph-is-not-complete", func(t *testing.T) { input := definition.DeepCopy() input.Spec.Resources = input.Spec.Resources[:1] - partial, err := builder.NewResourceGraphDefinition(input, graph.RGDConfig{MaxCollectionDimensionSize: 5}) + partial, err := builder.NewResourceGraphDefinition(input, graph.Config{MaxCollectionDimensionSize: 5}) if err != nil { t.Fatal(err) }