diff --git a/src/audit/execution-identity-admission.test.ts b/src/audit/execution-identity-admission.test.ts index 918cc6492a50..62b15df291bd 100644 --- a/src/audit/execution-identity-admission.test.ts +++ b/src/audit/execution-identity-admission.test.ts @@ -191,6 +191,38 @@ describe("execution identity admission envelope", () => { ).toThrow("execution identity admission envelope is not canonical"); }); + it.each([ + ["malformed", { state: "invalid" }], + ["mixed", { state: "unknown", rawPrincipalRef: "raw-substitute-secret" }], + ["untagged", { kind: "local-account", rawPrincipalRef: "legacy-untagged" }], + [ + "extra-field", + { + state: "present", + kind: "local-account", + rawPrincipalRef: "raw-principal", + extra: true, + }, + ], + ])("rejects %s raw invoker facts before enqueue projection", (_variant, invoker) => { + const sink = vi.fn(() => true); + const clear = configureExecutionIdentityAdmissionSink(sink); + try { + expect( + enqueueExecutionIdentityContextAtAdmission(facts({ invoker: invoker as never }), { + enabled: true, + contextId: "context-invalid", + executionId: "execution-invalid", + now: 1, + runtimeInstanceId: "runtime-1", + }), + ).toBeUndefined(); + expect(sink).not.toHaveBeenCalled(); + } finally { + clear(); + } + }); + it("rejects non-plain or lossy clone data without invoking accessors", () => { const envelope = captureEnvelope(facts({ invoker: { state: "unknown" } }), { contextId: "context-unknown", diff --git a/src/audit/execution-identity-admission.ts b/src/audit/execution-identity-admission.ts index 1d893bba6549..1f437ccd5b95 100644 --- a/src/audit/execution-identity-admission.ts +++ b/src/audit/execution-identity-admission.ts @@ -25,6 +25,25 @@ const evidenceState = () => const closedObject = [0]>(properties: T) => Type.Object(properties, { additionalProperties: false }); +const ExecutionIdentityAdmissionInvokerSchema = Type.Union([ + closedObject({ + state: Type.Literal("present"), + kind: Type.Union([ + Type.Literal("person"), + Type.Literal("agent"), + Type.Literal("service"), + Type.Literal("schedule"), + Type.Literal("webhook"), + Type.Literal("system"), + Type.Literal("local-account"), + Type.Literal("runtime"), + ]), + rawPrincipalRef: rawRef(), + displayLabel: Type.Optional(Type.String({ maxLength: 128 })), + }), + closedObject({ state: Type.Literal("unknown") }), +]); + const ExecutionIdentityAdmissionEnvelopeSchema = closedObject({ envelopeVersion: Type.Literal(1), contextId: boundedRef(), @@ -62,26 +81,7 @@ const ExecutionIdentityAdmissionEnvelopeSchema = closedObject({ Type.Literal("acp"), ]), }), - invoker: Type.Optional( - Type.Union([ - closedObject({ - state: Type.Literal("present"), - kind: Type.Union([ - Type.Literal("person"), - Type.Literal("agent"), - Type.Literal("service"), - Type.Literal("schedule"), - Type.Literal("webhook"), - Type.Literal("system"), - Type.Literal("local-account"), - Type.Literal("runtime"), - ]), - rawPrincipalRef: rawRef(), - displayLabel: Type.Optional(Type.String({ maxLength: 128 })), - }), - closedObject({ state: Type.Literal("unknown") }), - ]), - ), + invoker: Type.Optional(ExecutionIdentityAdmissionInvokerSchema), applicableGrants: Type.Array(closedObject({ rawGrantRef: rawRef(), state: evidenceState() }), { maxItems: EXECUTION_IDENTITY_ADMISSION_MAX_ITEMS, }), @@ -223,6 +223,12 @@ function validateEnvelope(value: unknown): asserts value is ExecutionIdentityAdm } } +function validateRawInvoker(value: unknown): void { + if (value !== undefined && !Value.Check(ExecutionIdentityAdmissionInvokerSchema, value)) { + throw new Error("execution identity admission invoker violates its bounded contract"); + } +} + function validateToken(value: unknown): asserts value is ExecutionIdentityAdmissionToken { if ( !Value.Check(ExecutionIdentityAdmissionTokenSchema, value) || @@ -419,6 +425,7 @@ export function enqueueExecutionIdentityContextAtAdmission( } try { assertPlainCloneData(facts); + validateRawInvoker(facts.invoker); const token = options.token ?? createExecutionIdentityAdmissionToken(facts.runId, {