diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5edb921398..47d6e45cc6 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 @@ -1220,8 +1220,10 @@ 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 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..706398a78e 100644 --- a/docs/tenant-source-bootstrap.md +++ b/docs/tenant-source-bootstrap.md @@ -87,6 +87,33 @@ acceptance. Pages and the private portal remain unchanged. ## Observe before activating reconciliation +A generic `Tenant` can bootstrap only its verified source with +`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 +40-character lowercase commit. Neither setting creates a consumer approval. +Publication evidence, approval generation and live source verification remain +separate requirements. + +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 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..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 @@ -70,6 +70,11 @@ 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}$"' + # 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 — @@ -103,6 +108,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. + # 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 == '')) && (!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 @@ -279,8 +292,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 +310,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 +319,7 @@ spec: # on a tenant with no `sops-age` Secret would wedge reconciliation. - id: kustomization includeWhen: - - ${!schema.spec.sops} + - ${schema.spec.source.reconcileApplication && !schema.spec.sops} template: apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization @@ -344,7 +356,7 @@ spec: targetNamespace: ${tenantNamespace.metadata.name} - id: kustomizationSops includeWhen: - - ${schema.spec.sops} + - ${schema.spec.source.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..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 4b4f077ffee9276ede0339daf8e1a5e2f8adeb7d8c8705aabc3e872393c855b0 +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 @@ -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 100755 index 0000000000..f71e0abf82 --- /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 == '')) && (!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"' + 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, 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" + 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-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-publish-workflow-canonical-approvals.sh b/scripts/tests/test-publish-workflow-canonical-approvals.sh index 9b0a08bcee..439949b108 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 100755 index 0000000000..c5531e054f --- /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.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" +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..9e395f7319 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.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.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")] + | 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/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 new file mode 100644 index 0000000000..284f28b238 --- /dev/null +++ b/scripts/tests/webapp-compiler/tenant_test.go @@ -0,0 +1,474 @@ +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.Config{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, + "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) + } + 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{} + }{ + {"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}}}, + {"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{}{"source": 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["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) + } + 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) + } + }) + } + t.Run("source-only-can-activate", func(t *testing.T) { + input := instance("activation-fixture", map[string]interface{}{ + "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) + } + 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) + } + 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") + } + }) + 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. + 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") + 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{}{"source": 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", "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 { + 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{}{"source": map[string]interface{}{"digest": tenantDigest, "publisherRevision": tenantPublisher, "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) + 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) + } + }) + 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.Config{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) + } +} 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 d888920706..adfa4c5673 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 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 c618b47d90..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"}: "ece1ec0a00fdbed29ef5bba180165e5a2f9b95e57c9a77ad56188ea14db13c12", + {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",