311 lines
18 KiB
TypeScript
311 lines
18 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import { rootPath } from "./config";
|
|
import { readYamlRecord } from "./platform-infra-ops-library";
|
|
import { materializeYamlComposition } from "./yaml-composition";
|
|
|
|
const configPath = "config/platform-infra/pipelines-as-code.yaml";
|
|
|
|
export interface PacAdmissionConsumerContract {
|
|
readonly id: string;
|
|
readonly node: string;
|
|
readonly namespace: string;
|
|
readonly pipeline: string;
|
|
readonly pipelineRunPrefix: string;
|
|
readonly markerValue: string;
|
|
readonly executionServiceAccountName: string;
|
|
readonly gitOps: { readonly repoUrl: string; readonly targetRevision: string };
|
|
}
|
|
|
|
export interface PacAdmissionContract {
|
|
readonly version: string;
|
|
readonly markerAnnotation: string;
|
|
readonly controllerUsername: string;
|
|
readonly policyName: string;
|
|
readonly bindingName: string;
|
|
readonly failurePolicy: "Fail";
|
|
readonly validationActions: readonly ["Deny"];
|
|
readonly child: {
|
|
readonly enabled: false;
|
|
readonly parentNameAnnotation: string;
|
|
readonly parentUidAnnotation: string;
|
|
};
|
|
readonly consumers: readonly PacAdmissionConsumerContract[];
|
|
}
|
|
|
|
export interface PacAdmissionDesiredIdentity {
|
|
readonly configSha256: string;
|
|
readonly policyName: string;
|
|
readonly bindingName: string;
|
|
readonly version: string;
|
|
readonly controllerIdentity: string;
|
|
}
|
|
|
|
export function readPacAdmissionContract(): PacAdmissionContract {
|
|
const source = readYamlRecord<Record<string, unknown>>(rootPath("config", "platform-infra", "pipelines-as-code.yaml"), "platform-infra-pipelines-as-code");
|
|
return parsePacAdmissionContract(source);
|
|
}
|
|
|
|
export function parsePacAdmissionContract(source: Record<string, unknown>): PacAdmissionContract {
|
|
const root = record(materializeYamlComposition(source, { label: configPath }).value, configPath);
|
|
const provenance = record(root.deliveryProvenance, `${configPath}#deliveryProvenance`);
|
|
const child = record(provenance.child, `${configPath}#deliveryProvenance.child`);
|
|
const controller = record(provenance.controller, `${configPath}#deliveryProvenance.controller`);
|
|
const admission = record(provenance.admission, `${configPath}#deliveryProvenance.admission`);
|
|
const controllerNamespace = required(controller.namespace, "deliveryProvenance.controller.namespace");
|
|
const controllerServiceAccount = required(controller.serviceAccountName, "deliveryProvenance.controller.serviceAccountName");
|
|
const validationActions = stringArray(admission.validationActions, "deliveryProvenance.admission.validationActions");
|
|
if (validationActions.length !== 1 || validationActions[0] !== "Deny") throw new Error("deliveryProvenance.admission.validationActions must be exactly [Deny]");
|
|
const consumers = recordArray(root.consumers, "consumers").flatMap((consumer, index) => {
|
|
if (consumer.deliveryProvenance === undefined) return [];
|
|
const value = record(consumer.deliveryProvenance, `consumers[${index}].deliveryProvenance`);
|
|
if (value.required === false) return [];
|
|
if (value.required !== true) throw new Error(`consumers[${index}].deliveryProvenance.required must be boolean true or false`);
|
|
const gitOps = record(value.gitOps, `consumers[${index}].deliveryProvenance.gitOps`);
|
|
return [{
|
|
id: required(consumer.id, `consumers[${index}].id`),
|
|
node: required(consumer.node, `consumers[${index}].node`),
|
|
namespace: required(consumer.namespace, `consumers[${index}].namespace`),
|
|
pipeline: required(consumer.pipeline, `consumers[${index}].pipeline`),
|
|
pipelineRunPrefix: required(consumer.pipelineRunPrefix, `consumers[${index}].pipelineRunPrefix`),
|
|
markerValue: required(value.markerValue, `consumers[${index}].deliveryProvenance.markerValue`),
|
|
executionServiceAccountName: required(value.executionServiceAccountName, `consumers[${index}].deliveryProvenance.executionServiceAccountName`),
|
|
gitOps: {
|
|
repoUrl: required(gitOps.repoUrl, `consumers[${index}].deliveryProvenance.gitOps.repoUrl`),
|
|
targetRevision: required(gitOps.targetRevision, `consumers[${index}].deliveryProvenance.gitOps.targetRevision`),
|
|
},
|
|
}];
|
|
});
|
|
const markerValues = new Set(consumers.map((consumer) => consumer.markerValue));
|
|
if (markerValues.size !== consumers.length) throw new Error("deliveryProvenance markerValue must be unique per consumer");
|
|
const version = required(provenance.version, "deliveryProvenance.version");
|
|
const versionMatch = version.match(/-v([1-9][0-9]*)$/u);
|
|
if (versionMatch === null) throw new Error("deliveryProvenance.version must end with a positive -vN resource epoch");
|
|
const resourceEpoch = `-v${versionMatch[1]}`;
|
|
const policyName = required(admission.policyName, "deliveryProvenance.admission.policyName");
|
|
const bindingName = required(admission.bindingName, "deliveryProvenance.admission.bindingName");
|
|
if (!policyName.endsWith(resourceEpoch) || !bindingName.endsWith(resourceEpoch)) throw new Error(`deliveryProvenance admission resource names must end with ${resourceEpoch}`);
|
|
return {
|
|
version,
|
|
markerAnnotation: required(provenance.markerAnnotation, "deliveryProvenance.markerAnnotation"),
|
|
controllerUsername: `system:serviceaccount:${controllerNamespace}:${controllerServiceAccount}`,
|
|
policyName,
|
|
bindingName,
|
|
failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Fail"),
|
|
validationActions: ["Deny"],
|
|
child: {
|
|
enabled: literal(child.enabled, "deliveryProvenance.child.enabled", false),
|
|
parentNameAnnotation: required(child.parentNameAnnotation, "deliveryProvenance.child.parentNameAnnotation"),
|
|
parentUidAnnotation: required(child.parentUidAnnotation, "deliveryProvenance.child.parentUidAnnotation"),
|
|
},
|
|
consumers,
|
|
};
|
|
}
|
|
|
|
export function pacAdmissionConsumer(consumerId: string): PacAdmissionConsumerContract | null {
|
|
return readPacAdmissionContract().consumers.find((consumer) => consumer.id === consumerId) ?? null;
|
|
}
|
|
|
|
export function renderPacAdmissionDesiredFragment(targetId: string): string {
|
|
const contract = readPacAdmissionContract();
|
|
const consumers = contract.consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase());
|
|
if (consumers.length === 0) return "";
|
|
const annotation = contract.markerAnnotation;
|
|
const markerPresent = `has(object.metadata.annotations) && ${cel(annotation)} in object.metadata.annotations`;
|
|
const oldMarkerPresent = `has(oldObject.metadata.annotations) && ${cel(annotation)} in oldObject.metadata.annotations`;
|
|
const protectedUpdate = [markerPresent, oldMarkerPresent, ...consumers.flatMap((consumer) => [candidateExpression(consumer, "object"), candidateExpression(consumer, "oldObject")])].map((item) => `(${item})`).join(" || ");
|
|
const immutableProofKeys = [
|
|
["labels", "app.kubernetes.io/managed-by"],
|
|
["labels", "pipelinesascode.tekton.dev/event-type"],
|
|
["labels", "pipelinesascode.tekton.dev/repository"],
|
|
["labels", "pipelinesascode.tekton.dev/sha"],
|
|
["labels", "pipelinesascode.tekton.dev/commit"],
|
|
["labels", "pipelinesascode.tekton.dev/branch"],
|
|
["labels", "pipelinesascode.tekton.dev/sender"],
|
|
["annotations", "pipelinesascode.tekton.dev/controller-info"],
|
|
["annotations", "pipelinesascode.tekton.dev/event-type"],
|
|
["annotations", "pipelinesascode.tekton.dev/git-provider"],
|
|
["annotations", "pipelinesascode.tekton.dev/repository"],
|
|
["annotations", "pipelinesascode.tekton.dev/sha"],
|
|
["annotations", "pipelinesascode.tekton.dev/commit"],
|
|
["annotations", "pipelinesascode.tekton.dev/branch"],
|
|
["annotations", "pipelinesascode.tekton.dev/source-branch"],
|
|
["annotations", "pipelinesascode.tekton.dev/sender"],
|
|
["annotations", contract.child.parentNameAnnotation],
|
|
["annotations", contract.child.parentUidAnnotation],
|
|
] as const;
|
|
const validations: Record<string, unknown>[] = [
|
|
{
|
|
expression: `request.operation != 'CREATE' || !(${markerPresent}) || request.userInfo.username == ${cel(contract.controllerUsername)}`,
|
|
message: "reserved PaC provenance marker may only be created by the authenticated PaC controller ServiceAccount",
|
|
reason: "Forbidden",
|
|
},
|
|
{
|
|
expression: `request.operation != 'UPDATE' || ((${markerPresent}) == (${oldMarkerPresent}) && (!(${markerPresent}) || object.metadata.annotations[${cel(annotation)}] == oldObject.metadata.annotations[${cel(annotation)}]))`,
|
|
message: "reserved PaC provenance marker is immutable",
|
|
reason: "Invalid",
|
|
},
|
|
{
|
|
expression: `request.operation != 'CREATE' || ((!has(object.metadata.annotations) || !(${cel(contract.child.parentNameAnnotation)} in object.metadata.annotations)) && (!has(object.metadata.annotations) || !(${cel(contract.child.parentUidAnnotation)} in object.metadata.annotations)))`,
|
|
message: "child PipelineRun provenance is disabled until an admission-verified parent name and UID contract is enabled",
|
|
reason: "Forbidden",
|
|
},
|
|
{
|
|
expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${immutableProofKeys.map(([map, key]) => metadataEntryEqual(map, key)).join(" && ")})`,
|
|
message: "PaC repository, branch, revision, event, controller, sender, and parent identity proof fields are immutable",
|
|
reason: "Invalid",
|
|
},
|
|
{
|
|
expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || ((!has(object.spec.params) && !has(oldObject.spec.params)) || (has(object.spec.params) && has(oldObject.spec.params) && object.spec.params == oldObject.spec.params))`,
|
|
message: "PaC source revision parameters are immutable",
|
|
reason: "Invalid",
|
|
},
|
|
{
|
|
expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${serviceAccountEqual()})`,
|
|
message: "PaC execution ServiceAccount is immutable",
|
|
reason: "Invalid",
|
|
},
|
|
{
|
|
expression: `request.operation != 'UPDATE' || !(${protectedUpdate}) || (${pipelineRefEqual()})`,
|
|
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`,
|
|
reason: "Invalid",
|
|
}, {
|
|
expression: `!(${usesServiceAccount(consumer, "object")}) || ((${candidateExpression(consumer, "object")}) && (${markerPresent}) && object.metadata.annotations[${cel(annotation)}] == ${cel(consumer.markerValue)})`,
|
|
message: `execution ServiceAccount for ${consumer.id} is reserved to its admission-proven PipelineRun candidates`,
|
|
reason: "Forbidden",
|
|
}]),
|
|
];
|
|
const policySpec = {
|
|
failurePolicy: contract.failurePolicy,
|
|
matchConstraints: {
|
|
matchPolicy: "Equivalent",
|
|
resourceRules: [{ apiGroups: ["tekton.dev"], apiVersions: ["v1"], operations: ["CREATE", "UPDATE"], resources: ["pipelineruns"] }],
|
|
},
|
|
validations,
|
|
};
|
|
const bindingSpec = { policyName: contract.policyName, validationActions: contract.validationActions };
|
|
const policyHash = admissionSpecSha256(targetId, contract.policyName, contract.bindingName, policySpec, bindingSpec);
|
|
const metadata = {
|
|
labels: { "app.kubernetes.io/managed-by": "unidesk", "app.kubernetes.io/part-of": "platform-infra" },
|
|
annotations: {
|
|
"argocd.argoproj.io/sync-wave": "0",
|
|
"unidesk.ai/pac-provenance-version": contract.version,
|
|
"unidesk.ai/pac-controller-identity": contract.controllerUsername,
|
|
"unidesk.ai/owning-config-ref": `${configPath}#deliveryProvenance`,
|
|
"unidesk.ai/effective-config-sha256": policyHash,
|
|
},
|
|
};
|
|
const policy = {
|
|
apiVersion: "admissionregistration.k8s.io/v1",
|
|
kind: "ValidatingAdmissionPolicy",
|
|
metadata: { name: contract.policyName, ...metadata },
|
|
spec: policySpec,
|
|
};
|
|
const binding = {
|
|
apiVersion: "admissionregistration.k8s.io/v1",
|
|
kind: "ValidatingAdmissionPolicyBinding",
|
|
metadata: {
|
|
name: contract.bindingName,
|
|
...metadata,
|
|
annotations: { ...metadata.annotations, "argocd.argoproj.io/sync-wave": "1" },
|
|
},
|
|
spec: bindingSpec,
|
|
};
|
|
return [policy, binding].map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n") + "\n";
|
|
}
|
|
|
|
export function pacAdmissionDesiredIdentity(targetId: string): PacAdmissionDesiredIdentity {
|
|
const contract = readPacAdmissionContract();
|
|
const manifest = renderPacAdmissionDesiredFragment(targetId);
|
|
const policy = manifest.length === 0 ? null : record(Bun.YAML.parse(manifest.split(/^---\s*$/mu)[0] ?? ""), "rendered admission policy");
|
|
const policyMetadata = policy === null ? {} : record(policy.metadata, "rendered admission policy metadata");
|
|
const annotations = policy === null ? {} : record(policyMetadata.annotations, "rendered admission policy annotations");
|
|
return {
|
|
configSha256: policy === null
|
|
? `sha256:${createHash("sha256").update(stableCanonicalJson({ targetId, disabled: true })).digest("hex")}`
|
|
: required(annotations["unidesk.ai/effective-config-sha256"], "rendered admission policy effective config sha256"),
|
|
policyName: contract.policyName,
|
|
bindingName: contract.bindingName,
|
|
version: contract.version,
|
|
controllerIdentity: contract.controllerUsername,
|
|
};
|
|
}
|
|
|
|
function admissionSpecSha256(targetId: string, policyName: string, bindingName: string, policySpec: Record<string, unknown>, bindingSpec: Record<string, unknown>): string {
|
|
const input = { targetId, policyName, bindingName, policySpec, bindingSpec };
|
|
return `sha256:${createHash("sha256").update(stableCanonicalJson(input)).digest("hex")}`;
|
|
}
|
|
|
|
function stableCanonicalJson(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(stableCanonicalJson).join(",")}]`;
|
|
if (typeof value === "object" && value !== null) {
|
|
return `{${Object.entries(value as Record<string, unknown>).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${stableCanonicalJson(item)}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function candidateExpression(consumer: PacAdmissionConsumerContract, root: "object" | "oldObject"): string {
|
|
return `${root}.metadata.namespace == ${cel(consumer.namespace)} && (${root}.metadata.name.startsWith(${cel(consumer.pipelineRunPrefix)}) || (has(${root}.metadata.labels) && ((${cel("tekton.dev/pipeline")} in ${root}.metadata.labels && ${root}.metadata.labels[${cel("tekton.dev/pipeline")}] == ${cel(consumer.pipeline)}) || (${cel("tekton.dev/pipelineName")} in ${root}.metadata.labels && ${root}.metadata.labels[${cel("tekton.dev/pipelineName")}] == ${cel(consumer.pipeline)}))) || (has(${root}.spec.pipelineRef) && has(${root}.spec.pipelineRef.name) && ${root}.spec.pipelineRef.name == ${cel(consumer.pipeline)}))`;
|
|
}
|
|
|
|
function usesServiceAccount(consumer: PacAdmissionConsumerContract, root: "object" | "oldObject"): string {
|
|
return `has(${root}.spec.taskRunTemplate) && has(${root}.spec.taskRunTemplate.serviceAccountName) && ${root}.spec.taskRunTemplate.serviceAccountName == ${cel(consumer.executionServiceAccountName)}`;
|
|
}
|
|
|
|
function metadataEntryEqual(map: "annotations" | "labels", key: string): string {
|
|
const current = `has(object.metadata.${map}) && ${cel(key)} in object.metadata.${map}`;
|
|
const previous = `has(oldObject.metadata.${map}) && ${cel(key)} in oldObject.metadata.${map}`;
|
|
return `((${current}) == (${previous}) && (!(${current}) || object.metadata.${map}[${cel(key)}] == oldObject.metadata.${map}[${cel(key)}]))`;
|
|
}
|
|
|
|
function serviceAccountEqual(): string {
|
|
const current = "has(object.spec.taskRunTemplate) && has(object.spec.taskRunTemplate.serviceAccountName)";
|
|
const previous = "has(oldObject.spec.taskRunTemplate) && has(oldObject.spec.taskRunTemplate.serviceAccountName)";
|
|
return `((${current}) == (${previous}) && (!(${current}) || object.spec.taskRunTemplate.serviceAccountName == oldObject.spec.taskRunTemplate.serviceAccountName))`;
|
|
}
|
|
|
|
function pipelineRefEqual(): string {
|
|
const current = "has(object.spec.pipelineRef)";
|
|
const previous = "has(oldObject.spec.pipelineRef)";
|
|
return `((${current}) == (${previous}) && (!(${current}) || object.spec.pipelineRef == oldObject.spec.pipelineRef))`;
|
|
}
|
|
|
|
function cel(value: string): string {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function record(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function recordArray(value: unknown, path: string): Record<string, unknown>[] {
|
|
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
|
|
return value.map((item, index) => record(item, `${path}[${index}]`));
|
|
}
|
|
|
|
function required(value: unknown, path: string): string {
|
|
if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`);
|
|
return value;
|
|
}
|
|
|
|
function stringArray(value: unknown, path: string): string[] {
|
|
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be a non-empty string array`);
|
|
return value as string[];
|
|
}
|
|
|
|
function literal<T extends string | boolean>(value: unknown, path: string, expected: T): T {
|
|
if (value !== expected) throw new Error(`${path} must be ${expected}`);
|
|
return expected;
|
|
}
|