diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index a1fe0012..35272334 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -41,10 +41,6 @@ deliveryProvenance: failurePolicy: Fail validationActions: - Deny - terminalRoles: - - plan-artifacts - - collect-artifacts - - gitops-promote capabilities: sentinelInternalPublish: enabled: false diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index 920b84cd..82c6e755 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -400,6 +400,11 @@ function extractPacSourceObservation(recordsInput) { reason = "delivery-terminal-evidence-incomplete"; } + const planMarkerSource = plan?.event === "pac-delivery-plan" + ? "pac-delivery-plan-structured-log" + : plan?.event === "g14-ci-plan" + ? "g14-ci-plan-structured-log" + : "delivery-plan-structured-log"; return { schemaVersion: "v1", contract: plan?.event === "pac-delivery-plan" ? "pac-delivery-plan-v1" : "hwlab-g14-ci-plan", @@ -419,7 +424,7 @@ function extractPacSourceObservation(recordsInput) { gitopsPromote: promoteMarker !== null ? "no-build-no-rollout-plan" : promoteTask?.status || "missing", }, terminalSources: { - planArtifacts: terminalSource(planTask, plan, "observed", "g14-ci-plan-structured-log"), + planArtifacts: terminalSource(planTask, plan, "observed", planMarkerSource), collectArtifacts: terminalSource(collectTask, collectMarker), gitopsPromote: terminalSource(promoteTask, promoteMarker), }, diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index 1316f856..7a20cb7b 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -32,6 +32,8 @@ test("admission contract rejects malformed required declarations instead of sile test("versioned admission desired state protects controller proof, candidate identity, params, and execution SA", () => { const contract = readPacAdmissionContract(); + const owningYaml = Bun.YAML.parse(readFileSync(resolve(root, "config/platform-infra/pipelines-as-code.yaml"), "utf8")) as any; + expect(owningYaml.deliveryProvenance.terminalRoles).toBeUndefined(); expect(contract.policyName).toEndWith("-v1"); expect(contract.bindingName).toEndWith("-v1"); expect(contract.child.enabled).toBe(false); @@ -50,6 +52,7 @@ test("versioned admission desired state protects controller proof, candidate ide expect(expressions).toContain("spec.pipelineRef.name"); expect(expressions).toContain("spec.params == oldObject.spec.params"); expect(expressions).toContain("object.spec.pipelineRef == oldObject.spec.pipelineRef"); + expect(expressions).toContain("object.spec == oldObject.spec"); expect(expressions).toContain("taskRunTemplate.serviceAccountName == oldObject.spec.taskRunTemplate.serviceAccountName"); expect(expressions).toContain("object.metadata.name.startsWith"); expect(expressions).toContain('object.metadata.labels["tekton.dev/pipeline"]'); @@ -58,6 +61,12 @@ test("versioned admission desired state protects controller proof, candidate ide expect(expressions).toContain(contract.child.parentUidAnnotation); expect(expressions).toContain("oldObject"); expect(JSON.stringify(items)).not.toContain('"kind":"ServiceAccount"'); + const specGuard = policy.spec.validations.find((item: any) => item.message.includes("PipelineRun spec is immutable")); + const oldRun = { spec: { pipelineSpec: { tasks: [{ name: "plan-artifacts" }] }, workspaces: [], timeouts: { pipeline: "1h" } } }; + const mutatedRun = structuredClone(oldRun); + mutatedRun.spec.pipelineSpec.tasks[0].name = "forged-task"; + expect(specGuard.expression).toMatch(/object\.spec == oldObject\.spec/u); + expect(mutatedRun.spec).not.toEqual(oldRun.spec); }); test("required consumer default RBAC has one automatic desired owner and no Tekton mutation verbs", () => { @@ -184,6 +193,9 @@ test("AgentRun rendered delivery plan and real TaskRun terminals close the statu collectArtifacts: "succeeded", gitopsPromote: "succeeded", }, + terminalSources: { + planArtifacts: { markerSource: "pac-delivery-plan-structured-log" }, + }, }); expect(evaluator.evaluatePacStatus({ sourceCommit, @@ -213,6 +225,15 @@ test("history evaluates admission and default RBAC in each consumer namespace", expect(remote).toContain("kubectlJson(['-n', consumer.namespace, 'get', 'role', 'unidesk-pac-consumer-runner'"); expect(remote).toContain("kubectlJson(['-n', consumer.namespace, 'get', 'rolebinding', 'unidesk-pac-consumer-runner-default'"); expect(remote).toContain("defaultCanMutate(consumer.namespace)"); + expect(remote).toContain("for verb in create patch update delete deletecollection; do"); + expect(remote).toContain('timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" kubectl auth can-i'); + expect(remote).toContain("for (const verb of ['create', 'patch', 'update', 'delete', 'deletecollection'])"); + const defaultMutationProbe = remote.slice(remote.indexOf("function defaultCanMutate(namespace)"), remote.indexOf("function admissionStateForConsumer(consumer)")); + expect(defaultMutationProbe).toContain("timeout: waitTimeoutSeconds * 1000"); + expect(defaultMutationProbe).not.toContain("timeout: 12000"); + expect(remote).toContain("'delivery plan structured evidence is missing'"); + expect(remote).toContain("'delivery plan source commit does not match the selected PipelineRun'"); + expect(remote).not.toContain("'g14-ci-plan structured evidence is missing'"); expect(remote).not.toContain("for (const consumer of consumers) consumer.admissionState = admissionState"); }); diff --git a/scripts/src/platform-infra-pac-provenance.ts b/scripts/src/platform-infra-pac-provenance.ts index 2a16667d..f7df6f5d 100644 --- a/scripts/src/platform-infra-pac-provenance.ts +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -25,7 +25,6 @@ export interface PacAdmissionContract { readonly bindingName: string; readonly failurePolicy: "Fail"; readonly validationActions: readonly ["Deny"]; - readonly terminalRoles: readonly string[]; readonly child: { readonly enabled: false; readonly parentNameAnnotation: string; @@ -94,7 +93,6 @@ export function parsePacAdmissionContract(source: Record): PacA bindingName, failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Fail"), validationActions: ["Deny"], - terminalRoles: stringArray(provenance.terminalRoles, "deliveryProvenance.terminalRoles"), child: { enabled: literal(child.enabled, "deliveryProvenance.child.enabled", false), parentNameAnnotation: required(child.parentNameAnnotation, "deliveryProvenance.child.parentNameAnnotation"), @@ -172,6 +170,11 @@ export function renderPacAdmissionDesiredFragment(targetId: string): string { message: "PaC referenced Pipeline identity is immutable", reason: "Invalid", }, + { + expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || object.spec == oldObject.spec`, + message: "admission-proven PaC PipelineRun spec is immutable, including embedded pipelineSpec, workspaces, and timeouts", + reason: "Invalid", + }, ...consumers.flatMap((consumer) => [{ expression: `request.operation != 'CREATE' || !(${candidateExpression(consumer, "object")}) || (request.userInfo.username == ${cel(contract.controllerUsername)} && (${markerPresent}) && object.metadata.annotations[${cel(annotation)}] == ${cel(consumer.markerValue)} && ${usesServiceAccount(consumer, "object")})`, message: `PipelineRun candidates for ${consumer.id} require its exact admission marker and execution ServiceAccount`, diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 8c72bb1d..30d88de2 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -278,9 +278,9 @@ admission_provenance_state() { kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get role unidesk-pac-consumer-runner -o json >"$role_file" 2>/dev/null || printf '{}' >"$role_file" kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get rolebinding unidesk-pac-consumer-runner-default -o json >"$role_binding_file" 2>/dev/null || printf '{}' >"$role_binding_file" default_can_mutate=false - for verb in create patch update; do + for verb in create patch update delete deletecollection; do for resource in pipelineruns.tekton.dev taskruns.tekton.dev; do - if answer=$(kubectl auth can-i --as="system:serviceaccount:$UNIDESK_PAC_TARGET_NAMESPACE:default" "$verb" "$resource" -n "$UNIDESK_PAC_TARGET_NAMESPACE" 2>/dev/null); then :; else :; fi + if answer=$(timeout "$UNIDESK_PAC_WAIT_TIMEOUT_SECONDS" kubectl auth can-i --as="system:serviceaccount:$UNIDESK_PAC_TARGET_NAMESPACE:default" "$verb" "$resource" -n "$UNIDESK_PAC_TARGET_NAMESPACE" 2>/dev/null); then :; else :; fi case "$answer" in yes) default_can_mutate=true ;; no) ;; @@ -472,12 +472,16 @@ let admissionBinding = null; const admissionRbac = new Map(); function defaultCanMutate(namespace) { - for (const verb of ['create', 'patch', 'update']) { + const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10); + if (!Number.isInteger(waitTimeoutSeconds) || waitTimeoutSeconds <= 0) { + kubectlJsonErrors.push({ context: `${namespace}:default-can-mutation`, status: null, error: 'invalid-yaml-wait-timeout', stderr: '' }); + return null; + } + for (const verb of ['create', 'patch', 'update', 'delete', 'deletecollection']) { for (const resource of ['pipelineruns.tekton.dev', 'taskruns.tekton.dev']) { const result = cp.spawnSync('kubectl', ['auth', 'can-i', `--as=system:serviceaccount:${namespace}:default`, verb, resource, '-n', namespace], { encoding: 'utf8', - timeout: 12000, - maxBuffer: 64 * 1024, + timeout: waitTimeoutSeconds * 1000, }); const answer = String(result.stdout || '').trim(); if (answer === 'yes') return true; @@ -1424,8 +1428,8 @@ if (historyErrors.length > 0) breakAt('target-read-failed', 'target-read', histo if (Object.keys(row).length === 0) breakAt('pipeline-run-not-found', 'pipeline-run', `PipelineRun ${process.env.UNIDESK_PAC_HISTORY_ID || '-'} was not found for the selected consumer`); if (Object.keys(row).length > 0 && row.deliveryAuthorityEligible !== true) breakAt('pipeline-run-not-delivery-authority', 'pipeline-run', `PipelineRun is classified as ${row.deliveryClass || 'unknown'}; only an outer PaC event can drive delivery closeout`); if (Object.keys(row).length > 0 && row.status !== 'True') breakAt('pipeline-run-not-succeeded', 'pipeline-run', `PipelineRun terminal status is ${row.status || row.reason || 'unknown'}`); -if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'g14-ci-plan structured evidence is missing'); -if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'g14-ci-plan source commit does not match the selected PipelineRun'); +if (Object.keys(row).length > 0 && Object.keys(sourceObservation).length === 0) breakAt('source-observation-missing', 'plan-artifacts', 'delivery plan structured evidence is missing'); +if (sourceObservation.sourceMatched !== true) breakAt('source-observation-mismatch', 'plan-artifacts', 'delivery plan source commit does not match the selected PipelineRun'); if (sourceObservation.valid !== true) breakAt('source-observation-invalid', 'plan-artifacts', sourceObservation.reason || 'delivery plan is incomplete or contradictory'); for (const item of terminal) { if (item.present !== true || item.status !== 'succeeded') breakAt('terminal-evidence-incomplete', item.step, `${item.step} TaskRun terminal status is ${item.status}`);