diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e1d871b..58f6c80b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,9 @@ jobs: with: node-version-file: sdk/typescript/.node-version cache: npm - cache-dependency-path: sdk/typescript/package-lock.json + cache-dependency-path: | + sdk/typescript/package-lock.json + action_plane/typescript/package-lock.json - uses: astral-sh/setup-uv@v6 with: version: "0.8.22" diff --git a/.gitignore b/.gitignore index 60446647..5fddc534 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ sdk/typescript/node_modules/ sdk/typescript/dist/ sdk/typescript/*.tgz sdk/typescript/*.tsbuildinfo +action_plane/typescript/node_modules/ +action_plane/typescript/dist/ +action_plane/typescript/*.tgz +action_plane/typescript/*.tsbuildinfo # --- OS / editor --- .DS_Store diff --git a/CONTEXT.md b/CONTEXT.md index 9c45781c..c26b50bc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -320,11 +320,12 @@ A signed, short-lived, one-shot capability for exactly one external effect. handling belong to the owning design and ADRs. - **Invariant:** ContextAccessTicket and ActionTicket have different audiences and are never interchangeable; rejected use has business effect zero. -- **Activation note:** Issue #18 proves only a distinct signed - Organization/channel-bound synthetic no-op and zero-effect rejection. It does - not activate the canonical durable one-shot lifecycle, ActionPlane - prepare/perform, Sender/IM delivery, payload/destination/approval/idempotency, - DeliveryAttempt, replay, stored receipt, or reconciliation semantics. +- **Activation note:** Issue #18 proves a distinct signed + Organization/channel-bound synthetic no-op. Issue #67 additionally activates + private `ActionPlane.prepare`: exact payload/destination/audience/approval/ + idempotency binding and durable digest-only issuance. Ticket consumption, + `perform`, Sender/IM delivery, stored receipts, replay after application, and + reconciliation remain `NOT_ACTIVE`. - **Do not confuse with:** read ticket, EgressGrant, WorkerLease, credential, or proof that an effect succeeded. diff --git a/Makefile b/Makefile index 4e096c20..d838769e 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ -.PHONY: install build lint typecheck test catalog security-gate smoke db-up db-down db-reset integration openapi-generate openapi-check openapi-breaking-check sdk-generate sdk-check sdk-build sdk-test sdk-pack check +.PHONY: install build lint typecheck test catalog security-gate smoke db-up db-down db-reset integration openapi-generate openapi-check openapi-breaking-check sdk-generate sdk-check sdk-build sdk-test sdk-pack action-typecheck action-build action-test check install: uv sync --frozen npm --prefix sdk/typescript ci --ignore-scripts + npm --prefix action_plane/typescript ci --ignore-scripts build: uv build @@ -12,6 +13,7 @@ lint: typecheck: uv run mypy + npm --prefix action_plane/typescript run typecheck test: uv run pytest -q tests/unit @@ -62,4 +64,13 @@ sdk-test: sdk-pack: npm --prefix sdk/typescript run pack:artifact -check: build lint typecheck openapi-check sdk-check sdk-build sdk-test sdk-pack test catalog smoke integration security-gate +action-typecheck: + npm --prefix action_plane/typescript run typecheck + +action-build: + npm --prefix action_plane/typescript run build + +action-test: + npm --prefix action_plane/typescript run test:runtime + +check: build lint typecheck openapi-check sdk-check sdk-build sdk-test sdk-pack action-build action-test test catalog smoke integration security-gate diff --git a/action_plane/typescript/.node-version b/action_plane/typescript/.node-version new file mode 100644 index 00000000..1d9b7831 --- /dev/null +++ b/action_plane/typescript/.node-version @@ -0,0 +1 @@ +22.12.0 diff --git a/action_plane/typescript/README.md b/action_plane/typescript/README.md new file mode 100644 index 00000000..df95df20 --- /dev/null +++ b/action_plane/typescript/README.md @@ -0,0 +1,14 @@ +# ContextEngine ActionPlane + +This private TypeScript module owns the trusted `ActionPlane.prepare` boundary +for one private delivery effect. It accepts only module-created +`TrustedEffectIntent` values, revalidates exact authority through the dedicated +PostgreSQL action login, and returns a closed zero-effect outcome or one +operation-specific `ActionTicket`. + +`perform`, Sender/provider access, group delivery, and external effects are not +part of this package revision and remain inactive. + +Run `npm test` for the contract, type, and runtime checks. Real PostgreSQL +prepare/RLS/idempotency evidence is exercised by the repository integration +suite. diff --git a/action_plane/typescript/THIRD_PARTY_NOTICES.md b/action_plane/typescript/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..d907c711 --- /dev/null +++ b/action_plane/typescript/THIRD_PARTY_NOTICES.md @@ -0,0 +1,11 @@ +# Third-party notices + +Production dependencies are pinned by `package-lock.json`. + +- `canonicalize` 3.0.0 — Apache License 2.0. Used only to encode the RFC 8785 + JSON payload binding before hashing. +- `pg` 8.22.0 — MIT License. Copyright (c) 2010–2021 Brian Carlson. Used only + for the dedicated PostgreSQL prepare authority. + +The complete license texts are included with the installed packages and their +published distributions. diff --git a/action_plane/typescript/package-lock.json b/action_plane/typescript/package-lock.json new file mode 100644 index 00000000..6e7cbcf4 --- /dev/null +++ b/action_plane/typescript/package-lock.json @@ -0,0 +1,227 @@ +{ + "name": "@context-engine/action-plane", + "version": "0.0.0-m2-prepare", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@context-engine/action-plane", + "version": "0.0.0-m2-prepare", + "license": "UNLICENSED", + "dependencies": { + "canonicalize": "3.0.0", + "pg": "8.22.0" + }, + "devDependencies": { + "@types/node": "22.10.2", + "@types/pg": "8.20.0", + "typescript": "5.9.3" + }, + "engines": { + "node": "22.12.0", + "npm": "10.9.0" + } + }, + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/canonicalize": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/canonicalize/-/canonicalize-3.0.0.tgz", + "integrity": "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/action_plane/typescript/package.json b/action_plane/typescript/package.json new file mode 100644 index 00000000..b1c320f9 --- /dev/null +++ b/action_plane/typescript/package.json @@ -0,0 +1,41 @@ +{ + "name": "@context-engine/action-plane", + "version": "0.0.0-m2-prepare", + "description": "Trusted private ActionPlane prepare module", + "type": "module", + "private": true, + "license": "UNLICENSED", + "engines": { + "node": "22.12.0", + "npm": "10.9.0" + }, + "packageManager": "npm@10.9.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md", + "THIRD_PARTY_NOTICES.md" + ], + "scripts": { + "clean": "node scripts/clean.mjs", + "typecheck": "tsc --project tsconfig.json --noEmit", + "build": "npm run clean && tsc --project tsconfig.build.json", + "test:runtime": "node --test test/action-plane.test.mjs", + "test": "npm run typecheck && npm run build && npm run test:runtime" + }, + "dependencies": { + "canonicalize": "3.0.0", + "pg": "8.22.0" + }, + "devDependencies": { + "@types/node": "22.10.2", + "@types/pg": "8.20.0", + "typescript": "5.9.3" + } +} diff --git a/action_plane/typescript/scripts/clean.mjs b/action_plane/typescript/scripts/clean.mjs new file mode 100644 index 00000000..b04f5f9f --- /dev/null +++ b/action_plane/typescript/scripts/clean.mjs @@ -0,0 +1,4 @@ +import { rmSync } from "node:fs"; +import { resolve } from "node:path"; + +rmSync(resolve(import.meta.dirname, "../dist"), { force: true, recursive: true }); diff --git a/action_plane/typescript/src/index.ts b/action_plane/typescript/src/index.ts new file mode 100644 index 00000000..9e2cd226 --- /dev/null +++ b/action_plane/typescript/src/index.ts @@ -0,0 +1,20 @@ +export { + ActionPlane, + ActionTicketKeyring, + CreatePlaceholderActionTicket, + FinalizeReplyActionTicket, + PrivateActionPrepareProfile, + SendPrivateFollowupActionTicket, +} from "./internal.js"; + +export type { + ActionOperation, + ActionPreparationOutcome, + ActionPrepareDatabase, + ActionTicket, + AudienceChanged, + GenericDenied, + PreparedAction, + RetryableUnavailable, + TrustedEffectIntent, +} from "./internal.js"; diff --git a/action_plane/typescript/src/internal.ts b/action_plane/typescript/src/internal.ts new file mode 100644 index 00000000..1446125c --- /dev/null +++ b/action_plane/typescript/src/internal.ts @@ -0,0 +1,854 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +import canonicalize from "canonicalize"; + +export type ActionOperation = + | "create_placeholder" + | "finalize_reply" + | "send_private_followup"; + +export type JsonValue = + | boolean + | number + | string + | null + | readonly JsonValue[] + | { readonly [key: string]: JsonValue }; + +export type EffectPayload = Readonly>; + +const ACTION_PAYLOAD_DOMAIN = Buffer.from("context-engine.action-payload.v1\0"); +const ACTION_TICKET_DOMAIN = Buffer.from("context-engine.action-ticket.v2\0"); +const ACTION_BINDING_DOMAIN = "context-engine.action-binding.v1\0"; +const PRIVATE_AUDIENCE_DOMAIN = Buffer.from("context-engine.private-delivery-audience.v1\0"); +const PRIVATE_AUDIENCE_PROFILE = "private-delivery-audience-rfc8785-sha256-v1"; +const ACTION_TICKET_DIGEST_PROFILE = "action-ticket-sha256-v1"; +const ACTION_PAYLOAD_DIGEST_PROFILE = "action-payload-rfc8785-sha256-v1"; +const MEDIA_TYPE = "application/json"; +const MAX_REF_LENGTH = 256; +const SHA256_HEX = /^[0-9a-f]{64}$/; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const DELIVERY_ATTEMPT_PATTERN = /^dla_[0-9a-f]{32}$/; +const TICKET_REF_PATTERN = /^act_[0-9a-f]{32}$/; +const ticketConstructionAuthority = Object.freeze({}); + +const OPERATION_CONTRACT = { + create_placeholder: { + audience: "private-effect:create-placeholder", + type: "CE-CreatePlaceholderActionTicket", + }, + finalize_reply: { + audience: "private-effect:finalize-reply", + type: "CE-FinalizeReplyActionTicket", + }, + send_private_followup: { + audience: "private-effect:send-private-followup", + type: "CE-SendPrivateFollowupActionTicket", + }, +} as const satisfies Record; + +function requireRef(name: string, value: unknown, maximum = MAX_REF_LENGTH): string { + if ( + typeof value !== "string" + || value.length === 0 + || value.length > maximum + || value.trim() !== value + || /\s/u.test(value) + ) { + throw new TypeError(`${name} must be a bounded opaque reference`); + } + return value; +} + +function requireUuid(name: string, value: unknown): string { + if (typeof value !== "string" || !UUID_PATTERN.test(value)) { + throw new TypeError(`${name} must be a canonical UUID`); + } + return value; +} + +function requirePositiveInteger(name: string, value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + throw new TypeError(`${name} must be a positive safe integer`); + } + return value as number; +} + +function requireSha256(name: string, value: unknown): string { + if (typeof value !== "string" || !SHA256_HEX.test(value)) { + throw new TypeError(`${name} must be lowercase SHA-256`); + } + return value; +} + +function requireDate(name: string, value: unknown): Date { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + throw new TypeError(`${name} must be a valid Date`); + } + return value; +} + +function canonicalJson(value: JsonValue | Record): Buffer { + const encoded = canonicalize(value); + if (encoded === undefined) { + throw new TypeError("value is outside the RFC 8785 JSON domain"); + } + return Buffer.from(encoded, "utf8"); +} + +function sha256Hex(domain: string, ...values: readonly string[]): string { + const digest = createHash("sha256"); + digest.update(domain, "utf8"); + for (const value of values) { + const encoded = Buffer.from(value, "utf8"); + digest.update(String(encoded.byteLength), "ascii"); + digest.update("\0", "ascii"); + digest.update(encoded); + } + return digest.digest("hex"); +} + +export function actionPayloadDigest( + operation: ActionOperation, + payload: EffectPayload, +): string { + if (!(operation in OPERATION_CONTRACT)) { + throw new TypeError("action operation is outside the closed union"); + } + const canonical = canonicalJson({ mediaType: MEDIA_TYPE, operation, payload }); + return createHash("sha256").update(ACTION_PAYLOAD_DOMAIN).update(canonical).digest("hex"); +} + +export function privateAudienceDigestForBinding(facts: { + readonly consumerRef: string; + readonly destinationRef: string; + readonly membershipId: string; + readonly membershipVersion: number; + readonly organizationId: string; +}): string { + const document = canonicalJson({ + consumerRef: requireRef("consumer", facts.consumerRef), + destinationRef: requireRef("private destination", facts.destinationRef), + membershipId: requireUuid("Membership", facts.membershipId), + membershipVersion: requirePositiveInteger("Membership version", facts.membershipVersion), + organizationId: requireUuid("Organization", facts.organizationId), + profile: PRIVATE_AUDIENCE_PROFILE, + }); + return createHash("sha256").update(PRIVATE_AUDIENCE_DOMAIN).update(document).digest("hex"); +} + +function validatePayload(operation: ActionOperation, payload: EffectPayload): EffectPayload { + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + throw new TypeError("effect payload must be one closed JSON object"); + } + const keys = Object.keys(payload).sort(); + if (operation === "finalize_reply") { + if ( + keys.join("\0") !== "messageRef\0text" + || typeof payload.messageRef !== "string" + || typeof payload.text !== "string" + ) { + throw new TypeError("finalize_reply payload must contain messageRef and text"); + } + requireRef("finalize message", payload.messageRef); + } else if (keys.join("\0") !== "text" || typeof payload.text !== "string") { + throw new TypeError("private message payload must contain only text"); + } + const text = payload.text; + if (typeof text !== "string" || text.length === 0 || text.trim().length === 0) { + throw new TypeError("effect text must be nonblank"); + } + canonicalJson(payload); + return Object.freeze({ ...payload }); +} + +export interface TrustedPrivateEffectFacts { + readonly audienceDigest: string; + readonly authenticatedServiceRef: string; + readonly authenticationBindingRef: string; + readonly consumerRef: string; + readonly deliveryEvidenceRef: string; + readonly destinationRef: string; + readonly membershipId: string; + readonly membershipVersion: number; + readonly organizationId: string; + readonly policyEpoch: number; + readonly purpose: string; + readonly userId: string; +} + +interface TrustedPrivateEffectAuthority extends TrustedPrivateEffectFacts { + readonly identityDigest: string; +} + +const trustedAuthorities = new WeakSet(); + +export function createTrustedPrivateEffectAuthority( + facts: TrustedPrivateEffectFacts, +): TrustedPrivateEffectAuthority { + const validated: TrustedPrivateEffectFacts = { + audienceDigest: requireSha256("private audience", facts.audienceDigest), + authenticatedServiceRef: requireRef("authenticated service", facts.authenticatedServiceRef), + authenticationBindingRef: requireRef("authentication binding", facts.authenticationBindingRef), + consumerRef: requireRef("consumer", facts.consumerRef), + deliveryEvidenceRef: requireRef("DeliveryEvidenceRef", facts.deliveryEvidenceRef, 4096), + destinationRef: requireRef("private destination", facts.destinationRef), + membershipId: requireUuid("Membership", facts.membershipId), + membershipVersion: requirePositiveInteger("Membership version", facts.membershipVersion), + organizationId: requireUuid("Organization", facts.organizationId), + policyEpoch: requirePositiveInteger("Policy Epoch", facts.policyEpoch), + purpose: requireRef("purpose", facts.purpose), + userId: requireUuid("User", facts.userId), + }; + const authority = Object.freeze({ + ...validated, + identityDigest: sha256Hex( + `${ACTION_BINDING_DOMAIN}identity\0`, + validated.organizationId, + validated.userId, + validated.membershipId, + String(validated.membershipVersion), + validated.authenticationBindingRef, + ), + }); + trustedAuthorities.add(authority); + return authority; +} + +export interface ActiveSourceContext { + readonly sourceRef: string; + readonly sourceVersionRef: string; +} + +interface EffectIntentOptions { + readonly approvalTier?: string; + readonly deliveryAttemptRef: string; + readonly idempotencyKey: string; + readonly payload: EffectPayload; + readonly sourceContext?: ActiveSourceContext; +} + +declare const trustedEffectIntentBrand: unique symbol; + +export interface TrustedEffectIntent { + readonly [trustedEffectIntentBrand]: true; +} + +interface PrivateEffectIntent extends TrustedEffectIntent { + readonly approvalTier: string | undefined; + readonly authority: TrustedPrivateEffectAuthority; + readonly deliveryAttemptRef: string; + readonly idempotencyKey: string; + readonly operation: ActionOperation; + readonly payload: EffectPayload; + readonly payloadDigest: string; + readonly sourceContext: ActiveSourceContext | undefined; +} + +const trustedIntents = new WeakSet(); + +function createIntent( + authority: TrustedPrivateEffectAuthority, + operation: ActionOperation, + options: EffectIntentOptions, +): TrustedEffectIntent { + if (!trustedAuthorities.has(authority)) { + throw new TypeError("TrustedEffectIntent requires trusted private authority"); + } + const deliveryAttemptRef = requireRef("DeliveryAttemptRef", options.deliveryAttemptRef); + if (!DELIVERY_ATTEMPT_PATTERN.test(deliveryAttemptRef)) { + throw new TypeError("DeliveryAttemptRef has the wrong shape"); + } + const idempotencyKey = requireRef("action idempotency key", options.idempotencyKey, 128); + const payload = validatePayload(operation, options.payload); + let sourceContext: ActiveSourceContext | undefined; + if (options.sourceContext !== undefined) { + sourceContext = Object.freeze({ + sourceRef: requireUuid("source", options.sourceContext.sourceRef), + sourceVersionRef: requireUuid("source version", options.sourceContext.sourceVersionRef), + }); + } + const intent = Object.freeze({ + approvalTier: options.approvalTier, + authority, + deliveryAttemptRef, + idempotencyKey, + operation, + payload, + payloadDigest: actionPayloadDigest(operation, payload), + sourceContext, + }) as unknown as PrivateEffectIntent; + trustedIntents.add(intent); + return intent; +} + +export function createPlaceholderEffectIntent( + authority: TrustedPrivateEffectAuthority, + options: EffectIntentOptions, +): TrustedEffectIntent { + return createIntent(authority, "create_placeholder", options); +} + +export function createFinalizeReplyEffectIntent( + authority: TrustedPrivateEffectAuthority, + options: EffectIntentOptions, +): TrustedEffectIntent { + return createIntent(authority, "finalize_reply", options); +} + +export function createPrivateFollowupEffectIntent( + authority: TrustedPrivateEffectAuthority, + options: EffectIntentOptions, +): TrustedEffectIntent { + return createIntent(authority, "send_private_followup", options); +} + +interface ProfileOptions { + readonly approvalTier: string; + readonly authenticatedServiceRef: string; + readonly consumerRef: string; + readonly maximumPayloadBytes: number; + readonly profileRef: string; + readonly purpose: string; + readonly retentionPolicyRef: string; + readonly retentionSeconds: number; + readonly ticketTtlSeconds: number; +} + +const profiles = new WeakMap>(); + +export class PrivateActionPrepareProfile { + constructor(options: ProfileOptions) { + const validated = Object.freeze({ + approvalTier: requireRef("approval tier", options.approvalTier), + authenticatedServiceRef: requireRef("profile service", options.authenticatedServiceRef), + consumerRef: requireRef("profile consumer", options.consumerRef), + maximumPayloadBytes: requirePositiveInteger("maximum payload bytes", options.maximumPayloadBytes), + profileRef: requireRef("prepare profile", options.profileRef), + purpose: requireRef("profile purpose", options.purpose), + retentionPolicyRef: requireRef("retention policy", options.retentionPolicyRef), + retentionSeconds: requirePositiveInteger("retention seconds", options.retentionSeconds), + ticketTtlSeconds: requirePositiveInteger("ticket TTL", options.ticketTtlSeconds), + }); + if (validated.ticketTtlSeconds > 300) { + throw new TypeError("ticket TTL exceeds the active safety ceiling"); + } + if ( + validated.retentionPolicyRef !== "action-digest-audit-retention-v1" + || validated.retentionSeconds > 31_536_000 + ) { + throw new TypeError("action retention profile is not active"); + } + profiles.set(this, validated); + Object.freeze(this); + } +} + +interface KeyringOptions { + readonly activeVersion: number; + readonly keys: ReadonlyMap; +} + +interface KeyringState { + readonly activeVersion: number; + readonly keys: ReadonlyMap; +} + +const keyrings = new WeakMap(); + +export class ActionTicketKeyring { + constructor(options: KeyringOptions) { + const activeVersion = requirePositiveInteger("active signing key version", options.activeVersion); + const copied = new Map(); + for (const [version, key] of options.keys) { + requirePositiveInteger("signing key version", version); + if (!(key instanceof Uint8Array) || key.byteLength < 32) { + throw new TypeError("action signing keys must contain at least 256 bits"); + } + copied.set(version, Buffer.from(key)); + } + if (!copied.has(activeVersion)) { + throw new TypeError("active action signing key is missing"); + } + keyrings.set(this, { activeVersion, keys: copied }); + Object.freeze(this); + } +} + +function keyringState(keyring: ActionTicketKeyring): KeyringState { + const state = keyrings.get(keyring); + if (state === undefined) { + throw new TypeError("action keyring has invalid provenance"); + } + return state; +} + +abstract class RedactedTicket { + readonly #serialized: string; + + protected constructor(serialized: string) { + this.#serialized = serialized; + } + + serialize(): string { + return this.#serialized; + } + + toString(): string { + return ""; + } + + toJSON(): never { + throw new TypeError("ActionTicket is not JSON serializable"); + } +} + +export class CreatePlaceholderActionTicket extends RedactedTicket { + declare private readonly createPlaceholderTicketBrand: true; + + constructor(serialized: string, authority: object) { + if (authority !== ticketConstructionAuthority) { + throw new TypeError("ActionTicket is issuer-constructed only"); + } + super(serialized); + } +} + +export class FinalizeReplyActionTicket extends RedactedTicket { + declare private readonly finalizeReplyTicketBrand: true; + + constructor(serialized: string, authority: object) { + if (authority !== ticketConstructionAuthority) { + throw new TypeError("ActionTicket is issuer-constructed only"); + } + super(serialized); + } +} + +export class SendPrivateFollowupActionTicket extends RedactedTicket { + declare private readonly sendPrivateFollowupTicketBrand: true; + + constructor(serialized: string, authority: object) { + if (authority !== ticketConstructionAuthority) { + throw new TypeError("ActionTicket is issuer-constructed only"); + } + super(serialized); + } +} + +export type ActionTicket = + | CreatePlaceholderActionTicket + | FinalizeReplyActionTicket + | SendPrivateFollowupActionTicket; + +interface TicketClaims { + readonly approvalTier: string; + readonly audience: string; + readonly audienceDigest: string; + readonly deliveryAttemptRef: string; + readonly destinationDigest: string; + readonly expiresAt: string; + readonly idempotencyDigest: string; + readonly identityDigest: string; + readonly issuedAt: string; + readonly operation: ActionOperation; + readonly organizationId: string; + readonly payloadDigest: string; + readonly policyEpoch: number; + readonly profileRef: string; + readonly purposeDigest: string; + readonly serviceDigest: string; + readonly signingKeyVersion: number; + readonly sourceContextDigest: string | null; + readonly ticketRef: string; + readonly type: string; +} + +function base64url(value: Buffer): string { + return value.toString("base64url"); +} + +function mintTicket(claims: TicketClaims, keyring: ActionTicketKeyring): ActionTicket { + const state = keyringState(keyring); + if (claims.signingKeyVersion !== state.activeVersion) { + throw new TypeError("prepare authority returned an inactive signing version"); + } + const header = canonicalJson({ + alg: "HS256", + domain: ACTION_TICKET_DOMAIN.toString("utf8").replace(/\0$/u, ""), + keyVersion: claims.signingKeyVersion, + typ: claims.type, + }); + const body = canonicalJson(claims as unknown as Record); + const unsigned = `${base64url(header)}.${base64url(body)}`; + const key = state.keys.get(claims.signingKeyVersion); + if (key === undefined) { + throw new TypeError("action signing key is unavailable"); + } + const signature = createHmac("sha256", key) + .update(ACTION_TICKET_DOMAIN) + .update(unsigned, "ascii") + .digest(); + const serialized = `${unsigned}.${base64url(signature)}`; + switch (claims.operation) { + case "create_placeholder": + return new CreatePlaceholderActionTicket(serialized, ticketConstructionAuthority); + case "finalize_reply": + return new FinalizeReplyActionTicket(serialized, ticketConstructionAuthority); + case "send_private_followup": + return new SendPrivateFollowupActionTicket(serialized, ticketConstructionAuthority); + } +} + +function parseTicket(ticket: ActionTicket, keyring: ActionTicketKeyring): TicketClaims { + if (!(ticket instanceof RedactedTicket)) { + throw new TypeError("ActionTicket has invalid nominal type"); + } + const segments = ticket.serialize().split("."); + if (segments.length !== 3) { + throw new TypeError("ActionTicket is malformed"); + } + const [encodedHeader, encodedClaims, encodedSignature] = segments; + if (encodedHeader === undefined || encodedClaims === undefined || encodedSignature === undefined) { + throw new TypeError("ActionTicket is malformed"); + } + const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")) as { + keyVersion?: unknown; + typ?: unknown; + }; + const version = requirePositiveInteger("ticket key version", header.keyVersion); + const state = keyringState(keyring); + const key = state.keys.get(version); + if (key === undefined) { + throw new TypeError("ActionTicket key is unavailable"); + } + const expected = createHmac("sha256", key) + .update(ACTION_TICKET_DOMAIN) + .update(`${encodedHeader}.${encodedClaims}`, "ascii") + .digest(); + const observed = Buffer.from(encodedSignature, "base64url"); + if (observed.byteLength !== expected.byteLength || !timingSafeEqual(observed, expected)) { + throw new TypeError("ActionTicket is not available"); + } + const claims = JSON.parse(Buffer.from(encodedClaims, "base64url").toString("utf8")) as TicketClaims; + const operationContract = OPERATION_CONTRACT[claims.operation]; + if ( + operationContract === undefined + || claims.type !== operationContract.type + || header.typ !== operationContract.type + || claims.audience !== operationContract.audience + ) { + throw new TypeError("ActionTicket operation binding is invalid"); + } + return claims; +} + +export function inspectPreparedActionTicket( + ticket: ActionTicket, + keyring: ActionTicketKeyring, +): TicketClaims { + return parseTicket(ticket, keyring); +} + +interface DatabaseQueryResult { + readonly rows: readonly Record[]; +} + +export interface ActionPrepareDatabase { + query(config: { readonly text: string; readonly values: readonly unknown[] }): Promise; +} + +interface PrepareRequest { + readonly approvalTier: string; + readonly audienceDigest: string; + readonly authenticationBindingRef: string; + readonly authenticatedServiceRef: string; + readonly consumerRef: string; + readonly deliveryAttemptRef: string; + readonly deliveryEvidenceRef: string; + readonly destinationRef: string; + readonly idempotencyDigest: string; + readonly identityDigest: string; + readonly membershipId: string; + readonly membershipVersion: number; + readonly operation: ActionOperation; + readonly organizationId: string; + readonly payloadDigest: string; + readonly policyEpoch: number; + readonly profileRef: string; + readonly proposedTicketRef: string; + readonly purpose: string; + readonly retentionPolicyRef: string; + readonly retentionSeconds: number; + readonly signingKeyVersion: number; + readonly sourceContext: ActiveSourceContext | undefined; + readonly ticketTtlSeconds: number; + readonly userId: string; +} + +interface PrepareAuthorityResult { + readonly deliveryAttemptRef?: string; + readonly expiresAt?: Date; + readonly idempotent?: boolean; + readonly issuedAt?: Date; + readonly kind: "audience_changed" | "generic_denied" | "prepared" | "retryable_unavailable"; + readonly ticketRef?: string; +} + +const PREPARE_SQL = ` +SELECT * FROM context_action_prepare_private_effect( + $1::uuid, digest($2::text, 'sha256'), digest($3::text, 'sha256'), + digest($4::text, 'sha256'), $5::uuid, $6::uuid, $7::bigint, + digest($8::text, 'sha256'), digest($9::text, 'sha256'), + digest($10::text, 'sha256'), $11::bytea, $12::bytea, $13::bigint, + $14::text, $15::text, $16::bytea, $17::bytea, $18::bytea, + $19::text, $20::text, $21::text, $22::text, $23::integer, + $24::bigint, $25::uuid, $26::uuid, $27::text, $28::bigint +)`; + +class PostgresActionPrepareAuthority { + readonly #database: ActionPrepareDatabase; + + constructor(database: ActionPrepareDatabase) { + if (typeof database?.query !== "function") { + throw new TypeError("ActionPlane prepare requires a database query authority"); + } + this.#database = database; + Object.freeze(this); + } + + async prepare(request: PrepareRequest): Promise { + try { + const row = (await this.#database.query({ + text: PREPARE_SQL, + values: [ + request.organizationId, + request.authenticatedServiceRef, + request.deliveryEvidenceRef, + request.authenticationBindingRef, + request.userId, + request.membershipId, + request.membershipVersion, + request.destinationRef, + request.consumerRef, + request.purpose, + Buffer.from(request.audienceDigest, "hex"), + Buffer.from(request.identityDigest, "hex"), + request.policyEpoch, + request.operation, + OPERATION_CONTRACT[request.operation].audience, + Buffer.from(request.payloadDigest, "hex"), + Buffer.from(request.idempotencyDigest, "hex"), + Buffer.from(sha256Hex(`${ACTION_BINDING_DOMAIN}approval\0`, request.approvalTier), "hex"), + request.approvalTier, + request.deliveryAttemptRef, + request.proposedTicketRef, + request.profileRef, + request.signingKeyVersion, + request.ticketTtlSeconds, + request.sourceContext?.sourceRef ?? null, + request.sourceContext?.sourceVersionRef ?? null, + request.retentionPolicyRef, + request.retentionSeconds, + ], + })).rows[0]; + if (row === undefined) { + return { kind: "retryable_unavailable" }; + } + const kind = row.outcome; + if (kind === "generic_denied" || kind === "audience_changed") { + return { kind }; + } + if (kind !== "prepared") { + return { kind: "retryable_unavailable" }; + } + return { + deliveryAttemptRef: requireRef("persisted DeliveryAttemptRef", row.delivery_attempt_ref), + expiresAt: requireDate("persisted ticket expiry", row.expires_at), + idempotent: row.idempotent === true, + issuedAt: requireDate("persisted ticket issuance", row.issued_at), + kind, + ticketRef: requireRef("persisted ActionTicket ref", row.ticket_ref), + }; + } catch { + return { kind: "retryable_unavailable" }; + } + } +} + +export interface PreparedAction { + readonly deliveryAttemptRef: string; + readonly effectCount: 0; + readonly idempotent: boolean; + readonly kind: "prepared"; + readonly operation: ActionOperation; + readonly ticket: ActionTicket; +} + +export interface GenericDenied { + readonly effectCount: 0; + readonly kind: "generic_denied"; +} + +export interface AudienceChanged { + readonly effectCount: 0; + readonly kind: "audience_changed"; +} + +export interface RetryableUnavailable { + readonly effectCount: 0; + readonly kind: "retryable_unavailable"; +} + +export type ActionPreparationOutcome = + | AudienceChanged + | GenericDenied + | PreparedAction + | RetryableUnavailable; + +interface ActionPlaneOptions { + readonly database: ActionPrepareDatabase; + readonly keyring: ActionTicketKeyring; + readonly profile: PrivateActionPrepareProfile; + readonly ticketRefFactory?: () => string; +} + +export class ActionPlane { + readonly #authority: PostgresActionPrepareAuthority; + readonly #keyring: ActionTicketKeyring; + readonly #profile: Readonly; + readonly #ticketRefFactory: () => string; + + constructor(options: ActionPlaneOptions) { + keyringState(options.keyring); + const profile = profiles.get(options.profile); + if (profile === undefined) { + throw new TypeError("ActionPlane requires a trusted prepare profile"); + } + this.#authority = new PostgresActionPrepareAuthority(options.database); + this.#keyring = options.keyring; + this.#profile = profile; + this.#ticketRefFactory = options.ticketRefFactory ?? (() => `act_${randomBytes(16).toString("hex")}`); + Object.freeze(this); + } + + async prepare(intent: TrustedEffectIntent): Promise { + if (typeof intent !== "object" || intent === null || !trustedIntents.has(intent)) { + throw new TypeError("ActionPlane.prepare requires TrustedEffectIntent"); + } + const trusted = intent as PrivateEffectIntent; + const authority = trusted.authority; + const profile = this.#profile; + const approvalTier = trusted.approvalTier ?? profile.approvalTier; + const canonicalPayloadBytes = canonicalJson({ + mediaType: MEDIA_TYPE, + operation: trusted.operation, + payload: trusted.payload, + }); + if ( + !trustedAuthorities.has(authority) + || authority.authenticatedServiceRef !== profile.authenticatedServiceRef + || authority.consumerRef !== profile.consumerRef + || authority.purpose !== profile.purpose + || approvalTier !== profile.approvalTier + || canonicalPayloadBytes.byteLength > profile.maximumPayloadBytes + || actionPayloadDigest(trusted.operation, trusted.payload) !== trusted.payloadDigest + ) { + return { effectCount: 0, kind: "generic_denied" }; + } + const proposedTicketRef = this.#ticketRefFactory(); + if (!TICKET_REF_PATTERN.test(proposedTicketRef)) { + return { effectCount: 0, kind: "retryable_unavailable" }; + } + const signing = keyringState(this.#keyring); + const idempotencyDigest = sha256Hex( + `${ACTION_BINDING_DOMAIN}idempotency\0`, + authority.organizationId, + trusted.operation, + trusted.idempotencyKey, + ); + const result = await this.#authority.prepare({ + approvalTier, + audienceDigest: authority.audienceDigest, + authenticationBindingRef: authority.authenticationBindingRef, + authenticatedServiceRef: authority.authenticatedServiceRef, + consumerRef: authority.consumerRef, + deliveryAttemptRef: trusted.deliveryAttemptRef, + deliveryEvidenceRef: authority.deliveryEvidenceRef, + destinationRef: authority.destinationRef, + idempotencyDigest, + identityDigest: authority.identityDigest, + membershipId: authority.membershipId, + membershipVersion: authority.membershipVersion, + operation: trusted.operation, + organizationId: authority.organizationId, + payloadDigest: trusted.payloadDigest, + policyEpoch: authority.policyEpoch, + profileRef: profile.profileRef, + proposedTicketRef, + purpose: authority.purpose, + retentionPolicyRef: profile.retentionPolicyRef, + retentionSeconds: profile.retentionSeconds, + signingKeyVersion: signing.activeVersion, + sourceContext: trusted.sourceContext, + ticketTtlSeconds: profile.ticketTtlSeconds, + userId: authority.userId, + }); + if (result.kind !== "prepared") { + return { effectCount: 0, kind: result.kind }; + } + if ( + result.deliveryAttemptRef === undefined + || !DELIVERY_ATTEMPT_PATTERN.test(result.deliveryAttemptRef) + || result.ticketRef === undefined + || !TICKET_REF_PATTERN.test(result.ticketRef) + || result.issuedAt === undefined + || result.expiresAt === undefined + || result.expiresAt <= result.issuedAt + ) { + return { effectCount: 0, kind: "retryable_unavailable" }; + } + const operationContract = OPERATION_CONTRACT[trusted.operation]; + const claims: TicketClaims = { + approvalTier, + audience: operationContract.audience, + audienceDigest: authority.audienceDigest, + deliveryAttemptRef: result.deliveryAttemptRef, + destinationDigest: sha256Hex(`${ACTION_BINDING_DOMAIN}destination\0`, authority.destinationRef), + expiresAt: result.expiresAt.toISOString(), + idempotencyDigest, + identityDigest: authority.identityDigest, + issuedAt: result.issuedAt.toISOString(), + operation: trusted.operation, + organizationId: authority.organizationId, + payloadDigest: trusted.payloadDigest, + policyEpoch: authority.policyEpoch, + profileRef: profile.profileRef, + purposeDigest: sha256Hex(`${ACTION_BINDING_DOMAIN}purpose\0`, authority.purpose), + serviceDigest: sha256Hex(`${ACTION_BINDING_DOMAIN}service\0`, authority.authenticatedServiceRef), + signingKeyVersion: signing.activeVersion, + sourceContextDigest: trusted.sourceContext === undefined + ? null + : sha256Hex( + `${ACTION_BINDING_DOMAIN}source\0`, + trusted.sourceContext.sourceRef, + trusted.sourceContext.sourceVersionRef, + ), + ticketRef: result.ticketRef, + type: operationContract.type, + }; + return { + deliveryAttemptRef: result.deliveryAttemptRef, + effectCount: 0, + idempotent: result.idempotent === true, + kind: "prepared", + operation: trusted.operation, + ticket: mintTicket(claims, this.#keyring), + }; + } +} + +export const actionDigestProfiles = Object.freeze({ + payload: ACTION_PAYLOAD_DIGEST_PROFILE, + ticket: ACTION_TICKET_DIGEST_PROFILE, +}); diff --git a/action_plane/typescript/test/action-plane.test.mjs b/action_plane/typescript/test/action-plane.test.mjs new file mode 100644 index 00000000..697e2cf6 --- /dev/null +++ b/action_plane/typescript/test/action-plane.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + ActionPlane, + ActionTicketKeyring, + CreatePlaceholderActionTicket, + PrivateActionPrepareProfile, +} from "../dist/index.js"; +import { + actionPayloadDigest, + createPlaceholderEffectIntent, + createTrustedPrivateEffectAuthority, + inspectPreparedActionTicket, +} from "../dist/internal.js"; + +const exactFacts = { + audienceDigest: "a".repeat(64), + authenticatedServiceRef: "service:bot-delivery", + authenticationBindingRef: "binding:bot-delivery", + consumerRef: "consumer:bot-delivery", + deliveryEvidenceRef: `der_${"b".repeat(64)}`, + destinationRef: "private-chat:42", + membershipId: "9c9e9f4c-a5ec-4417-9408-0346e1c6c998", + membershipVersion: 7, + organizationId: "81e18bca-86a1-478a-937d-7675c6fe69b0", + policyEpoch: 11, + purpose: "context.answer", + userId: "d3d9893f-82d2-4890-8cb2-4c7e57a56f16", +}; + +const profile = new PrivateActionPrepareProfile({ + approvalTier: "preapproved_private_delivery_v1", + authenticatedServiceRef: exactFacts.authenticatedServiceRef, + consumerRef: exactFacts.consumerRef, + maximumPayloadBytes: 4096, + profileRef: "private-action-prepare-v1", + purpose: exactFacts.purpose, + retentionPolicyRef: "action-digest-audit-retention-v1", + retentionSeconds: 2_592_000, + ticketTtlSeconds: 60, +}); +const keyring = new ActionTicketKeyring({ + activeVersion: 1, + keys: new Map([[1, Buffer.alloc(32, 0x11)]]), +}); + +test("RFC 8785 payload binding has an independent known digest", () => { + assert.equal( + actionPayloadDigest("create_placeholder", { text: "Working…" }), + "a7b1734f5419699728721a4482c1d020be048e1899ed4b6dd40b7a8e9f8d8ebe", + ); +}); + +test("prepare returns one signed create ticket and has no Sender seam", async () => { + const observed = []; + const database = { + async query(query) { + observed.push(query); + return { + rows: [ + { + delivery_attempt_ref: `dla_${"c".repeat(32)}`, + expires_at: new Date("2026-07-24T08:01:00.000Z"), + idempotent: false, + issued_at: new Date("2026-07-24T08:00:00.000Z"), + outcome: "prepared", + ticket_ref: `act_${"d".repeat(32)}`, + }, + ], + }; + }, + }; + const plane = new ActionPlane({ + database, + keyring, + profile, + }); + const trusted = createTrustedPrivateEffectAuthority(exactFacts); + const intent = createPlaceholderEffectIntent(trusted, { + deliveryAttemptRef: `dla_${"c".repeat(32)}`, + idempotencyKey: "turn-42:create-placeholder", + payload: { text: "Working…" }, + }); + + const outcome = await plane.prepare(intent); + + assert.equal(outcome.kind, "prepared"); + assert.equal(outcome.effectCount, 0); + assert.equal(outcome.operation, "create_placeholder"); + assert.equal(outcome.deliveryAttemptRef, `dla_${"c".repeat(32)}`); + assert.equal(observed.length, 1); + assert.equal("sender" in plane, false); + const claims = inspectPreparedActionTicket(outcome.ticket, keyring); + assert.equal(claims.type, "CE-CreatePlaceholderActionTicket"); + assert.equal(claims.operation, "create_placeholder"); + assert.equal(claims.destinationDigest.length, 64); + assert.equal(claims.payloadDigest, actionPayloadDigest( + "create_placeholder", + { text: "Working…" }, + )); + assert.equal(outcome.ticket.toString(), ""); + assert.equal(outcome.ticket.serialize().includes(exactFacts.destinationRef), false); +}); + +test("plain objects and caller wire values cannot become trusted intents", async () => { + const database = { query: async () => ({ rows: [] }) }; + const plane = new ActionPlane({ + database, + keyring, + profile, + }); + for (const untrusted of [ + {}, + { action_required: true }, + { kind: "resolved", package: { organizationRef: "caller-authored" } }, + { text: "model output" }, + ]) { + await assert.rejects( + plane.prepare(untrusted), + /TrustedEffectIntent/, + ); + } +}); + +test("public package exposes no lower-level prepare authority", async () => { + const publicApi = await import("../dist/index.js"); + + assert.equal("PostgresActionPrepareAuthority" in publicApi, false); +}); + +test("exported ticket nominal types cannot mint effect authority", () => { + assert.throws( + () => new CreatePlaceholderActionTicket("caller-authored"), + /issuer-constructed/, + ); +}); diff --git a/action_plane/typescript/test/live-prepare.mjs b/action_plane/typescript/test/live-prepare.mjs new file mode 100644 index 00000000..851499d3 --- /dev/null +++ b/action_plane/typescript/test/live-prepare.mjs @@ -0,0 +1,287 @@ +import { + ActionPlane, + ActionTicketKeyring, + PrivateActionPrepareProfile, +} from "../dist/index.js"; +import { + createFinalizeReplyEffectIntent, + createPlaceholderEffectIntent, + createPrivateFollowupEffectIntent, + createTrustedPrivateEffectAuthority, + inspectPreparedActionTicket, + privateAudienceDigestForBinding, +} from "../dist/internal.js"; +import pg from "pg"; +import { randomUUID } from "node:crypto"; + +function requiredEnvironment(name) { + const value = process.env[name]; + if (value === undefined || value.length === 0) { + throw new Error(`missing live action test input: ${name}`); + } + return value; +} + +function mutateFacts(facts, field, otherOrganizationId) { + switch (field) { + case "service": + return { ...facts, authenticatedServiceRef: `${facts.authenticatedServiceRef}-wrong` }; + case "binding": + return { ...facts, authenticationBindingRef: `${facts.authenticationBindingRef}-wrong` }; + case "destination": + return { ...facts, destinationRef: `${facts.destinationRef}-wrong` }; + case "consumer": + return { ...facts, consumerRef: `${facts.consumerRef}-wrong` }; + case "purpose": + return { ...facts, purpose: `${facts.purpose}-wrong` }; + case "audience": + return { ...facts, audienceDigest: "f".repeat(64) }; + case "epoch": + return { ...facts, policyEpoch: facts.policyEpoch + 1 }; + case "membership_version": + return { ...facts, membershipVersion: facts.membershipVersion + 1 }; + case "organization": + return { ...facts, organizationId: otherOrganizationId }; + default: + throw new Error("unknown live mutation"); + } +} + +const organizationId = requiredEnvironment("CE_ACTION_ORGANIZATION_ID"); +const otherOrganizationId = requiredEnvironment("CE_ACTION_OTHER_ORGANIZATION_ID"); +const userId = requiredEnvironment("CE_ACTION_USER_ID"); +const membershipId = requiredEnvironment("CE_ACTION_MEMBERSHIP_ID"); +const deliveryEvidenceRef = requiredEnvironment("CE_ACTION_DELIVERY_EVIDENCE_REF"); +const expiredDeliveryEvidenceRef = requiredEnvironment("CE_ACTION_EXPIRED_EVIDENCE_REF"); +const sourceId = requiredEnvironment("CE_ACTION_SOURCE_ID"); +const sourceVersionId = requiredEnvironment("CE_ACTION_SOURCE_VERSION_ID"); +const expectedActiveSource = process.env.CE_ACTION_EXPECT_ACTIVE_SOURCE ?? "prepared"; +const databaseUrl = requiredEnvironment("CE_ACTION_DATABASE_URL"); +const exactFacts = { + authenticatedServiceRef: "application:private-bot", + authenticationBindingRef: "binding:private-bot", + consumerRef: "consumer:private-bot", + deliveryEvidenceRef, + destinationRef: "private-chat:same-label", + membershipId, + membershipVersion: 1, + organizationId, + policyEpoch: 1, + purpose: "context.answer", + userId, +}; +exactFacts.audienceDigest = privateAudienceDigestForBinding(exactFacts); + +const profileOptions = { + approvalTier: "preapproved_private_delivery_v1", + authenticatedServiceRef: exactFacts.authenticatedServiceRef, + consumerRef: exactFacts.consumerRef, + maximumPayloadBytes: 4096, + profileRef: "private-action-prepare-v1", + purpose: exactFacts.purpose, + retentionPolicyRef: "action-digest-audit-retention-v1", + retentionSeconds: 2_592_000, + ticketTtlSeconds: 60, +}; +const keyring = new ActionTicketKeyring({ + activeVersion: 1, + keys: new Map([[1, Buffer.alloc(32, 0x29)]]), +}); +const pool = new pg.Pool({ + application_name: "context-engine-action-prepare-integration", + connectionString: databaseUrl, + connectionTimeoutMillis: 5_000, + max: 1, + statement_timeout: 5_000, +}); +let nextTicket = 1; +function plane(profile = profileOptions) { + return new ActionPlane({ + database: pool, + keyring, + profile: new PrivateActionPrepareProfile(profile), + ticketRefFactory: () => `act_${(nextTicket++).toString(16).padStart(32, "0")}`, + }); +} + +const nominalPlane = plane(); +const trusted = createTrustedPrivateEffectAuthority(exactFacts); +const placeholderIntent = createPlaceholderEffectIntent(trusted, { + deliveryAttemptRef: `dla_${"1".repeat(32)}`, + idempotencyKey: "turn-67:create-placeholder", + payload: { text: "Working…" }, +}); + +try { + const first = await nominalPlane.prepare(placeholderIntent); + const retry = await nominalPlane.prepare(placeholderIntent); + if (first.kind !== "prepared" || retry.kind !== "prepared") { + throw new Error( + `nominal prepare did not produce Prepared (${first.kind}, ${retry.kind})`, + ); + } + if (first.ticket.serialize() !== retry.ticket.serialize() || retry.idempotent !== true) { + throw new Error("exact retry did not return the same logical ticket"); + } + + const finalize = await nominalPlane.prepare(createFinalizeReplyEffectIntent(trusted, { + deliveryAttemptRef: `dla_${"1".repeat(32)}`, + idempotencyKey: "turn-67:finalize-reply", + payload: { messageRef: "message:placeholder-67", text: "Done" }, + })); + const followup = await nominalPlane.prepare(createPrivateFollowupEffectIntent(trusted, { + deliveryAttemptRef: `dla_${"2".repeat(32)}`, + idempotencyKey: "turn-67:private-followup", + payload: { text: "More context" }, + })); + if (finalize.kind !== "prepared" || followup.kind !== "prepared") { + throw new Error("closed operation variants did not prepare"); + } + const types = [first, finalize, followup].map((outcome) => + inspectPreparedActionTicket(outcome.ticket, keyring).type + ); + if (new Set(types).size !== 3) { + throw new Error("operation-specific ticket types are not distinct"); + } + + const operationCases = [ + { + conflictPayload: { text: "Conflicting placeholder" }, + deliveryAttemptRef: `dla_${"1".repeat(32)}`, + idempotencyKey: "turn-67:create-placeholder", + makeIntent: createPlaceholderEffectIntent, + operation: "create_placeholder", + payload: { text: "Working…" }, + }, + { + conflictPayload: { messageRef: "message:placeholder-67", text: "Conflicting final" }, + deliveryAttemptRef: `dla_${"1".repeat(32)}`, + idempotencyKey: "turn-67:finalize-reply", + makeIntent: createFinalizeReplyEffectIntent, + operation: "finalize_reply", + payload: { messageRef: "message:placeholder-67", text: "Done" }, + }, + { + conflictPayload: { text: "Conflicting follow-up" }, + deliveryAttemptRef: `dla_${"2".repeat(32)}`, + idempotencyKey: "turn-67:private-followup", + makeIntent: createPrivateFollowupEffectIntent, + operation: "send_private_followup", + payload: { text: "More context" }, + }, + ]; + const payloadConflicts = {}; + for (const operationCase of operationCases) { + const conflict = await nominalPlane.prepare(operationCase.makeIntent(trusted, { + deliveryAttemptRef: operationCase.deliveryAttemptRef, + idempotencyKey: operationCase.idempotencyKey, + payload: operationCase.conflictPayload, + })); + if (conflict.kind !== "generic_denied" || conflict.effectCount !== 0) { + throw new Error(`${operationCase.operation} payload conflict did not fail closed`); + } + payloadConflicts[operationCase.operation] = conflict.kind; + } + + const denied = {}; + let matrixEffectCount = 0; + let mutationNumber = 10; + for (const operationCase of operationCases) { + denied[operationCase.operation] = {}; + for (const field of [ + "organization", + "service", + "binding", + "destination", + "consumer", + "purpose", + "audience", + "epoch", + "membership_version", + ]) { + const mutation = mutateFacts(exactFacts, field, otherOrganizationId); + if (field === "audience") { + mutation.audienceDigest = "f".repeat(64); + } else { + mutation.audienceDigest = privateAudienceDigestForBinding(mutation); + } + const outcome = await nominalPlane.prepare(operationCase.makeIntent( + createTrustedPrivateEffectAuthority(mutation), + { + deliveryAttemptRef: `dla_${(mutationNumber++).toString(16).padStart(32, "0")}`, + idempotencyKey: `turn-67:${operationCase.operation}:wrong-${field}`, + payload: operationCase.payload, + }, + )); + if (![ + "generic_denied", + "audience_changed", + "retryable_unavailable", + ].includes(outcome.kind) || outcome.effectCount !== 0) { + throw new Error( + `${operationCase.operation} wrong ${field} did not return a closed refusal`, + ); + } + matrixEffectCount += outcome.effectCount; + denied[operationCase.operation][field] = outcome.kind; + } + } + + const approvalOutcome = await plane({ + ...profileOptions, + approvalTier: "approval:wrong", + }).prepare(placeholderIntent); + if (approvalOutcome.kind !== "generic_denied") { + throw new Error("wrong approval tier did not fail closed"); + } + + const expiredFacts = { + ...exactFacts, + deliveryEvidenceRef: expiredDeliveryEvidenceRef, + }; + const expired = await nominalPlane.prepare(createPlaceholderEffectIntent( + createTrustedPrivateEffectAuthority(expiredFacts), + { + deliveryAttemptRef: `dla_${"3".repeat(32)}`, + idempotencyKey: "turn-67:expired-evidence", + payload: { text: "Working…" }, + }, + )); + if (expired.kind !== "generic_denied" || expired.effectCount !== 0) { + throw new Error("expired evidence did not fail closed"); + } + + const staleSource = await nominalPlane.prepare(createPlaceholderEffectIntent(trusted, { + deliveryAttemptRef: `dla_${"4".repeat(32)}`, + idempotencyKey: "turn-67:stale-source", + payload: { text: "Working…" }, + sourceContext: { sourceRef: sourceId, sourceVersionRef: randomUUID() }, + })); + const activeSource = await nominalPlane.prepare(createPlaceholderEffectIntent(trusted, { + deliveryAttemptRef: `dla_${"5".repeat(32)}`, + idempotencyKey: "turn-67:active-source", + payload: { text: "Working…" }, + sourceContext: { sourceRef: sourceId, sourceVersionRef: sourceVersionId }, + })); + if ( + staleSource.kind !== "generic_denied" + || activeSource.kind !== expectedActiveSource + ) { + throw new Error("source context revalidation is not exact"); + } + + process.stdout.write(JSON.stringify({ + denied, + distinctTicketTypes: types, + effectCount: first.effectCount, + exactRetryIdempotent: retry.idempotent, + expiredEvidence: expired.kind, + matrixEffectCount, + operation: first.operation, + payloadConflicts, + prepared: first.kind, + sourceContext: { active: activeSource.kind, stale: staleSource.kind }, + })); +} finally { + await pool.end(); +} diff --git a/action_plane/typescript/tsconfig.build.json b/action_plane/typescript/tsconfig.build.json new file mode 100644 index 00000000..74a47248 --- /dev/null +++ b/action_plane/typescript/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "dist", + "sourceMap": true + } +} diff --git a/action_plane/typescript/tsconfig.json b/action_plane/typescript/tsconfig.json new file mode 100644 index 00000000..9909f591 --- /dev/null +++ b/action_plane/typescript/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "strict": true, + "target": "ES2023", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/compose.yaml b/compose.yaml index ab384c0c..e02f5193 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,8 @@ services: CONTEXT_ENGINE_IDENTITY_PASSWORD: ${CONTEXT_ENGINE_IDENTITY_PASSWORD:?CONTEXT_ENGINE_IDENTITY_PASSWORD is required} CONTEXT_ENGINE_EGRESS_ROLE: ${CONTEXT_ENGINE_EGRESS_ROLE:?CONTEXT_ENGINE_EGRESS_ROLE is required} CONTEXT_ENGINE_EGRESS_PASSWORD: ${CONTEXT_ENGINE_EGRESS_PASSWORD:?CONTEXT_ENGINE_EGRESS_PASSWORD is required} + CONTEXT_ENGINE_ACTION_ROLE: ${CONTEXT_ENGINE_ACTION_ROLE:?CONTEXT_ENGINE_ACTION_ROLE is required} + CONTEXT_ENGINE_ACTION_PASSWORD: ${CONTEXT_ENGINE_ACTION_PASSWORD:?CONTEXT_ENGINE_ACTION_PASSWORD is required} CONTEXT_ENGINE_RUNTIME_ROLE: ${CONTEXT_ENGINE_RUNTIME_ROLE:?CONTEXT_ENGINE_RUNTIME_ROLE is required} CONTEXT_ENGINE_RUNTIME_PASSWORD: ${CONTEXT_ENGINE_RUNTIME_PASSWORD:?CONTEXT_ENGINE_RUNTIME_PASSWORD is required} CONTEXT_ENGINE_WORKER_ROLE: ${CONTEXT_ENGINE_WORKER_ROLE:?CONTEXT_ENGINE_WORKER_ROLE is required} diff --git a/docs/decisions/0049-prepare-one-exact-private-effect.md b/docs/decisions/0049-prepare-one-exact-private-effect.md new file mode 100644 index 00000000..7279a325 --- /dev/null +++ b/docs/decisions/0049-prepare-one-exact-private-effect.md @@ -0,0 +1,108 @@ +--- +name: adr-0049-prepare-one-exact-private-effect +version: "1.0.0" +description: > + Prepare one operation-specific private ActionTicket through a digest-only, + FORCE-RLS, function-only PostgreSQL authority without invoking Sender. +--- + +# 0049. Prepare one exact private effect before Sender + +- Status: accepted +- Date: 2026-07-23 +- Refines: ADR-0011, ADR-0013, ADR-0015, ADR-0030, ADR-0044, ADR-0045, + ADR-0046 + +## Context + +Issue #18 proved only that synthetic read and action capabilities have distinct +audiences. A private Bot delivery needs a production preparation boundary that +cannot derive effect authority from model output, `action_required`, a +`ContextPackage`, an SDK body, or caller-authored destination/audience values. +It must also make exact retry safe before the external provider is introduced. + +The engine's Python ActionTicket remains the explicitly synthetic Issue #18 +carrier. Reusing or extending it would blur the process boundary and could +false-green production payload, destination, approval, idempotency, and durable +authority claims. + +## Decision + +The co-resident Bot application owns a private TypeScript ActionPlane module. +Its public `prepare(TrustedEffectIntent)` accepts only request-lived nominal +values constructed by trusted in-process orchestration. The package export map +does not expose those constructors, and exported ticket nominal classes require +an internal construction authority at runtime. + +The operation is a closed union of `create_placeholder`, `finalize_reply`, and +`send_private_followup`. Each maps to a distinct ticket audience and type. The +payload digest is SHA-256 over a domain-separated RFC 8785 document containing +the exact operation, `application/json` media type, and closed payload object. +Approval and idempotency are separately domain-bound. + +ActionPlane uses a dedicated `context_engine_action` NOINHERIT/NOBYPASSRLS +login. That login has no table privileges and can execute only +`context_action_prepare_private_effect`. A distinct NOLOGIN definer owns the +function and has only the RLS-gated reference reads plus delivery-attempt and +ticket reads/inserts and restricted-audit inserts needed by prepare; it has no +Action-table update or delete authority. The function fixes +`search_path`, enables row security, uses database-owned time, and atomically +revalidates: + +- exact private DeliveryEvidence digest, authenticated service and binding; +- Organization, User, current Membership/version, destination, consumer, + purpose, private audience digest, and Policy Epoch; +- exact closed operation/audience, payload digest, approval tier, profile, + expiry, signing version, and idempotency digest; +- when supplied, the exact active Source and active SourceVersion. + +Delivery-attempt, ticket, and audit rows are Organization-owned and FORCE-RLS. +They retain only digests for evidence/service/binding/destination/consumer/ +purpose/audience/identity, payload, approval, idempotency, and decisions. The +serialized ticket bearer and payload body are never stored. The active +`action-digest-audit-retention-v1` profile fixes a bounded `retain_until` for +every row. + +Exact retry under one Organization/idempotency digest returns the stored ticket +reference, delivery attempt, issuance, and expiry. Conflicting reuse is a +generic zero-effect denial. Placeholder and finalize can share only an exact +DeliveryAttemptRef and still require separate idempotency and ticket rows. + +`prepare` contains no Sender/provider seam. Issue #67 activates only ticket +preparation. `perform`, ticket consumption, external writes, applied-receipt +replay, ambiguous-attempt reconciliation, group audience, compensation, and +real Feishu remain `NOT_ACTIVE`. + +Public repositories may inform clean-room behavior, interface shapes, and test +oracles. No repository implementation supplies this module or database +boundary; ContextEngine design, ADRs, threat model, and executable evidence +remain authoritative. + +## Rationale + +Keeping the login powerless outside one function makes the PostgreSQL +transaction the atomic authority for current audience, source lifecycle, +idempotency, and durable issuance. Digest-only rows limit retained sensitive +data while preserving exact conflict, audit, and future reconciliation +lineage. Separate operation types prevent a generic action string or create +ticket from acquiring edit/send authority. + +## Consequences + +- Wrong Organization, service, binding, destination, audience, payload, epoch, + operation, approval, evidence lifetime, or source lifecycle produces only a + closed zero-effect outcome. +- An identical prepare does not mint a second logical ticket authority. +- The TypeScript module and database role become M2 production foundations; + the Issue #18 Python carrier remains synthetic and separate. +- Prepared tickets cannot cause an effect until the later `perform` boundary + validates and consumes them. + +## Revisit trigger + +Revisit before adding group delivery, another effect kind or approval tier, +rotating signing profiles, splitting ActionPlane into another process, remote +ticket storage, compensation, or any Sender. Any revision must preserve trusted +intent provenance, closed operation-specific tickets, exact current audience +and source validation, digest-only bearer/payload persistence, function-only +least privilege, generic zero-effect refusal, and atomic idempotency. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 6186fe6f..ae5109d1 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -30,6 +30,7 @@ kernel, capability separation, and publication visibility model. | First worker lease | [0029 — Persistent no-op WorkerLease](0029-bound-first-worker-lease-to-persistent-no-op-jobs.md) | Versioned HMAC-SHA256 exact-job lease permits one atomic persistent no-op completion; the full Supply carrier remains deferred | Ambient worker identity, signature-only authority, in-memory replay defense, or claiming full `ACCEPT-008` PASS | | Read versus effect | [0011 — Read/write plane separation](0011-read-write-plane-separation.md) | `ContextAccessTicket` and `ActionTicket` use different audiences and are non-interchangeable | Using content/read authority to execute an external effect | | First ticket audience split | [0030 — Bound ticket audiences](0030-bound-ticket-audiences.md) | Same identity/key configuration, but distinct signed read/action domains, nominal types, fixed synthetic operations, and target audiences; current epoch is checked before effect | Cross-plane reuse, caller-authored claims/targets, or claiming the synthetic no-op as production Provider or ActionPlane | +| Private effect preparation | [0049 — Exact private ActionPlane prepare](0049-prepare-one-exact-private-effect.md) | Trusted TypeScript intent, operation-specific ticket, and function-only digest/RLS persistence revalidate one private effect with atomic idempotency before Sender | Model/wire-derived intent, generic operation tickets, application-role table access, bearer/payload persistence, or claiming prepare as perform | | Durable decision lineage | [0031 — Authorized-only ContextRun lineage](0031-persist-authorized-context-run-lineage.md) | Final digest-only ContextRun commits in the retained current-UserActor transaction; empty delivery adds a seven-field restricted DecisionAudit; one-use application authorization obtains an exact Organization-and-decision database ticket whose consumption the supported reader commits | Raw query/Package retention, denied identifiers or counts, post-response persistence, role/GUC-only operator access, cross-binding or post-expiry replay, claiming durable exactly-once redemption across a direct database rollback, or claiming complete observability redaction | | Membership field projection | [0032 — Membership-bound materialized fields](0032-bind-materialized-fields-to-membership-projection-rights.md) | Exact Resource authorization is monotonically narrowed by current Membership/version field rights before field values leave PostgreSQL; projected fields bind Evidence and Package integrity | Candidate/request-authored fields, missing-as-all, Python-side filtering of private values, or implicit access to legacy bodies | | Release promotion owner | [0033 — Organization release promotion owner](0033-promote-organization-releases-through-one-learning-owner.md) | Organization-owned immutable release lineage advances only through one generation-bound, release-operator-authorized `ContextLearning.promote` transaction | Pointer seeds, direct application DML, manifest-only CAS, evaluator/Control/Curation publication, or rollback mutation | @@ -123,6 +124,7 @@ touched: - [0028 — Fail-closed unavailable Runtime capabilities](0028-fail-closed-unavailable-runtime-capabilities.md) - [0029 — Persistent no-op WorkerLease](0029-bound-first-worker-lease-to-persistent-no-op-jobs.md) - [0030 — Bound ticket audiences](0030-bound-ticket-audiences.md) +- [0049 — Exact private ActionPlane prepare](0049-prepare-one-exact-private-effect.md) - [0031 — Authorized-only ContextRun lineage](0031-persist-authorized-context-run-lineage.md) - [0032 — Membership-bound materialized fields](0032-bind-materialized-fields-to-membership-projection-rights.md) - [0033 — Organization release promotion owner](0033-promote-organization-releases-through-one-learning-owner.md) diff --git a/engine/persistence/__init__.py b/engine/persistence/__init__.py index e47ce6de..911e723c 100644 --- a/engine/persistence/__init__.py +++ b/engine/persistence/__init__.py @@ -8,6 +8,8 @@ ResourceAccessRevocation, ) from engine.persistence.configuration import ( + ACTION_PREPARE_DEFINER_ROLE, + ACTION_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, EGRESS_GRANT_DEFINER_ROLE, EGRESS_ROLE, @@ -58,6 +60,7 @@ ) from engine.persistence.releases import PostgreSQLReleaseStore from engine.persistence.role_guard import ( + assert_action_role, assert_control_role, assert_egress_role, assert_identity_role, @@ -87,6 +90,8 @@ __all__ = [ "DatabaseConfiguration", + "ACTION_PREPARE_DEFINER_ROLE", + "ACTION_ROLE", "AccessChangeRejected", "AccessPolicyControlUnavailable", "DatabaseConfigurationError", @@ -142,6 +147,7 @@ "DEFAULT_WORKER_LEASE_TTL_SECONDS", "MAX_WORKER_LEASE_TTL_SECONDS", "assert_runtime_role", + "assert_action_role", "assert_learning_role", "assert_identity_role", "assert_egress_role", diff --git a/engine/persistence/configuration.py b/engine/persistence/configuration.py index dab55756..0963babc 100644 --- a/engine/persistence/configuration.py +++ b/engine/persistence/configuration.py @@ -14,6 +14,8 @@ CONTROL_ROLE = "context_engine_control" IDENTITY_ROLE = "context_engine_identity" EGRESS_ROLE = "context_engine_egress" +ACTION_ROLE = "context_engine_action" +ACTION_PREPARE_DEFINER_ROLE = "context_engine_action_prepare_definer" EGRESS_GRANT_DEFINER_ROLE = "context_engine_egress_grant_definer" DELIVERY_EVIDENCE_DEFINER_ROLE = "context_engine_delivery_evidence_definer" ACCESS_POLICY_DEFINER_ROLE = "context_engine_access_policy_definer" @@ -33,6 +35,7 @@ class DatabasePurpose(Enum): CONTROL_PLANE = ("CONTEXT_ENGINE_CONTROL_DATABASE_URL", CONTROL_ROLE) TRUSTED_IDENTITY = ("CONTEXT_ENGINE_IDENTITY_DATABASE_URL", IDENTITY_ROLE) TRUSTED_EGRESS = ("CONTEXT_ENGINE_EGRESS_DATABASE_URL", EGRESS_ROLE) + TRUSTED_ACTION = ("CONTEXT_ENGINE_ACTION_DATABASE_URL", ACTION_ROLE) API_RUNTIME = ("CONTEXT_ENGINE_RUNTIME_DATABASE_URL", RUNTIME_ROLE) SUPPLY_WORKER = ("CONTEXT_ENGINE_WORKER_DATABASE_URL", WORKER_ROLE) LEARNING = ("CONTEXT_ENGINE_LEARNING_DATABASE_URL", LEARNING_ROLE) @@ -56,6 +59,7 @@ def expected_role(self) -> str: DatabasePurpose.CONTROL_PLANE: "CONTEXT_ENGINE_CONTROL_ROLE", DatabasePurpose.TRUSTED_IDENTITY: "CONTEXT_ENGINE_IDENTITY_ROLE", DatabasePurpose.TRUSTED_EGRESS: "CONTEXT_ENGINE_EGRESS_ROLE", + DatabasePurpose.TRUSTED_ACTION: "CONTEXT_ENGINE_ACTION_ROLE", DatabasePurpose.API_RUNTIME: "CONTEXT_ENGINE_RUNTIME_ROLE", DatabasePurpose.SUPPLY_WORKER: "CONTEXT_ENGINE_WORKER_ROLE", DatabasePurpose.LEARNING: "CONTEXT_ENGINE_LEARNING_ROLE", @@ -119,6 +123,7 @@ class HarnessDatabaseConfigurations: control: DatabaseConfiguration identity: DatabaseConfiguration egress: DatabaseConfiguration + action: DatabaseConfiguration runtime: DatabaseConfiguration worker: DatabaseConfiguration learning: DatabaseConfiguration @@ -186,7 +191,7 @@ def load_database_configuration( def load_harness_database_configurations( environment: Mapping[str, str] | None = None, ) -> HarnessDatabaseConfigurations: - """Load and cross-check the seven explicit harness credential contracts.""" + """Load and cross-check the explicit harness credential contracts.""" source = os.environ if environment is None else environment configurations = HarnessDatabaseConfigurations( @@ -194,6 +199,7 @@ def load_harness_database_configurations( control=load_database_configuration(DatabasePurpose.CONTROL_PLANE, source), identity=load_database_configuration(DatabasePurpose.TRUSTED_IDENTITY, source), egress=load_database_configuration(DatabasePurpose.TRUSTED_EGRESS, source), + action=load_database_configuration(DatabasePurpose.TRUSTED_ACTION, source), runtime=load_database_configuration(DatabasePurpose.API_RUNTIME, source), worker=load_database_configuration(DatabasePurpose.SUPPLY_WORKER, source), learning=load_database_configuration(DatabasePurpose.LEARNING, source), @@ -207,15 +213,16 @@ def load_harness_database_configurations( configurations.control.expected_role, configurations.identity.expected_role, configurations.egress.expected_role, + configurations.action.expected_role, configurations.runtime.expected_role, configurations.worker.expected_role, configurations.learning.expected_role, configurations.operator.expected_role, } - if len(distinct_roles) != 8: + if len(distinct_roles) != 9: raise DatabaseConfigurationError( - "migration, control, identity, egress, runtime, worker, learning, and " - "security-operator " + "migration, control, identity, egress, action, runtime, worker, " + "learning, and security-operator " "database roles must be distinct" ) if configurations.security_test.url != configurations.runtime.url: diff --git a/engine/persistence/role_guard.py b/engine/persistence/role_guard.py index 92f3ca86..f9bd06fe 100644 --- a/engine/persistence/role_guard.py +++ b/engine/persistence/role_guard.py @@ -5,6 +5,7 @@ from sqlalchemy import Connection, text from engine.persistence.configuration import ( + ACTION_ROLE, CONTROL_ROLE, EGRESS_ROLE, IDENTITY_ROLE, @@ -118,6 +119,13 @@ def assert_egress_role(connection: Connection) -> None: _assert_no_owned_objects_or_role_members(connection) +def assert_action_role(connection: Connection) -> None: + """Require the dedicated trusted ActionPlane database login.""" + + _assert_non_owner_role(connection, ACTION_ROLE) + _assert_no_owned_objects_or_role_members(connection) + + def assert_runtime_role(connection: Connection) -> None: """Reject owner, superuser, BYPASSRLS, inheriting, or CREATE-capable sessions.""" diff --git a/engine/persistence/schema_security_manifest.yaml b/engine/persistence/schema_security_manifest.yaml index 9ad1c00d..eed1b2df 100644 --- a/engine/persistence/schema_security_manifest.yaml +++ b/engine/persistence/schema_security_manifest.yaml @@ -1,5 +1,5 @@ { - "manifestVersion": "20.0.0", + "manifestVersion": "21.0.0", "controlOperations": [ { "name": "register_file_source", @@ -550,6 +550,14 @@ "context_engine_egress_grant_definer" ], "using": "true" + }, + { + "name": "membership_action_prepare_definer_select", + "command": "SELECT", + "roles": [ + "context_engine_action_prepare_definer" + ], + "using": "true" } ] }, @@ -566,6 +574,9 @@ ], "context_engine_egress_grant_definer": [ "SELECT" + ], + "context_engine_action_prepare_definer": [ + "SELECT" ] }, "partitions": [], @@ -715,6 +726,14 @@ ], "using": "true", "withCheck": "true" + }, + { + "name": "delivery_evidence_action_prepare_definer_select", + "command": "SELECT", + "roles": [ + "context_engine_action_prepare_definer" + ], + "using": "true" } ] }, @@ -747,6 +766,9 @@ "UPDATE", "DELETE" ], + "context_engine_action_prepare_definer": [ + "SELECT" + ], "context_engine_control": [], "context_engine_worker": [], "context_engine_learning": [], @@ -1059,6 +1081,14 @@ ], "using": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid", "withCheck": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid" + }, + { + "name": "context_source_action_prepare_definer_select", + "command": "SELECT", + "roles": [ + "context_engine_action_prepare_definer" + ], + "using": "true" } ] }, @@ -1079,6 +1109,9 @@ "context_engine_worker_lease_definer": [ "SELECT", "UPDATE" + ], + "context_engine_action_prepare_definer": [ + "SELECT" ] }, "partitions": [], @@ -1194,6 +1227,14 @@ "context_engine_worker_lease_definer" ], "withCheck": "organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid" + }, + { + "name": "source_version_action_prepare_definer_select", + "command": "SELECT", + "roles": [ + "context_engine_action_prepare_definer" + ], + "using": "true" } ] }, @@ -1218,6 +1259,9 @@ "context_engine_worker_lease_definer": [ "SELECT", "INSERT" + ], + "context_engine_action_prepare_definer": [ + "SELECT" ] }, "partitions": [], @@ -1615,6 +1659,14 @@ "context_engine_egress_grant_definer" ], "using": "true" + }, + { + "name": "organization_policy_epoch_action_prepare_definer_select", + "command": "SELECT", + "roles": [ + "context_engine_action_prepare_definer" + ], + "using": "true" } ] }, @@ -1634,6 +1686,9 @@ "context_engine_egress_grant_definer": [ "SELECT" ], + "context_engine_action_prepare_definer": [ + "SELECT" + ], "context_engine_runtime": [ "SELECT" ], @@ -2601,6 +2656,140 @@ "OBS-005" ] }, + { + "name": "action_delivery_attempt", + "classification": "tenant_owned", + "nonOwnerEvidence": { + "evidenceId": "PG-ACTION-PREPARE-067", + "selector": {"table": "action_delivery_attempt"} + }, + "purpose": "Digest-only private delivery identity shared by operation-specific ActionTickets", + "organizationColumn": "organization_id", + "organizationInclusiveKeys": [ + { + "name": "pk_action_delivery_attempt", + "kind": "primary_key", + "columns": ["organization_id", "delivery_attempt_ref"] + } + ], + "capabilityUniqueKeys": [ + { + "name": "uq_action_delivery_attempt_ref_global", + "kind": "unique", + "columns": ["delivery_attempt_ref"], + "rationale": "one opaque delivery-attempt reference identifies at most one Organization-owned identity chain" + } + ], + "foreignKeys": [ + { + "name": "fk_action_delivery_attempt_organization", + "columns": ["organization_id"], + "references": {"table": "organization", "columns": ["organization_id"]}, + "onDelete": "RESTRICT" + }, + { + "name": "fk_action_delivery_attempt_membership_version", + "columns": ["organization_id", "membership_id", "membership_version"], + "references": { + "table": "membership", + "columns": ["organization_id", "membership_id", "membership_version"] + } + } + ], + "checkConstraints": [ + {"name": "ck_action_delivery_attempt_ref", "expression": "delivery_attempt_ref is one bounded dla_ opaque reference"}, + {"name": "ck_action_delivery_attempt_sha256_digests", "expression": "service, evidence, authentication, destination, consumer, purpose, audience, and identity bindings are 32-byte SHA-256 digests"}, + {"name": "ck_action_delivery_attempt_positive_versions", "expression": "membership_version > 0 AND policy_epoch > 0"}, + {"name": "ck_action_delivery_attempt_profiles", "expression": "prepare and retention profiles are exact active versions"}, + {"name": "ck_action_delivery_attempt_retention_window", "expression": "retain_until > created_at"} + ], + "rowLevelSecurity": { + "enabled": true, + "forced": true, + "policies": [ + {"name": "action_delivery_attempt_migrator_administration", "command": "ALL", "roles": ["context_engine_migrator"], "using": "true", "withCheck": "true"}, + {"name": "action_delivery_attempt_action_prepare_definer_select", "command": "SELECT", "roles": ["context_engine_action_prepare_definer"], "using": "true"}, + {"name": "action_delivery_attempt_action_prepare_definer_insert", "command": "INSERT", "roles": ["context_engine_action_prepare_definer"], "withCheck": "true"} + ] + }, + "functionOnlyMutation": { + "databaseFunction": "context_action_prepare_private_effect", + "role": "context_engine_action", + "definerRole": "context_engine_action_prepare_definer", + "directTableMutationAllowed": false + }, + "retention": {"class": "restricted_digest_audit", "bearerStored": false, "payloadStored": false, "policy": "action-digest-audit-retention-v1"}, + "permittedOperations": { + "context_engine_action": ["EXECUTE context_action_prepare_private_effect"], + "context_engine_action_prepare_definer": ["SELECT", "INSERT"], + "context_engine_control": [], "context_engine_identity": [], "context_engine_egress": [], "context_engine_runtime": [], "context_engine_worker": [], "context_engine_learning": [], "context_engine_security_operator": [] + }, + "partitions": [], + "securityInvariantIds": ["TENANT-OWNERSHIP-001", "TENANT-FK-002", "RLS-FAIL-CLOSED-003", "REVOCATION-006", "NON-ENUMERATION-009", "TRACE-REDACTION-012", "ACTION-SEPARATION-014"], + "negativeTestIds": ["DB-001", "DB-002", "DB-004", "DB-008", "DB-009", "DB-010", "OBS-004", "OBS-005", "ACTION-001", "ACTION-004"] + }, + { + "name": "action_ticket", + "classification": "tenant_owned", + "nonOwnerEvidence": {"evidenceId": "PG-ACTION-PREPARE-067", "selector": {"table": "action_ticket"}}, + "purpose": "Operation-specific one-shot ActionTicket state with exact payload, audience, approval, epoch, expiry, source, and idempotency bindings", + "organizationColumn": "organization_id", + "organizationInclusiveKeys": [ + {"name": "pk_action_ticket", "kind": "primary_key", "columns": ["organization_id", "ticket_ref"]}, + {"name": "uq_action_ticket_prepare_idempotency", "kind": "unique", "columns": ["organization_id", "idempotency_digest"]} + ], + "capabilityUniqueKeys": [ + {"name": "uq_action_ticket_ref_global", "kind": "unique", "columns": ["ticket_ref"], "rationale": "one opaque ticket reference identifies at most one Organization-owned effect authority"} + ], + "foreignKeys": [ + {"name": "fk_action_ticket_delivery_attempt", "columns": ["organization_id", "delivery_attempt_ref"], "references": {"table": "action_delivery_attempt", "columns": ["organization_id", "delivery_attempt_ref"]}, "onDelete": "RESTRICT"}, + {"name": "fk_action_ticket_source_version", "columns": ["organization_id", "source_id", "source_version_id"], "references": {"table": "source_version", "columns": ["organization_id", "source_id", "version_id"]}, "onDelete": "RESTRICT"} + ], + "checkConstraints": [ + {"name": "ck_action_ticket_ref", "expression": "ticket_ref is one bounded act_ opaque reference"}, + {"name": "ck_action_ticket_sha256_digests", "expression": "payload, idempotency, and approval bindings are 32-byte SHA-256 digests"}, + {"name": "ck_action_ticket_operation_audience", "expression": "create_placeholder, finalize_reply, and send_private_followup each have one distinct closed ticket audience"}, + {"name": "ck_action_ticket_source_context_pair", "expression": "source and source-version context are both absent or both present"}, + {"name": "ck_action_ticket_profiles", "expression": "approval, prepare, state, and retention profiles are exact active versions"}, + {"name": "ck_action_ticket_positive_versions", "expression": "policy_epoch and signing_key_version are positive"}, + {"name": "ck_action_ticket_time_windows", "expression": "ticket lifetime is positive and at most five minutes with retained digest state after expiry"} + ], + "rowLevelSecurity": {"enabled": true, "forced": true, "policies": [ + {"name": "action_ticket_migrator_administration", "command": "ALL", "roles": ["context_engine_migrator"], "using": "true", "withCheck": "true"}, + {"name": "action_ticket_action_prepare_definer_select", "command": "SELECT", "roles": ["context_engine_action_prepare_definer"], "using": "true"}, + {"name": "action_ticket_action_prepare_definer_insert", "command": "INSERT", "roles": ["context_engine_action_prepare_definer"], "withCheck": "true"} + ]}, + "functionOnlyMutation": {"databaseFunction": "context_action_prepare_private_effect", "role": "context_engine_action", "definerRole": "context_engine_action_prepare_definer", "directTableMutationAllowed": false}, + "retention": {"class": "restricted_digest_audit", "bearerStored": false, "payloadStored": false, "policy": "action-digest-audit-retention-v1"}, + "permittedOperations": {"context_engine_action": ["EXECUTE context_action_prepare_private_effect"], "context_engine_action_prepare_definer": ["SELECT", "INSERT"], "context_engine_control": [], "context_engine_identity": [], "context_engine_egress": [], "context_engine_runtime": [], "context_engine_worker": [], "context_engine_learning": [], "context_engine_security_operator": []}, + "partitions": [], + "securityInvariantIds": ["TENANT-OWNERSHIP-001", "TENANT-FK-002", "RLS-FAIL-CLOSED-003", "REVOCATION-006", "NON-ENUMERATION-009", "TRACE-REDACTION-012", "ACTION-SEPARATION-014"], + "negativeTestIds": ["DB-001", "DB-002", "DB-004", "DB-008", "DB-009", "DB-010", "OBS-004", "OBS-005", "ACTION-001", "ACTION-004"] + }, + { + "name": "action_prepare_audit", + "classification": "tenant_owned", + "nonOwnerEvidence": {"evidenceId": "PG-ACTION-PREPARE-067", "selector": {"table": "action_prepare_audit"}}, + "purpose": "Restricted digest-only ActionPlane preparation decisions without denied content", + "organizationColumn": "organization_id", + "organizationInclusiveKeys": [{"name": "pk_action_prepare_audit", "kind": "primary_key", "columns": ["organization_id", "audit_id"]}], + "foreignKeys": [{"name": "fk_action_prepare_audit_organization", "columns": ["organization_id"], "references": {"table": "organization", "columns": ["organization_id"]}, "onDelete": "RESTRICT"}], + "checkConstraints": [ + {"name": "ck_action_prepare_audit_decision_digest", "expression": "decision_digest is 32-byte SHA-256"}, + {"name": "ck_action_prepare_audit_category", "expression": "category is prepared, idempotent, generic_denied, or audience_changed"}, + {"name": "ck_action_prepare_audit_retention", "expression": "retention profile is exact and retain_until is after recorded_at"} + ], + "rowLevelSecurity": {"enabled": true, "forced": true, "policies": [ + {"name": "action_prepare_audit_migrator_administration", "command": "ALL", "roles": ["context_engine_migrator"], "using": "true", "withCheck": "true"}, + {"name": "action_prepare_audit_action_prepare_definer_insert", "command": "INSERT", "roles": ["context_engine_action_prepare_definer"], "withCheck": "true"} + ]}, + "functionOnlyMutation": {"databaseFunction": "context_action_prepare_private_effect", "role": "context_engine_action", "definerRole": "context_engine_action_prepare_definer", "directTableMutationAllowed": false}, + "retention": {"class": "restricted_digest_audit", "bearerStored": false, "payloadStored": false, "deniedContentStored": false, "policy": "action-digest-audit-retention-v1"}, + "permittedOperations": {"context_engine_action": ["EXECUTE context_action_prepare_private_effect"], "context_engine_action_prepare_definer": ["INSERT"], "context_engine_control": [], "context_engine_identity": [], "context_engine_egress": [], "context_engine_runtime": [], "context_engine_worker": [], "context_engine_learning": [], "context_engine_security_operator": []}, + "partitions": [], + "securityInvariantIds": ["TENANT-OWNERSHIP-001", "TENANT-FK-002", "RLS-FAIL-CLOSED-003", "NON-ENUMERATION-009", "TRACE-REDACTION-012", "ACTION-SEPARATION-014"], + "negativeTestIds": ["DB-001", "DB-002", "DB-004", "DB-008", "DB-009", "DB-010", "OBS-004", "OBS-005", "ACTION-001", "ACTION-004"] + }, { "name": "service_principal", "classification": "tenant_owned", diff --git a/eval/catalogs/m0-security-evidence.yaml b/eval/catalogs/m0-security-evidence.yaml index 7cb8c665..b7990abb 100644 --- a/eval/catalogs/m0-security-evidence.yaml +++ b/eval/catalogs/m0-security-evidence.yaml @@ -95,6 +95,7 @@ {"id": "RUNTIME-TRACE-REDACTION-012", "layer": "runtime", "selector": "tests/unit/test_runtime_authorized_evidence.py::test_empty_decision_audit_is_generic_and_retains_no_denied_detail"}, {"id": "PROP-ACTION-SEPARATION-014", "layer": "property", "selector": "tests/unit/test_ticket_audience_separation.py::test_public_ticket_types_and_server_side_issuers_are_structurally_distinct"}, {"id": "PG-ACTION-SEPARATION-014", "layer": "postgres", "selector": "tests/integration/test_ticket_policy_epoch.py::test_committed_epoch_bump_rejects_both_previously_valid_ticket_types"}, + {"id": "PG-ACTION-PREPARE-067", "layer": "postgres", "selector": "tests/integration/test_action_prepare.py::test_private_prepare_is_exact_idempotent_digest_only_and_restricted"}, {"id": "RUNTIME-ACTION-SEPARATION-014", "layer": "runtime", "selector": "tests/unit/test_ticket_audience_separation.py::test_each_ticket_is_rejected_by_the_other_plane_and_deserializer"}, {"id": "PROP-CROSS-ORG-LEARN-015", "layer": "property", "selector": "tests/unit/test_m0_learning_isolation.py::test_m0_learning_artifact_contract_has_no_cross_organization_carrier"}, {"id": "PG-CROSS-ORG-LEARN-015", "layer": "postgres", "selector": "tests/integration/test_m0_unavailable_security_carriers.py::test_learning_persistence_is_organization_bound"}, @@ -128,7 +129,7 @@ {"invariantRef": "CITATION-AUTH-010", "evidenceRefs": {"property": ["PROP-CITATION-AUTH-010"], "postgres": ["PG-CITATION-AUTH-010"], "runtime": ["RUNTIME-CITATION-AUTH-010"]}}, {"invariantRef": "EGRESS-011", "evidenceRefs": {"property": ["PROP-EGRESS-011"], "postgres": ["PG-EGRESS-011"], "runtime": ["RUNTIME-EGRESS-011"]}}, {"invariantRef": "TRACE-REDACTION-012", "evidenceRefs": {"property": ["PROP-TRACE-REDACTION-012"], "postgres": ["PG-TRACE-REDACTION-012"], "runtime": ["RUNTIME-TRACE-REDACTION-012"]}}, - {"invariantRef": "ACTION-SEPARATION-014", "evidenceRefs": {"property": ["PROP-ACTION-SEPARATION-014"], "postgres": ["PG-ACTION-SEPARATION-014"], "runtime": ["RUNTIME-ACTION-SEPARATION-014"]}}, + {"invariantRef": "ACTION-SEPARATION-014", "evidenceRefs": {"property": ["PROP-ACTION-SEPARATION-014"], "postgres": ["PG-ACTION-SEPARATION-014", "PG-ACTION-PREPARE-067"], "runtime": ["RUNTIME-ACTION-SEPARATION-014"]}}, {"invariantRef": "CROSS-ORG-LEARN-015", "evidenceRefs": {"property": ["PROP-CROSS-ORG-LEARN-015"], "postgres": ["PG-CROSS-ORG-LEARN-015"], "runtime": ["RUNTIME-CROSS-ORG-LEARN-015"]}}, {"invariantRef": "RELEASE-OWNER-019", "evidenceRefs": {"property": ["PROP-RELEASE-OWNER-019"], "postgres": ["PG-RELEASE-OWNER-019"], "runtime": ["RUNTIME-RELEASE-OWNER-019"]}} ], diff --git a/eval/catalogs/security-catalog.schema.json b/eval/catalogs/security-catalog.schema.json index b190b6a3..e3161142 100644 --- a/eval/catalogs/security-catalog.schema.json +++ b/eval/catalogs/security-catalog.schema.json @@ -95,8 +95,8 @@ }, "activations": { "type": "array", - "minItems": 10, - "maxItems": 10, + "minItems": 11, + "maxItems": 11, "uniqueItems": true, "prefixItems": [ { @@ -567,6 +567,44 @@ "external effects" ] } + }, + { + "const": { + "issueRef": "#67", + "invariantRef": "ACTION-SEPARATION-014", + "carrier": "ActionPlane.prepare private create-placeholder, finalize-reply, or follow-up ticket", + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": "trusted co-resident intent -> closed TypeScript ActionPlane.prepare -> least-privilege PostgreSQL prepare function -> operation-specific audience-bound ActionTicket", + "testEvidence": [ + { + "id": "PG-ACTION-PREPARE-067", + "surface": "tests/integration/test_action_prepare.py::test_private_prepare_is_exact_idempotent_digest_only_and_restricted", + "oracle": "A packaged TypeScript ActionPlane calling real PostgreSQL under the dedicated non-owner action role prepares distinct signed CreatePlaceholder, FinalizeReply, and PrivateFollowup tickets only for one exact current private delivery authority; identical retry returns the same durable ticket, while every one-field binding mutation, approval mismatch, expired evidence, stale or disabled source, and conflicting idempotency use returns only a closed zero-effect outcome. FORCE-RLS storage retains digests and restricted decision categories, and the caller has no direct table access or Sender surface." + } + ], + "deferredEvidence": [ + "ActionPlane.perform one-shot ticket redemption", + "provider receipt and ambiguous-result reconciliation", + "production Sender conformance", + "group AudienceSnapshot revalidation" + ], + "futureCarriers": [ + "ActionPlane.perform", + "Sender", + "BotDelivery application", + "group-public delivery", + "provider receipt reconciliation" + ], + "notActive": [ + "external channel write or business effect", + "ActionTicket consumption", + "Sender or provider network call", + "Applied, AlreadyApplied, or ReconciliationRequired outcome", + "group AudienceSnapshot", + "full ACCEPT-012 pass" + ] + } } ], "items": false @@ -843,7 +881,8 @@ "HTTP-V0-066", "PG-RUNTIME-RELEASE-066", "SDK-CONTRACT-064", - "SDK-LIVE-FILE-064" + "SDK-LIVE-FILE-064", + "PG-ACTION-PREPARE-067" ] }, "surface": { @@ -881,7 +920,8 @@ "#63", "#65", "#66", - "#64" + "#64", + "#67" ] }, "invariantRef": { @@ -907,7 +947,8 @@ "private authenticated HTTP Acquire DeliveryEvidenceRef", "opaque one-shot model or channel EgressGrant with deterministic boundary spies", "frozen public POST /v0/resolve OpenAPI contract", - "packaged generated TypeScript POST /v0/resolve client" + "packaged generated TypeScript POST /v0/resolve client", + "ActionPlane.prepare private create-placeholder, finalize-reply, or follow-up ticket" ] }, "status": { @@ -930,12 +971,13 @@ "PrivateDeliveryEvidenceIssuer -> PostgreSQL identity function -> authenticated HTTP metadata -> current UserActor transaction redemption -> TrustedDeliveryContext -> sealed ContextRuntime.resolve(Acquire)", "ContextPackage -> final EgressGate -> digest-only PostgreSQL grant -> AuthorizedModelInput | AuthorizedChannelPayload -> exact one-shot redemption -> ModelGateway | Sender preflight spy", "authenticated HTTP metadata -> closed ResolveWire -> current UserActor and active release observation -> sealed ContextRuntime.resolve -> closed ResolutionOutcome", - "immutable OpenAPI v0 -> pinned generator -> generated semantic types and internal fetch client -> closed package exports and metadata-safe facade -> authenticated HTTP ingress -> sealed ContextRuntime.resolve" + "immutable OpenAPI v0 -> pinned generator -> generated semantic types and internal fetch client -> closed package exports and metadata-safe facade -> authenticated HTTP ingress -> sealed ContextRuntime.resolve", + "trusted co-resident intent -> closed TypeScript ActionPlane.prepare -> least-privilege PostgreSQL prepare function -> operation-specific audience-bound ActionTicket" ] }, "testEvidence": { "type": "array", - "minItems": 2, + "minItems": 1, "maxItems": 4, "uniqueItems": true, "items": { diff --git a/eval/catalogs/security-invariants.yaml b/eval/catalogs/security-invariants.yaml index a0341604..1ec6e44e 100644 --- a/eval/catalogs/security-invariants.yaml +++ b/eval/catalogs/security-invariants.yaml @@ -13,7 +13,8 @@ "#63", "#65", "#66", - "#64" + "#64", + "#67" ], "documentRefs": [ "README.md", @@ -31,9 +32,10 @@ "docs/decisions/0045-redeem-private-delivery-evidence-at-ingress.md", "docs/decisions/0046-bind-egress-to-one-exact-package-hop.md", "docs/decisions/0047-freeze-openapi-v0-through-one-runtime-path.md", - "docs/decisions/0048-generate-typescript-sdk-behind-a-closed-facade.md" + "docs/decisions/0048-generate-typescript-sdk-behind-a-closed-facade.md", + "docs/decisions/0049-prepare-one-exact-private-effect.md" ], - "reconciliation": "Issue #2 fixes the product and testing decisions, issue #5 requires exactly fifteen release invariants and twelve canonical acceptance fixtures, and ADR-0019 resolves the later nineteen-label prose expansion without weakening any safeguard. Issue #15 activates only Organization-level next-request resolve(Acquire) revocation evidence under REVOCATION-006: PG-REVOCATION-006, RUN-006, and CACHE-002 are active while BLOB-002 and Continue, citation, Policy-Epoch-bound WorkerLease, production ContextAccessTicket/ActionTicket, audit, outbox, cleanup, finer-epoch, UI, and external-admin carriers remain future or NOT_ACTIVE. Issue #16 activates only the M0 refusal gate for unavailable Continue, OpenCitation, and server-owned unavailable Acquire plans: its real continuation, citation, federated/source-native, and File carriers remain future, while its Runtime and HTTP refusal surfaces prove generic outcomes before content I/O. Issue #17 activates only the signed one-shot persistent no-op durable-job WorkerLease subcarrier under WORKER-LEASE-007. It binds one exact worker audience but no end-user delivery audience or Policy Epoch, and proves only LEASE-SIGNING-017, PG-WORKER-LEASE-NOOP-017, and WORKER-LEASE-REPLAY-007; Source, Resource, Revision, Policy Epoch, end-user delivery audience, idempotency, generation, business mutation, outbox, File publication, and the full ACCEPT-008 matrix remain deferred or NOT_ACTIVE. Issue #18 activates only distinct signed synthetic ContextAccessTicket Provider-read and ActionTicket no-op channel-action subcarriers under ACTION-SEPARATION-014, with current Organization-v0 Policy Epoch validation. TICKET-AUDIENCE-018 and PG-TICKET-EPOCH-018 do not activate production ContextProvider integration, ContextRuntime ticket integration, BotDelivery, full M2 ActionPlane.prepare/perform, a real Sender or external effect, payload/destination/approval/idempotency binding, durable one-shot/replay/reconciliation, or full ACCEPT-012 PASS; those remain future or NOT_ACTIVE. Issue #19 activates only the current Acquire authorized-only ContextRun and restricted delivered-empty DecisionAudit subcarrier under TRACE-REDACTION-012. DIGEST-019, RUN-LINEAGE-019, AUTHORIZED-RUN-019, and PG-TRACE-REDACTION-012 prove deterministic Package and Organization-bound query digests, retained-UserActor-transaction persistence, decisionRef resolution, redaction, and short-lived exact-Organization operator ticket reads with no application-role table access; the supported reader commits deletion before returning, while a direct caller rollback is not claimed as durable exactly-once redemption. Raw query retention, full ContextPackage body retention, unauthenticated transport failures as ContextRuns, cross-Organization analytics, and general observability redaction remain NOT_ACTIVE. Issue #48 activates only the current ACCEPT-002 authenticated HTTP Acquire Membership field-projection carrier under SCOPE-INTERSECTION-004, INDEX-NOT-AUTHORITY-005, and TRACE-REDACTION-012. PROP-FIELD-PROJECTION-048, PG-FIELD-PROJECTION-048, and HTTP-ACCEPT-002-048 bind one current Membership/version field ceiling to same-transaction FORCE-RLS reduction, the sealed AuthorizationKernel, AuthorizedProjection and Evidence integrity, and authorized-only ContextRun/audit persistence. General permission DSLs, caller-authored projection lists, CandidateRef or index field authority, production Provider/source-native ACL negotiation, Supply publication, File/Base field ACL, typed fields, Continue/OpenCitation, and Issue #20 runner substitution remain future or NOT_ACTIVE. Issue #63 activates only the digest-only private authenticated HTTP Acquire DeliveryEvidenceRef carrier under TRANSPORT-UNTRUSTED-008. PROP-DELIVERY-EVIDENCE-063, PG-DELIVERY-EVIDENCE-063, HTTP-DELIVERY-EVIDENCE-063, and FILE-DELIVERY-EVIDENCE-063 prove exact service/request/Organization/asker/Membership-version/destination/consumer/purpose/audience/epoch/lifetime binding, stable identical retry identity, role isolation, expiry cleanup, pre-content generic rejection, and one File-backed sealed Runtime delivery. Group AudienceSnapshot, group/public DeliveryEvidenceRef, OpenCitation, production ModelGateway, ActionPlane, the BotDelivery application, frozen OpenAPI compatibility, and the generated TypeScript SDK remain future or NOT_ACTIVE. Issue #65 activates only one opaque digest-only model or channel EgressGrant after final Package policy, exact atomic PostgreSQL redemption and restricted audit, nominal BotDelivery inputs, and deterministic network-free ModelGateway or Sender-preflight spies under EGRESS-011. PROP-EGRESS-011, PG-EGRESS-011, and RUNTIME-EGRESS-011 prove exact Package/Organization/purpose/audience/epoch/hop/profile/lifetime binding and zero additional bytes on replay. Real model/provider calls, a real Sender or channel write, ActionTicket effects, group AudienceSnapshot revalidation, the BotDelivery application process, and a generated SDK consumer remain future or NOT_ACTIVE. Issue #66 activates the frozen public POST /v0/resolve OpenAPI carrier under TRANSPORT-UNTRUSTED-008. OPENAPI-CONTRACT-066, OPENAPI-BREAKING-066, HTTP-V0-066, and PG-RUNTIME-RELEASE-066 prove one public closed operation, deterministic immutable snapshot and breaking-change refusal, a hidden v1 bridge through the same handler and sealed Runtime path, and exact read-only observation of the active Learning-promoted release with fail-closed missing-release behavior before content work. Generated TypeScript SDK conformance, a production BotDelivery caller, Continue and OpenCitation redemption, MCP, group AudienceSnapshot, and external effects remain future or NOT_ACTIVE. Issue #64 activates only the packaged generated TypeScript POST /v0/resolve client under TRANSPORT-UNTRUSTED-008. SDK-CONTRACT-064 and SDK-LIVE-FILE-064 prove deterministic pinned generation, strict closed types, a narrow export map and metadata-only facade, installable tarball consumption, and one real PostgreSQL/File-backed Acquire through CandidateRef, AuthorizationKernel, AuthorizedProjection, ContextPackage, and opaque model egress grant; generated Continue and OpenCitation calls remain generic unavailable outcomes. External package publication, production BotDelivery, MCP, group AudienceSnapshot, real Continue/OpenCitation redemption, and external effects remain future or NOT_ACTIVE. The canonical set is IDs 001 through 012, 014, 015, and 019: CACHE-SCOPE-013 remains a preregistered conditional extension; AUDIENCE-016 is absorbed by SCOPE-INTERSECTION-004 and EGRESS-011; ACL-PROOF-017 is absorbed by INDEX-NOT-AUTHORITY-005 and REVOCATION-006; DELIVERY-EVIDENCE-018 is absorbed by TRANSPORT-UNTRUSTED-008. ACCEPT-001 through ACCEPT-012 follow ADR-0019's category order. Protected-asset references A-01 through A-08 refer, in order, to the eight bullets in the threat model's Protected assets section. Every expectedEvidence value below is a stable planned case identifier, not a claim that the case ran or passed; only an exact activation record upgrades named evidence, while fixture carrier status and the explicit M0 oracle preserve every other accepted-versus-active distinction." + "reconciliation": "Issue #2 fixes the product and testing decisions, issue #5 requires exactly fifteen release invariants and twelve canonical acceptance fixtures, and ADR-0019 resolves the later nineteen-label prose expansion without weakening any safeguard. Issue #15 activates only Organization-level next-request resolve(Acquire) revocation evidence under REVOCATION-006: PG-REVOCATION-006, RUN-006, and CACHE-002 are active while BLOB-002 and Continue, citation, Policy-Epoch-bound WorkerLease, production ContextAccessTicket/ActionTicket, audit, outbox, cleanup, finer-epoch, UI, and external-admin carriers remain future or NOT_ACTIVE. Issue #16 activates only the M0 refusal gate for unavailable Continue, OpenCitation, and server-owned unavailable Acquire plans: its real continuation, citation, federated/source-native, and File carriers remain future, while its Runtime and HTTP refusal surfaces prove generic outcomes before content I/O. Issue #17 activates only the signed one-shot persistent no-op durable-job WorkerLease subcarrier under WORKER-LEASE-007. It binds one exact worker audience but no end-user delivery audience or Policy Epoch, and proves only LEASE-SIGNING-017, PG-WORKER-LEASE-NOOP-017, and WORKER-LEASE-REPLAY-007; Source, Resource, Revision, Policy Epoch, end-user delivery audience, idempotency, generation, business mutation, outbox, File publication, and the full ACCEPT-008 matrix remain deferred or NOT_ACTIVE. Issue #18 activates only distinct signed synthetic ContextAccessTicket Provider-read and ActionTicket no-op channel-action subcarriers under ACTION-SEPARATION-014, with current Organization-v0 Policy Epoch validation. TICKET-AUDIENCE-018 and PG-TICKET-EPOCH-018 do not activate production ContextProvider integration, ContextRuntime ticket integration, BotDelivery, full M2 ActionPlane.prepare/perform, a real Sender or external effect, payload/destination/approval/idempotency binding, durable one-shot/replay/reconciliation, or full ACCEPT-012 PASS; those remain future or NOT_ACTIVE. Issue #19 activates only the current Acquire authorized-only ContextRun and restricted delivered-empty DecisionAudit subcarrier under TRACE-REDACTION-012. DIGEST-019, RUN-LINEAGE-019, AUTHORIZED-RUN-019, and PG-TRACE-REDACTION-012 prove deterministic Package and Organization-bound query digests, retained-UserActor-transaction persistence, decisionRef resolution, redaction, and short-lived exact-Organization operator ticket reads with no application-role table access; the supported reader commits deletion before returning, while a direct caller rollback is not claimed as durable exactly-once redemption. Raw query retention, full ContextPackage body retention, unauthenticated transport failures as ContextRuns, cross-Organization analytics, and general observability redaction remain NOT_ACTIVE. Issue #48 activates only the current ACCEPT-002 authenticated HTTP Acquire Membership field-projection carrier under SCOPE-INTERSECTION-004, INDEX-NOT-AUTHORITY-005, and TRACE-REDACTION-012. PROP-FIELD-PROJECTION-048, PG-FIELD-PROJECTION-048, and HTTP-ACCEPT-002-048 bind one current Membership/version field ceiling to same-transaction FORCE-RLS reduction, the sealed AuthorizationKernel, AuthorizedProjection and Evidence integrity, and authorized-only ContextRun/audit persistence. General permission DSLs, caller-authored projection lists, CandidateRef or index field authority, production Provider/source-native ACL negotiation, Supply publication, File/Base field ACL, typed fields, Continue/OpenCitation, and Issue #20 runner substitution remain future or NOT_ACTIVE. Issue #63 activates only the digest-only private authenticated HTTP Acquire DeliveryEvidenceRef carrier under TRANSPORT-UNTRUSTED-008. PROP-DELIVERY-EVIDENCE-063, PG-DELIVERY-EVIDENCE-063, HTTP-DELIVERY-EVIDENCE-063, and FILE-DELIVERY-EVIDENCE-063 prove exact service/request/Organization/asker/Membership-version/destination/consumer/purpose/audience/epoch/lifetime binding, stable identical retry identity, role isolation, expiry cleanup, pre-content generic rejection, and one File-backed sealed Runtime delivery. Group AudienceSnapshot, group/public DeliveryEvidenceRef, OpenCitation, production ModelGateway, ActionPlane, the BotDelivery application, frozen OpenAPI compatibility, and the generated TypeScript SDK remain future or NOT_ACTIVE. Issue #65 activates only one opaque digest-only model or channel EgressGrant after final Package policy, exact atomic PostgreSQL redemption and restricted audit, nominal BotDelivery inputs, and deterministic network-free ModelGateway or Sender-preflight spies under EGRESS-011. PROP-EGRESS-011, PG-EGRESS-011, and RUNTIME-EGRESS-011 prove exact Package/Organization/purpose/audience/epoch/hop/profile/lifetime binding and zero additional bytes on replay. Real model/provider calls, a real Sender or channel write, ActionTicket effects, group AudienceSnapshot revalidation, the BotDelivery application process, and a generated SDK consumer remain future or NOT_ACTIVE. Issue #66 activates the frozen public POST /v0/resolve OpenAPI carrier under TRANSPORT-UNTRUSTED-008. OPENAPI-CONTRACT-066, OPENAPI-BREAKING-066, HTTP-V0-066, and PG-RUNTIME-RELEASE-066 prove one public closed operation, deterministic immutable snapshot and breaking-change refusal, a hidden v1 bridge through the same handler and sealed Runtime path, and exact read-only observation of the active Learning-promoted release with fail-closed missing-release behavior before content work. Generated TypeScript SDK conformance, a production BotDelivery caller, Continue and OpenCitation redemption, MCP, group AudienceSnapshot, and external effects remain future or NOT_ACTIVE. Issue #64 activates only the packaged generated TypeScript POST /v0/resolve client under TRANSPORT-UNTRUSTED-008. SDK-CONTRACT-064 and SDK-LIVE-FILE-064 prove deterministic pinned generation, strict closed types, a narrow export map and metadata-only facade, installable tarball consumption, and one real PostgreSQL/File-backed Acquire through CandidateRef, AuthorizationKernel, AuthorizedProjection, ContextPackage, and opaque model egress grant; generated Continue and OpenCitation calls remain generic unavailable outcomes. External package publication, production BotDelivery, MCP, group AudienceSnapshot, real Continue/OpenCitation redemption, and external effects remain future or NOT_ACTIVE. Issue #67 activates only private ActionPlane.prepare for create-placeholder, finalize-reply, and private-follow-up operation-specific tickets under ACTION-SEPARATION-014. PG-ACTION-PREPARE-067 proves exact current delivery, Organization, destination, audience, source, payload, approval, epoch, lifetime, and idempotency binding under a dedicated non-owner PostgreSQL role with digest-only FORCE-RLS persistence and zero effects; ActionPlane.perform, ticket consumption, Sender/provider calls, group revalidation, receipts, reconciliation, external effects, and the full ACCEPT-012 pass remain future or NOT_ACTIVE. The canonical set is IDs 001 through 012, 014, 015, and 019: CACHE-SCOPE-013 remains a preregistered conditional extension; AUDIENCE-016 is absorbed by SCOPE-INTERSECTION-004 and EGRESS-011; ACL-PROOF-017 is absorbed by INDEX-NOT-AUTHORITY-005 and REVOCATION-006; DELIVERY-EVIDENCE-018 is absorbed by TRANSPORT-UNTRUSTED-008. ACCEPT-001 through ACCEPT-012 follow ADR-0019's category order. Protected-asset references A-01 through A-08 refer, in order, to the eight bullets in the threat model's Protected assets section. Every expectedEvidence value below is a stable planned case identifier, not a claim that the case ran or passed; only an exact activation record upgrades named evidence, while fixture carrier status and the explicit M0 oracle preserve every other accepted-versus-active distinction." }, "hardOracles": [ { @@ -501,6 +503,42 @@ "group AudienceSnapshot", "external effects" ] + }, + { + "issueRef": "#67", + "invariantRef": "ACTION-SEPARATION-014", + "carrier": "ActionPlane.prepare private create-placeholder, finalize-reply, or follow-up ticket", + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": "trusted co-resident intent -> closed TypeScript ActionPlane.prepare -> least-privilege PostgreSQL prepare function -> operation-specific audience-bound ActionTicket", + "testEvidence": [ + { + "id": "PG-ACTION-PREPARE-067", + "surface": "tests/integration/test_action_prepare.py::test_private_prepare_is_exact_idempotent_digest_only_and_restricted", + "oracle": "A packaged TypeScript ActionPlane calling real PostgreSQL under the dedicated non-owner action role prepares distinct signed CreatePlaceholder, FinalizeReply, and PrivateFollowup tickets only for one exact current private delivery authority; identical retry returns the same durable ticket, while every one-field binding mutation, approval mismatch, expired evidence, stale or disabled source, and conflicting idempotency use returns only a closed zero-effect outcome. FORCE-RLS storage retains digests and restricted decision categories, and the caller has no direct table access or Sender surface." + } + ], + "deferredEvidence": [ + "ActionPlane.perform one-shot ticket redemption", + "provider receipt and ambiguous-result reconciliation", + "production Sender conformance", + "group AudienceSnapshot revalidation" + ], + "futureCarriers": [ + "ActionPlane.perform", + "Sender", + "BotDelivery application", + "group-public delivery", + "provider receipt reconciliation" + ], + "notActive": [ + "external channel write or business effect", + "ActionTicket consumption", + "Sender or provider network call", + "Applied, AlreadyApplied, or ReconciliationRequired outcome", + "group AudienceSnapshot", + "full ACCEPT-012 pass" + ] } ], "invariants": [ @@ -1175,7 +1213,8 @@ "PROP-ACTION-SEPARATION-014" ], "postgres": [ - "PG-ACTION-SEPARATION-014" + "PG-ACTION-SEPARATION-014", + "PG-ACTION-PREPARE-067" ], "runtimeOrDelivery": [ "ACTION-001", @@ -1194,7 +1233,9 @@ "docs/security/context-engine-threat-model.md#6-threat-register", "docs/security/Test-Architecture-与可验证性设计.md#75-egress-与-action-contract", "docs/decisions/0030-bound-ticket-audiences.md#decision", - "#18" + "#18", + "#67", + "docs/decisions/0049-prepare-one-exact-private-effect.md#decision" ] }, { diff --git a/infra/postgres/init/10-security-roles.sh b/infra/postgres/init/10-security-roles.sh index 21e03638..e1d12bea 100755 --- a/infra/postgres/init/10-security-roles.sh +++ b/infra/postgres/init/10-security-roles.sh @@ -12,6 +12,8 @@ required_environment=( CONTEXT_ENGINE_IDENTITY_PASSWORD CONTEXT_ENGINE_EGRESS_ROLE CONTEXT_ENGINE_EGRESS_PASSWORD + CONTEXT_ENGINE_ACTION_ROLE + CONTEXT_ENGINE_ACTION_PASSWORD CONTEXT_ENGINE_RUNTIME_ROLE CONTEXT_ENGINE_RUNTIME_PASSWORD CONTEXT_ENGINE_WORKER_ROLE @@ -43,6 +45,8 @@ psql \ \getenv identity_password CONTEXT_ENGINE_IDENTITY_PASSWORD \getenv egress_role CONTEXT_ENGINE_EGRESS_ROLE \getenv egress_password CONTEXT_ENGINE_EGRESS_PASSWORD +\getenv action_role CONTEXT_ENGINE_ACTION_ROLE +\getenv action_password CONTEXT_ENGINE_ACTION_PASSWORD \getenv runtime_role CONTEXT_ENGINE_RUNTIME_ROLE \getenv runtime_password CONTEXT_ENGINE_RUNTIME_PASSWORD \getenv worker_role CONTEXT_ENGINE_WORKER_ROLE @@ -112,6 +116,16 @@ CREATE ROLE :"egress_role" NOREPLICATION NOBYPASSRLS; +CREATE ROLE :"action_role" + LOGIN + PASSWORD :'action_password' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS; + CREATE ROLE :"learning_role" LOGIN PASSWORD :'learning_password' @@ -135,14 +149,14 @@ CREATE ROLE :"security_operator_role" REVOKE ALL ON DATABASE :"database_name" FROM PUBLIC; GRANT CONNECT ON DATABASE :"database_name" TO :"migrator_role", :"control_role", :"runtime_role", :"worker_role", - :"identity_role", :"egress_role", :"learning_role", :"security_operator_role"; + :"identity_role", :"egress_role", :"action_role", :"learning_role", :"security_operator_role"; ALTER DATABASE :"database_name" OWNER TO :"migrator_role"; REVOKE ALL ON SCHEMA public FROM PUBLIC; ALTER SCHEMA public OWNER TO :"migrator_role"; GRANT USAGE ON SCHEMA public TO :"control_role", :"runtime_role", :"worker_role", - :"identity_role", :"egress_role", :"learning_role", :"security_operator_role"; + :"identity_role", :"egress_role", :"action_role", :"learning_role", :"security_operator_role"; -- pgvector is an untrusted extension, so only the disposable bootstrap -- superuser creates it. Application schema objects remain migrator-owned. diff --git a/migrations/versions/20260723_0022_action_prepare.py b/migrations/versions/20260723_0022_action_prepare.py new file mode 100644 index 00000000..1e3cd8fd --- /dev/null +++ b/migrations/versions/20260723_0022_action_prepare.py @@ -0,0 +1,777 @@ +"""Persist one exact private ActionPlane prepare authority. + +Revision ID: 20260723_0022 +Revises: 20260723_0021 +Create Date: 2026-07-23 +""" + +# ruff: noqa: E501 + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260723_0022" +down_revision: str | None = "20260723_0021" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_MIGRATOR = "context_engine_migrator" +_ACTION = "context_engine_action" +_DEFINER = "context_engine_action_prepare_definer" +_FUNCTION = "context_action_prepare_private_effect" +_SIGNATURE = "(uuid, bytea, bytea, bytea, uuid, uuid, bigint, bytea, bytea, bytea, bytea, bytea, bigint, text, text, bytea, bytea, bytea, text, text, text, text, integer, bigint, uuid, uuid, text, bigint)" +_REFERENCE_TABLES = ( + "delivery_evidence", + "membership", + "organization_policy_epoch", + "context_source", + "source_version", +) + + +def _secure_action_table( + table_name: str, + *, + definer_reads: bool, +) -> None: + for role in ("PUBLIC", _ACTION, _DEFINER): + op.execute(f"REVOKE ALL ON TABLE public.{table_name} FROM {role}") + op.execute(f"ALTER TABLE public.{table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE public.{table_name} FORCE ROW LEVEL SECURITY") + op.execute( + f"CREATE POLICY {table_name}_migrator_administration " + f"ON public.{table_name} FOR ALL TO {_MIGRATOR} " + "USING (true) WITH CHECK (true)" + ) + if definer_reads: + op.execute( + f"CREATE POLICY {table_name}_action_prepare_definer_select " + f"ON public.{table_name} FOR SELECT TO {_DEFINER} USING (true)" + ) + op.execute(f"GRANT SELECT ON TABLE public.{table_name} TO {_DEFINER}") + op.execute( + f"CREATE POLICY {table_name}_action_prepare_definer_insert " + f"ON public.{table_name} FOR INSERT TO {_DEFINER} WITH CHECK (true)" + ) + op.execute(f"GRANT INSERT ON TABLE public.{table_name} TO {_DEFINER}") + + +def upgrade() -> None: + """Create a function-only, digest-only private prepare boundary.""" + + op.create_table( + "action_delivery_attempt", + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("delivery_attempt_ref", sa.Text(), nullable=False), + sa.Column("authenticated_service_digest", postgresql.BYTEA(), nullable=False), + sa.Column("delivery_evidence_digest", postgresql.BYTEA(), nullable=False), + sa.Column("authentication_binding_digest", postgresql.BYTEA(), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("membership_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("membership_version", sa.BigInteger(), nullable=False), + sa.Column("destination_digest", postgresql.BYTEA(), nullable=False), + sa.Column("consumer_digest", postgresql.BYTEA(), nullable=False), + sa.Column("purpose_digest", postgresql.BYTEA(), nullable=False), + sa.Column("audience_digest", postgresql.BYTEA(), nullable=False), + sa.Column("identity_digest", postgresql.BYTEA(), nullable=False), + sa.Column("policy_epoch", sa.BigInteger(), nullable=False), + sa.Column("profile_ref", sa.Text(), nullable=False), + sa.Column("retention_policy_ref", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("retain_until", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint( + "organization_id", + "delivery_attempt_ref", + name="pk_action_delivery_attempt", + ), + sa.UniqueConstraint( + "delivery_attempt_ref", name="uq_action_delivery_attempt_ref_global" + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organization.organization_id"], + name="fk_action_delivery_attempt_organization", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["organization_id", "membership_id", "membership_version"], + [ + "membership.organization_id", + "membership.membership_id", + "membership.membership_version", + ], + name="fk_action_delivery_attempt_membership_version", + ), + sa.CheckConstraint( + "delivery_attempt_ref ~ '^dla_[0-9a-f]{32}$'", + name="ck_action_delivery_attempt_ref", + ), + sa.CheckConstraint( + "octet_length(authenticated_service_digest) = 32 AND " + "octet_length(delivery_evidence_digest) = 32 AND " + "octet_length(authentication_binding_digest) = 32 AND " + "octet_length(destination_digest) = 32 AND " + "octet_length(consumer_digest) = 32 AND " + "octet_length(purpose_digest) = 32 AND " + "octet_length(audience_digest) = 32 AND " + "octet_length(identity_digest) = 32", + name="ck_action_delivery_attempt_sha256_digests", + ), + sa.CheckConstraint( + "membership_version > 0 AND policy_epoch > 0", + name="ck_action_delivery_attempt_positive_versions", + ), + sa.CheckConstraint( + "profile_ref = 'private-action-prepare-v1' AND " + "retention_policy_ref = 'action-digest-audit-retention-v1'", + name="ck_action_delivery_attempt_profiles", + ), + sa.CheckConstraint( + "retain_until > created_at", + name="ck_action_delivery_attempt_retention_window", + ), + ) + op.create_table( + "action_ticket", + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("ticket_ref", sa.Text(), nullable=False), + sa.Column("delivery_attempt_ref", sa.Text(), nullable=False), + sa.Column("operation", sa.Text(), nullable=False), + sa.Column("ticket_audience", sa.Text(), nullable=False), + sa.Column("payload_digest", postgresql.BYTEA(), nullable=False), + sa.Column("idempotency_digest", postgresql.BYTEA(), nullable=False), + sa.Column("approval_digest", postgresql.BYTEA(), nullable=False), + sa.Column("approval_tier", sa.Text(), nullable=False), + sa.Column("source_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("source_version_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("policy_epoch", sa.BigInteger(), nullable=False), + sa.Column("signing_key_version", sa.Integer(), nullable=False), + sa.Column("profile_ref", sa.Text(), nullable=False), + sa.Column("state", sa.Text(), nullable=False), + sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("retention_policy_ref", sa.Text(), nullable=False), + sa.Column("retain_until", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("organization_id", "ticket_ref", name="pk_action_ticket"), + sa.UniqueConstraint("ticket_ref", name="uq_action_ticket_ref_global"), + sa.UniqueConstraint( + "organization_id", + "idempotency_digest", + name="uq_action_ticket_prepare_idempotency", + ), + sa.ForeignKeyConstraint( + ["organization_id", "delivery_attempt_ref"], + [ + "action_delivery_attempt.organization_id", + "action_delivery_attempt.delivery_attempt_ref", + ], + name="fk_action_ticket_delivery_attempt", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["organization_id", "source_id", "source_version_id"], + [ + "source_version.organization_id", + "source_version.source_id", + "source_version.version_id", + ], + name="fk_action_ticket_source_version", + ondelete="RESTRICT", + ), + sa.CheckConstraint( + "ticket_ref ~ '^act_[0-9a-f]{32}$'", + name="ck_action_ticket_ref", + ), + sa.CheckConstraint( + "octet_length(payload_digest) = 32 AND " + "octet_length(idempotency_digest) = 32 AND " + "octet_length(approval_digest) = 32", + name="ck_action_ticket_sha256_digests", + ), + sa.CheckConstraint( + "(operation = 'create_placeholder' AND " + "ticket_audience = 'private-effect:create-placeholder') OR " + "(operation = 'finalize_reply' AND " + "ticket_audience = 'private-effect:finalize-reply') OR " + "(operation = 'send_private_followup' AND " + "ticket_audience = 'private-effect:send-private-followup')", + name="ck_action_ticket_operation_audience", + ), + sa.CheckConstraint( + "(source_id IS NULL AND source_version_id IS NULL) OR " + "(source_id IS NOT NULL AND source_version_id IS NOT NULL)", + name="ck_action_ticket_source_context_pair", + ), + sa.CheckConstraint( + "approval_tier = 'preapproved_private_delivery_v1' AND " + "profile_ref = 'private-action-prepare-v1' AND state = 'prepared' AND " + "retention_policy_ref = 'action-digest-audit-retention-v1'", + name="ck_action_ticket_profiles", + ), + sa.CheckConstraint( + "policy_epoch > 0 AND signing_key_version > 0", + name="ck_action_ticket_positive_versions", + ), + sa.CheckConstraint( + "expires_at > issued_at AND expires_at <= issued_at + interval '5 minutes' " + "AND retain_until > expires_at", + name="ck_action_ticket_time_windows", + ), + ) + op.create_table( + "action_prepare_audit", + sa.Column( + "audit_id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("decision_digest", postgresql.BYTEA(), nullable=False), + sa.Column("category", sa.Text(), nullable=False), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("retention_policy_ref", sa.Text(), nullable=False), + sa.Column("retain_until", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint( + "organization_id", "audit_id", name="pk_action_prepare_audit" + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organization.organization_id"], + name="fk_action_prepare_audit_organization", + ondelete="RESTRICT", + ), + sa.CheckConstraint( + "octet_length(decision_digest) = 32", + name="ck_action_prepare_audit_decision_digest", + ), + sa.CheckConstraint( + "category IN ('prepared', 'idempotent', 'generic_denied', " + "'audience_changed')", + name="ck_action_prepare_audit_category", + ), + sa.CheckConstraint( + "retention_policy_ref = 'action-digest-audit-retention-v1' " + "AND retain_until > recorded_at", + name="ck_action_prepare_audit_retention", + ), + ) + + _secure_action_table("action_delivery_attempt", definer_reads=True) + _secure_action_table("action_ticket", definer_reads=True) + _secure_action_table("action_prepare_audit", definer_reads=False) + + for table_name in _REFERENCE_TABLES: + op.execute(f"GRANT SELECT ON TABLE public.{table_name} TO {_DEFINER}") + op.execute( + f"CREATE POLICY {table_name}_action_prepare_definer_select " + f"ON public.{table_name} FOR SELECT TO {_DEFINER} USING (true)" + ) + + op.execute( + f""" + CREATE FUNCTION public.{_FUNCTION}( + requested_organization_id uuid, + requested_service_digest bytea, + requested_evidence_digest bytea, + requested_authentication_binding_digest bytea, + requested_user_id uuid, + requested_membership_id uuid, + requested_membership_version bigint, + requested_destination_digest bytea, + requested_consumer_digest bytea, + requested_purpose_digest bytea, + requested_audience_digest bytea, + requested_identity_digest bytea, + requested_policy_epoch bigint, + requested_operation text, + requested_ticket_audience text, + requested_payload_digest bytea, + requested_idempotency_digest bytea, + requested_approval_digest bytea, + requested_approval_tier text, + requested_delivery_attempt_ref text, + requested_ticket_ref text, + requested_profile_ref text, + requested_signing_key_version integer, + requested_ttl_seconds bigint, + requested_source_id uuid, + requested_source_version_id uuid, + requested_retention_policy_ref text, + requested_retention_seconds bigint + ) RETURNS TABLE ( + outcome text, + delivery_attempt_ref text, + ticket_ref text, + issued_at timestamptz, + expires_at timestamptz, + idempotent boolean + ) + LANGUAGE plpgsql SECURITY DEFINER + SET search_path = pg_catalog, pg_temp + SET row_security = on + AS $function$ + DECLARE + authority_now timestamptz := pg_catalog.clock_timestamp(); + authority_expires_at timestamptz; + authority_retain_until timestamptz; + expected_audience text; + expected_identity_digest bytea; + expected_approval_digest bytea; + decision_digest bytea; + evidence_record public.delivery_evidence%ROWTYPE; + attempt_record public.action_delivery_attempt%ROWTYPE; + ticket_record public.action_ticket%ROWTYPE; + current_policy_epoch bigint; + BEGIN + CASE requested_operation + WHEN 'create_placeholder' THEN + expected_audience := 'private-effect:create-placeholder'; + WHEN 'finalize_reply' THEN + expected_audience := 'private-effect:finalize-reply'; + WHEN 'send_private_followup' THEN + expected_audience := 'private-effect:send-private-followup'; + ELSE + RETURN QUERY SELECT 'generic_denied'::text, NULL::text, + NULL::text, NULL::timestamptz, NULL::timestamptz, false; + RETURN; + END CASE; + + IF SESSION_USER <> '{_ACTION}' + OR requested_ticket_audience <> expected_audience + OR requested_profile_ref <> 'private-action-prepare-v1' + OR requested_approval_tier <> 'preapproved_private_delivery_v1' + OR requested_retention_policy_ref <> + 'action-digest-audit-retention-v1' + OR requested_ttl_seconds NOT BETWEEN 1 AND 300 + OR requested_retention_seconds NOT BETWEEN + requested_ttl_seconds + 1 AND 31536000 + OR requested_membership_version <= 0 + OR requested_policy_epoch <= 0 + OR requested_signing_key_version <= 0 + OR requested_delivery_attempt_ref !~ '^dla_[0-9a-f]{{32}}$' + OR requested_ticket_ref !~ '^act_[0-9a-f]{{32}}$' + OR octet_length(requested_service_digest) <> 32 + OR octet_length(requested_evidence_digest) <> 32 + OR octet_length(requested_authentication_binding_digest) <> 32 + OR octet_length(requested_destination_digest) <> 32 + OR octet_length(requested_consumer_digest) <> 32 + OR octet_length(requested_purpose_digest) <> 32 + OR octet_length(requested_audience_digest) <> 32 + OR octet_length(requested_identity_digest) <> 32 + OR octet_length(requested_payload_digest) <> 32 + OR octet_length(requested_idempotency_digest) <> 32 + OR octet_length(requested_approval_digest) <> 32 + OR (requested_source_id IS NULL) <> + (requested_source_version_id IS NULL) + THEN + RETURN QUERY SELECT 'generic_denied'::text, NULL::text, + NULL::text, NULL::timestamptz, NULL::timestamptz, false; + RETURN; + END IF; + + SELECT evidence.* INTO evidence_record + FROM public.delivery_evidence AS evidence + WHERE evidence.organization_id = requested_organization_id + AND evidence.evidence_digest = requested_evidence_digest; + IF NOT FOUND THEN + RETURN QUERY SELECT 'generic_denied'::text, NULL::text, + NULL::text, NULL::timestamptz, NULL::timestamptz, false; + RETURN; + END IF; + + expected_identity_digest := public.digest( + pg_catalog.convert_to( + 'context-engine.action-binding.v1', 'UTF8' + ) || pg_catalog.decode('00', 'hex') || + pg_catalog.convert_to('identity', 'UTF8') || + pg_catalog.decode('00', 'hex') || + pg_catalog.convert_to('36', 'UTF8') || + pg_catalog.decode('00', 'hex') || + pg_catalog.convert_to(requested_organization_id::text, 'UTF8') || + pg_catalog.convert_to('36', 'UTF8') || + pg_catalog.decode('00', 'hex') || + pg_catalog.convert_to(requested_user_id::text, 'UTF8') || + pg_catalog.convert_to('36', 'UTF8') || + pg_catalog.decode('00', 'hex') || + pg_catalog.convert_to(requested_membership_id::text, 'UTF8') || + pg_catalog.convert_to( + octet_length(pg_catalog.convert_to( + requested_membership_version::text, 'UTF8' + ))::text, + 'UTF8' + ) || pg_catalog.decode('00', 'hex') || + pg_catalog.convert_to( + requested_membership_version::text, 'UTF8' + ) || + pg_catalog.convert_to( + octet_length(pg_catalog.convert_to( + evidence_record.authentication_binding_ref, 'UTF8' + ))::text, + 'UTF8' + ) || pg_catalog.decode('00', 'hex') || + pg_catalog.convert_to( + evidence_record.authentication_binding_ref, 'UTF8' + ), + 'sha256' + ); + + decision_digest := public.digest( + pg_catalog.convert_to( + requested_organization_id::text || ':' || + requested_operation || ':' || + pg_catalog.encode(requested_idempotency_digest, 'hex') || ':' || + pg_catalog.encode(requested_payload_digest, 'hex'), + 'UTF8' + ), + 'sha256' + ); + expected_approval_digest := public.digest( + pg_catalog.convert_to( + 'context-engine.action-binding.v1', 'UTF8' + ) || pg_catalog.decode('00', 'hex') || + pg_catalog.convert_to('approval', 'UTF8') || + pg_catalog.decode('00', 'hex') || + pg_catalog.convert_to( + octet_length(pg_catalog.convert_to( + requested_approval_tier, 'UTF8' + ))::text, 'UTF8' + ) || pg_catalog.decode('00', 'hex') || + pg_catalog.convert_to(requested_approval_tier, 'UTF8'), + 'sha256' + ); + + IF evidence_record.delivery_kind <> 'private' + OR evidence_record.profile_ref <> 'private-delivery-evidence-v1' + OR evidence_record.expires_at <= authority_now + OR evidence_record.issued_at > authority_now + OR public.digest(pg_catalog.convert_to( + evidence_record.authenticated_service_ref, 'UTF8'), 'sha256') + <> requested_service_digest + OR public.digest(pg_catalog.convert_to( + evidence_record.authentication_binding_ref, 'UTF8'), 'sha256') + <> requested_authentication_binding_digest + OR evidence_record.user_id <> requested_user_id + OR evidence_record.membership_id <> requested_membership_id + OR evidence_record.membership_version <> + requested_membership_version + OR public.digest(pg_catalog.convert_to( + evidence_record.destination_ref, 'UTF8'), 'sha256') + <> requested_destination_digest + OR public.digest(pg_catalog.convert_to( + evidence_record.consumer_ref, 'UTF8'), 'sha256') + <> requested_consumer_digest + OR public.digest(pg_catalog.convert_to( + evidence_record.purpose, 'UTF8'), 'sha256') + <> requested_purpose_digest + OR evidence_record.policy_epoch <> requested_policy_epoch + OR expected_identity_digest IS DISTINCT FROM requested_identity_digest + OR expected_approval_digest <> requested_approval_digest + THEN + INSERT INTO public.action_prepare_audit ( + organization_id, decision_digest, category, recorded_at, + retention_policy_ref, retain_until + ) VALUES ( + requested_organization_id, decision_digest, + 'generic_denied', authority_now, + requested_retention_policy_ref, + authority_now + pg_catalog.make_interval( + secs => requested_retention_seconds + ) + ); + RETURN QUERY SELECT 'generic_denied'::text, NULL::text, + NULL::text, NULL::timestamptz, NULL::timestamptz, false; + RETURN; + END IF; + + IF pg_catalog.decode(evidence_record.audience_digest, 'hex') <> + requested_audience_digest + OR NOT EXISTS ( + SELECT 1 FROM public.membership AS membership + WHERE membership.organization_id = requested_organization_id + AND membership.user_id = requested_user_id + AND membership.membership_id = requested_membership_id + AND membership.membership_version = + requested_membership_version + AND membership.status = 'active' + AND membership.valid_from <= authority_now + AND (membership.valid_until IS NULL OR + membership.valid_until > authority_now) + ) + THEN + INSERT INTO public.action_prepare_audit ( + organization_id, decision_digest, category, recorded_at, + retention_policy_ref, retain_until + ) VALUES ( + requested_organization_id, decision_digest, + 'audience_changed', authority_now, + requested_retention_policy_ref, + authority_now + pg_catalog.make_interval( + secs => requested_retention_seconds + ) + ); + RETURN QUERY SELECT 'audience_changed'::text, NULL::text, + NULL::text, NULL::timestamptz, NULL::timestamptz, false; + RETURN; + END IF; + + SELECT epoch.policy_epoch INTO current_policy_epoch + FROM public.organization_policy_epoch AS epoch + WHERE epoch.organization_id = requested_organization_id; + IF current_policy_epoch IS DISTINCT FROM requested_policy_epoch + OR ( + requested_source_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM public.context_source AS source + JOIN public.source_version AS version + ON version.organization_id = source.organization_id + AND version.source_id = source.source_id + AND version.version_id = requested_source_version_id + WHERE source.organization_id = requested_organization_id + AND source.source_id = requested_source_id + AND source.active_version_id = + requested_source_version_id + AND source.lifecycle_state = 'active' + ) + ) + THEN + INSERT INTO public.action_prepare_audit ( + organization_id, decision_digest, category, recorded_at, + retention_policy_ref, retain_until + ) VALUES ( + requested_organization_id, decision_digest, + 'generic_denied', authority_now, + requested_retention_policy_ref, + authority_now + pg_catalog.make_interval( + secs => requested_retention_seconds + ) + ); + RETURN QUERY SELECT 'generic_denied'::text, NULL::text, + NULL::text, NULL::timestamptz, NULL::timestamptz, false; + RETURN; + END IF; + + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended( + 'action-attempt:' || requested_organization_id::text || ':' || + requested_delivery_attempt_ref, 0 + )); + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended( + 'action-idempotency:' || requested_organization_id::text || ':' || + pg_catalog.encode(requested_idempotency_digest, 'hex'), 0 + )); + + SELECT ticket.* INTO ticket_record + FROM public.action_ticket AS ticket + WHERE ticket.organization_id = requested_organization_id + AND ticket.idempotency_digest = requested_idempotency_digest; + IF FOUND THEN + SELECT attempt.* INTO STRICT attempt_record + FROM public.action_delivery_attempt AS attempt + WHERE attempt.organization_id = ticket_record.organization_id + AND attempt.delivery_attempt_ref = + ticket_record.delivery_attempt_ref; + IF ticket_record.delivery_attempt_ref <> + requested_delivery_attempt_ref + OR ticket_record.operation <> requested_operation + OR ticket_record.ticket_audience <> requested_ticket_audience + OR ticket_record.payload_digest <> requested_payload_digest + OR ticket_record.approval_digest <> requested_approval_digest + OR ticket_record.approval_tier <> requested_approval_tier + OR ticket_record.policy_epoch <> requested_policy_epoch + OR ticket_record.signing_key_version <> + requested_signing_key_version + OR ticket_record.profile_ref <> requested_profile_ref + OR ticket_record.source_id IS DISTINCT FROM requested_source_id + OR ticket_record.source_version_id IS DISTINCT FROM + requested_source_version_id + OR ticket_record.expires_at <= authority_now + OR attempt_record.authenticated_service_digest <> + requested_service_digest + OR attempt_record.delivery_evidence_digest <> + requested_evidence_digest + OR attempt_record.authentication_binding_digest <> + requested_authentication_binding_digest + OR attempt_record.user_id <> requested_user_id + OR attempt_record.membership_id <> requested_membership_id + OR attempt_record.membership_version <> + requested_membership_version + OR attempt_record.destination_digest <> + requested_destination_digest + OR attempt_record.consumer_digest <> requested_consumer_digest + OR attempt_record.purpose_digest <> requested_purpose_digest + OR attempt_record.audience_digest <> requested_audience_digest + OR attempt_record.identity_digest <> requested_identity_digest + OR attempt_record.policy_epoch <> requested_policy_epoch + THEN + INSERT INTO public.action_prepare_audit ( + organization_id, decision_digest, category, recorded_at, + retention_policy_ref, retain_until + ) VALUES ( + requested_organization_id, decision_digest, + 'generic_denied', authority_now, + requested_retention_policy_ref, + authority_now + pg_catalog.make_interval( + secs => requested_retention_seconds + ) + ); + RETURN QUERY SELECT 'generic_denied'::text, NULL::text, + NULL::text, NULL::timestamptz, NULL::timestamptz, false; + RETURN; + END IF; + INSERT INTO public.action_prepare_audit ( + organization_id, decision_digest, category, recorded_at, + retention_policy_ref, retain_until + ) VALUES ( + requested_organization_id, decision_digest, 'idempotent', + authority_now, requested_retention_policy_ref, + authority_now + pg_catalog.make_interval( + secs => requested_retention_seconds + ) + ); + RETURN QUERY SELECT 'prepared'::text, + ticket_record.delivery_attempt_ref, + ticket_record.ticket_ref, ticket_record.issued_at, + ticket_record.expires_at, true; + RETURN; + END IF; + + SELECT attempt.* INTO attempt_record + FROM public.action_delivery_attempt AS attempt + WHERE attempt.organization_id = requested_organization_id + AND attempt.delivery_attempt_ref = requested_delivery_attempt_ref; + IF FOUND AND ( + attempt_record.authenticated_service_digest <> + requested_service_digest + OR attempt_record.delivery_evidence_digest <> + requested_evidence_digest + OR attempt_record.authentication_binding_digest <> + requested_authentication_binding_digest + OR attempt_record.user_id <> requested_user_id + OR attempt_record.membership_id <> requested_membership_id + OR attempt_record.membership_version <> + requested_membership_version + OR attempt_record.destination_digest <> requested_destination_digest + OR attempt_record.consumer_digest <> requested_consumer_digest + OR attempt_record.purpose_digest <> requested_purpose_digest + OR attempt_record.audience_digest <> requested_audience_digest + OR attempt_record.identity_digest <> requested_identity_digest + OR attempt_record.policy_epoch <> requested_policy_epoch + OR attempt_record.profile_ref <> requested_profile_ref + ) THEN + INSERT INTO public.action_prepare_audit ( + organization_id, decision_digest, category, recorded_at, + retention_policy_ref, retain_until + ) VALUES ( + requested_organization_id, decision_digest, + 'generic_denied', authority_now, + requested_retention_policy_ref, + authority_now + pg_catalog.make_interval( + secs => requested_retention_seconds + ) + ); + RETURN QUERY SELECT 'generic_denied'::text, NULL::text, + NULL::text, NULL::timestamptz, NULL::timestamptz, false; + RETURN; + ELSIF NOT FOUND THEN + authority_retain_until := authority_now + + pg_catalog.make_interval(secs => requested_retention_seconds); + INSERT INTO public.action_delivery_attempt ( + organization_id, delivery_attempt_ref, + authenticated_service_digest, delivery_evidence_digest, + authentication_binding_digest, user_id, membership_id, + membership_version, destination_digest, consumer_digest, + purpose_digest, audience_digest, identity_digest, + policy_epoch, profile_ref, retention_policy_ref, + created_at, retain_until + ) VALUES ( + requested_organization_id, requested_delivery_attempt_ref, + requested_service_digest, requested_evidence_digest, + requested_authentication_binding_digest, requested_user_id, + requested_membership_id, requested_membership_version, + requested_destination_digest, requested_consumer_digest, + requested_purpose_digest, requested_audience_digest, + requested_identity_digest, requested_policy_epoch, + requested_profile_ref, requested_retention_policy_ref, + authority_now, authority_retain_until + ); + END IF; + + authority_expires_at := authority_now + + pg_catalog.make_interval(secs => requested_ttl_seconds); + authority_retain_until := authority_now + + pg_catalog.make_interval(secs => requested_retention_seconds); + INSERT INTO public.action_ticket ( + organization_id, ticket_ref, delivery_attempt_ref, operation, + ticket_audience, payload_digest, idempotency_digest, + approval_digest, approval_tier, source_id, source_version_id, + policy_epoch, signing_key_version, profile_ref, state, + issued_at, expires_at, retention_policy_ref, retain_until + ) VALUES ( + requested_organization_id, requested_ticket_ref, + requested_delivery_attempt_ref, requested_operation, + requested_ticket_audience, requested_payload_digest, + requested_idempotency_digest, requested_approval_digest, + requested_approval_tier, requested_source_id, + requested_source_version_id, requested_policy_epoch, + requested_signing_key_version, requested_profile_ref, + 'prepared', authority_now, authority_expires_at, + requested_retention_policy_ref, authority_retain_until + ); + INSERT INTO public.action_prepare_audit ( + organization_id, decision_digest, category, recorded_at, + retention_policy_ref, retain_until + ) VALUES ( + requested_organization_id, decision_digest, 'prepared', + authority_now, requested_retention_policy_ref, + authority_retain_until + ); + RETURN QUERY SELECT 'prepared'::text, + requested_delivery_attempt_ref, requested_ticket_ref, + authority_now, authority_expires_at, false; + EXCEPTION + WHEN unique_violation OR serialization_failure OR deadlock_detected THEN + RETURN QUERY SELECT 'retryable_unavailable'::text, NULL::text, + NULL::text, NULL::timestamptz, NULL::timestamptz, false; + END; + $function$ + """ + ) + op.execute(f"REVOKE ALL ON FUNCTION public.{_FUNCTION}{_SIGNATURE} FROM PUBLIC") + op.execute(f"GRANT CREATE ON SCHEMA public TO {_DEFINER}") + op.execute(f"ALTER FUNCTION public.{_FUNCTION}{_SIGNATURE} OWNER TO {_DEFINER}") + op.execute(f"REVOKE CREATE ON SCHEMA public FROM {_DEFINER}") + op.execute(f"SET LOCAL ROLE {_DEFINER}") + op.execute(f"GRANT EXECUTE ON FUNCTION public.{_FUNCTION}{_SIGNATURE} TO {_ACTION}") + op.execute("RESET ROLE") + + +def downgrade() -> None: + """Refuse to erase retained action authority or audit rows.""" + + op.execute( + """ + DO $block$ BEGIN + IF EXISTS (SELECT 1 FROM public.action_delivery_attempt) + OR EXISTS (SELECT 1 FROM public.action_ticket) + OR EXISTS (SELECT 1 FROM public.action_prepare_audit) + THEN RAISE EXCEPTION USING ERRCODE = '55000', + MESSAGE = 'cannot downgrade with retained ActionPlane rows'; + END IF; + END; $block$ + """ + ) + op.execute(f"SET LOCAL ROLE {_DEFINER}") + op.execute(f"DROP FUNCTION public.{_FUNCTION}{_SIGNATURE}") + op.execute("RESET ROLE") + for table_name in reversed(_REFERENCE_TABLES): + op.execute( + f"DROP POLICY {table_name}_action_prepare_definer_select " + f"ON public.{table_name}" + ) + op.execute(f"REVOKE SELECT ON TABLE public.{table_name} FROM {_DEFINER}") + op.drop_table("action_prepare_audit") + op.drop_table("action_ticket") + op.drop_table("action_delivery_attempt") diff --git a/scripts/database_harness.sh b/scripts/database_harness.sh index bd3e5107..fad3b320 100755 --- a/scripts/database_harness.sh +++ b/scripts/database_harness.sh @@ -43,6 +43,7 @@ generate_environment() { local control_password local identity_password local egress_password + local action_password local runtime_password local worker_password local learning_password @@ -54,6 +55,7 @@ generate_environment() { control_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" identity_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" egress_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" + action_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" runtime_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" worker_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" learning_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" @@ -81,6 +83,8 @@ generate_environment() { printf 'CONTEXT_ENGINE_IDENTITY_PASSWORD=%s\n' "$identity_password" printf 'CONTEXT_ENGINE_EGRESS_ROLE=context_engine_egress\n' printf 'CONTEXT_ENGINE_EGRESS_PASSWORD=%s\n' "$egress_password" + printf 'CONTEXT_ENGINE_ACTION_ROLE=context_engine_action\n' + printf 'CONTEXT_ENGINE_ACTION_PASSWORD=%s\n' "$action_password" printf 'CONTEXT_ENGINE_RUNTIME_ROLE=context_engine_runtime\n' printf 'CONTEXT_ENGINE_RUNTIME_PASSWORD=%s\n' "$runtime_password" printf 'CONTEXT_ENGINE_WORKER_ROLE=context_engine_worker\n' @@ -98,6 +102,8 @@ generate_environment() { "$identity_password" "$postgres_port" printf 'CONTEXT_ENGINE_EGRESS_DATABASE_URL=postgresql+psycopg://context_engine_egress:%s@127.0.0.1:%s/context_engine\n' \ "$egress_password" "$postgres_port" + printf 'CONTEXT_ENGINE_ACTION_DATABASE_URL=postgresql+psycopg://context_engine_action:%s@127.0.0.1:%s/context_engine\n' \ + "$action_password" "$postgres_port" printf 'CONTEXT_ENGINE_RUNTIME_DATABASE_URL=postgresql+psycopg://context_engine_runtime:%s@127.0.0.1:%s/context_engine\n' \ "$runtime_password" "$postgres_port" printf 'CONTEXT_ENGINE_WORKER_DATABASE_URL=postgresql+psycopg://context_engine_worker:%s@127.0.0.1:%s/context_engine\n' \ @@ -147,6 +153,7 @@ migrate_legacy_environment() { (migrate_legacy_control_identity) (migrate_legacy_identity_identity) (migrate_legacy_egress_identity) + (migrate_legacy_action_identity) (migrate_legacy_learning_identity) (migrate_legacy_security_operator_identity) rmdir "$ENV_MIGRATION_LOCK" @@ -293,6 +300,58 @@ migrate_legacy_egress_identity() { trap - EXIT } +migrate_legacy_action_identity() { + local action_role_count + local action_password_count + local action_url_count + action_role_count="$(grep -c '^CONTEXT_ENGINE_ACTION_ROLE=' "$ENV_FILE" || true)" + action_password_count="$( + grep -c '^CONTEXT_ENGINE_ACTION_PASSWORD=' "$ENV_FILE" || true + )" + action_url_count="$( + grep -c '^CONTEXT_ENGINE_ACTION_DATABASE_URL=' "$ENV_FILE" || true + )" + if [[ "$action_role_count" == '1' && \ + "$action_password_count" == '1' && \ + "$action_url_count" == '1' ]]; then + return + fi + + local postgres_port + postgres_port="$(sed -n 's/^CONTEXT_ENGINE_POSTGRES_PORT=//p' "$ENV_FILE")" + if [[ ! "$postgres_port" =~ ^[0-9]+$ ]]; then + printf 'legacy database environment has no valid PostgreSQL port\n' >&2 + exit 1 + fi + local action_password + action_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" + local migration_file + migration_file="$(mktemp "$STATE_DIR/database.env.action.XXXXXX")" + trap 'rm -f "$migration_file"' EXIT + ( + umask 077 + while IFS= read -r environment_line || [[ -n "$environment_line" ]]; do + case "$environment_line" in + CONTEXT_ENGINE_ACTION_ROLE=*|\ + CONTEXT_ENGINE_ACTION_PASSWORD=*|\ + CONTEXT_ENGINE_ACTION_DATABASE_URL=*) + continue + ;; + *) + printf '%s\n' "$environment_line" + ;; + esac + done <"$ENV_FILE" + printf 'CONTEXT_ENGINE_ACTION_ROLE=context_engine_action\n' + printf 'CONTEXT_ENGINE_ACTION_PASSWORD=%s\n' "$action_password" + printf 'CONTEXT_ENGINE_ACTION_DATABASE_URL=postgresql+psycopg://context_engine_action:%s@127.0.0.1:%s/context_engine\n' \ + "$action_password" "$postgres_port" + ) >"$migration_file" + chmod 600 "$migration_file" + mv "$migration_file" "$ENV_FILE" + trap - EXIT +} + migrate_legacy_learning_identity() { local learning_role_count local learning_password_count @@ -385,7 +444,7 @@ load_environment() { local variable_name local variable_value local loaded_variable_names=' ' - local allowed_variables=' POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD CONTEXT_ENGINE_POSTGRES_PORT CONTEXT_ENGINE_COMPOSE_PROJECT CONTEXT_ENGINE_MIGRATOR_ROLE CONTEXT_ENGINE_MIGRATOR_PASSWORD CONTEXT_ENGINE_CONTROL_ROLE CONTEXT_ENGINE_CONTROL_PASSWORD CONTEXT_ENGINE_IDENTITY_ROLE CONTEXT_ENGINE_IDENTITY_PASSWORD CONTEXT_ENGINE_EGRESS_ROLE CONTEXT_ENGINE_EGRESS_PASSWORD CONTEXT_ENGINE_RUNTIME_ROLE CONTEXT_ENGINE_RUNTIME_PASSWORD CONTEXT_ENGINE_WORKER_ROLE CONTEXT_ENGINE_WORKER_PASSWORD CONTEXT_ENGINE_LEARNING_ROLE CONTEXT_ENGINE_LEARNING_PASSWORD CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE CONTEXT_ENGINE_SECURITY_OPERATOR_PASSWORD CONTEXT_ENGINE_MIGRATION_DATABASE_URL CONTEXT_ENGINE_CONTROL_DATABASE_URL CONTEXT_ENGINE_IDENTITY_DATABASE_URL CONTEXT_ENGINE_EGRESS_DATABASE_URL CONTEXT_ENGINE_RUNTIME_DATABASE_URL CONTEXT_ENGINE_WORKER_DATABASE_URL CONTEXT_ENGINE_LEARNING_DATABASE_URL CONTEXT_ENGINE_SECURITY_OPERATOR_DATABASE_URL CONTEXT_ENGINE_TEST_DATABASE_URL ' + local allowed_variables=' POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD CONTEXT_ENGINE_POSTGRES_PORT CONTEXT_ENGINE_COMPOSE_PROJECT CONTEXT_ENGINE_MIGRATOR_ROLE CONTEXT_ENGINE_MIGRATOR_PASSWORD CONTEXT_ENGINE_CONTROL_ROLE CONTEXT_ENGINE_CONTROL_PASSWORD CONTEXT_ENGINE_IDENTITY_ROLE CONTEXT_ENGINE_IDENTITY_PASSWORD CONTEXT_ENGINE_EGRESS_ROLE CONTEXT_ENGINE_EGRESS_PASSWORD CONTEXT_ENGINE_ACTION_ROLE CONTEXT_ENGINE_ACTION_PASSWORD CONTEXT_ENGINE_RUNTIME_ROLE CONTEXT_ENGINE_RUNTIME_PASSWORD CONTEXT_ENGINE_WORKER_ROLE CONTEXT_ENGINE_WORKER_PASSWORD CONTEXT_ENGINE_LEARNING_ROLE CONTEXT_ENGINE_LEARNING_PASSWORD CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE CONTEXT_ENGINE_SECURITY_OPERATOR_PASSWORD CONTEXT_ENGINE_MIGRATION_DATABASE_URL CONTEXT_ENGINE_CONTROL_DATABASE_URL CONTEXT_ENGINE_IDENTITY_DATABASE_URL CONTEXT_ENGINE_EGRESS_DATABASE_URL CONTEXT_ENGINE_ACTION_DATABASE_URL CONTEXT_ENGINE_RUNTIME_DATABASE_URL CONTEXT_ENGINE_WORKER_DATABASE_URL CONTEXT_ENGINE_LEARNING_DATABASE_URL CONTEXT_ENGINE_SECURITY_OPERATOR_DATABASE_URL CONTEXT_ENGINE_TEST_DATABASE_URL ' while IFS='=' read -r variable_name variable_value; do if [[ -z "$variable_name" || "$allowed_variables" != *" $variable_name "* ]]; then @@ -417,6 +476,7 @@ load_environment() { "$CONTEXT_ENGINE_CONTROL_ROLE" != 'context_engine_control' || \ "$CONTEXT_ENGINE_IDENTITY_ROLE" != 'context_engine_identity' || \ "$CONTEXT_ENGINE_EGRESS_ROLE" != 'context_engine_egress' || \ + "$CONTEXT_ENGINE_ACTION_ROLE" != 'context_engine_action' || \ "$CONTEXT_ENGINE_RUNTIME_ROLE" != 'context_engine_runtime' || \ "$CONTEXT_ENGINE_WORKER_ROLE" != 'context_engine_worker' || \ "$CONTEXT_ENGINE_LEARNING_ROLE" != 'context_engine_learning' || \ @@ -429,6 +489,7 @@ load_environment() { ! "$CONTEXT_ENGINE_CONTROL_PASSWORD" =~ ^[0-9a-f]{64}$ || \ ! "$CONTEXT_ENGINE_IDENTITY_PASSWORD" =~ ^[0-9a-f]{64}$ || \ ! "$CONTEXT_ENGINE_EGRESS_PASSWORD" =~ ^[0-9a-f]{64}$ || \ + ! "$CONTEXT_ENGINE_ACTION_PASSWORD" =~ ^[0-9a-f]{64}$ || \ ! "$CONTEXT_ENGINE_RUNTIME_PASSWORD" =~ ^[0-9a-f]{64}$ || \ ! "$CONTEXT_ENGINE_WORKER_PASSWORD" =~ ^[0-9a-f]{64}$ || \ ! "$CONTEXT_ENGINE_LEARNING_PASSWORD" =~ ^[0-9a-f]{64}$ || \ @@ -447,6 +508,8 @@ load_environment() { "postgresql+psycopg://context_engine_identity:$CONTEXT_ENGINE_IDENTITY_PASSWORD@$database_endpoint" || \ "$CONTEXT_ENGINE_EGRESS_DATABASE_URL" != \ "postgresql+psycopg://context_engine_egress:$CONTEXT_ENGINE_EGRESS_PASSWORD@$database_endpoint" || \ + "$CONTEXT_ENGINE_ACTION_DATABASE_URL" != \ + "postgresql+psycopg://context_engine_action:$CONTEXT_ENGINE_ACTION_PASSWORD@$database_endpoint" || \ "$CONTEXT_ENGINE_RUNTIME_DATABASE_URL" != \ "postgresql+psycopg://context_engine_runtime:$CONTEXT_ENGINE_RUNTIME_PASSWORD@$database_endpoint" || \ "$CONTEXT_ENGINE_WORKER_DATABASE_URL" != \ diff --git a/scripts/provision_database_roles.py b/scripts/provision_database_roles.py index 429b543b..6ca8836f 100644 --- a/scripts/provision_database_roles.py +++ b/scripts/provision_database_roles.py @@ -15,6 +15,8 @@ from engine.persistence.configuration import ( ACCESS_POLICY_DEFINER_ROLE, + ACTION_PREPARE_DEFINER_ROLE, + ACTION_ROLE, CONTEXT_RUN_READER_DEFINER_ROLE, CONTROL_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, @@ -46,6 +48,8 @@ class RoleProvisioningContract: identity_password: str egress_role: str egress_password: str + action_role: str + action_password: str learning_role: str learning_password: str security_operator_role: str @@ -56,6 +60,7 @@ class RoleProvisioningContract: release_definer_role: str delivery_evidence_definer_role: str egress_grant_definer_role: str + action_prepare_definer_role: str def __post_init__(self) -> None: for field_name in ( @@ -65,6 +70,7 @@ def __post_init__(self) -> None: "control_role", "identity_role", "egress_role", + "action_role", "learning_role", "security_operator_role", "definer_role", @@ -73,6 +79,7 @@ def __post_init__(self) -> None: "release_definer_role", "delivery_evidence_definer_role", "egress_grant_definer_role", + "action_prepare_definer_role", ): value = getattr(self, field_name) if type(value) is not str or not value or value.isspace(): @@ -82,6 +89,7 @@ def __post_init__(self) -> None: self.control_role, self.identity_role, self.egress_role, + self.action_role, self.learning_role, self.security_operator_role, self.definer_role, @@ -90,8 +98,9 @@ def __post_init__(self) -> None: self.release_definer_role, self.delivery_evidence_definer_role, self.egress_grant_definer_role, + self.action_prepare_definer_role, } - if len(security_roles) != 12: + if len(security_roles) != 14: raise ValueError("provisioned database roles must be distinct") if type(self.postgres_port) is not int or not 1 <= self.postgres_port <= 65535: raise ValueError("postgres_port must be a valid TCP port") @@ -100,6 +109,7 @@ def __post_init__(self) -> None: "control_password", "identity_password", "egress_password", + "action_password", "learning_password", "security_operator_password", ): @@ -123,6 +133,8 @@ def _contract_from_environment( "CONTEXT_ENGINE_IDENTITY_PASSWORD", "CONTEXT_ENGINE_EGRESS_ROLE", "CONTEXT_ENGINE_EGRESS_PASSWORD", + "CONTEXT_ENGINE_ACTION_ROLE", + "CONTEXT_ENGINE_ACTION_PASSWORD", "CONTEXT_ENGINE_LEARNING_ROLE", "CONTEXT_ENGINE_LEARNING_PASSWORD", "CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE", @@ -146,6 +158,8 @@ def _contract_from_environment( raise ValueError("database role provisioning has an invalid identity role") if environment["CONTEXT_ENGINE_EGRESS_ROLE"] != EGRESS_ROLE: raise ValueError("database role provisioning has an invalid egress role") + if environment["CONTEXT_ENGINE_ACTION_ROLE"] != ACTION_ROLE: + raise ValueError("database role provisioning has an invalid action role") if environment["CONTEXT_ENGINE_LEARNING_ROLE"] != LEARNING_ROLE: raise ValueError("database role provisioning has an invalid learning role") if environment["CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE"] != OPERATOR_ROLE: @@ -168,6 +182,8 @@ def _contract_from_environment( identity_password=environment["CONTEXT_ENGINE_IDENTITY_PASSWORD"], egress_role=environment["CONTEXT_ENGINE_EGRESS_ROLE"], egress_password=environment["CONTEXT_ENGINE_EGRESS_PASSWORD"], + action_role=environment["CONTEXT_ENGINE_ACTION_ROLE"], + action_password=environment["CONTEXT_ENGINE_ACTION_PASSWORD"], learning_role=environment["CONTEXT_ENGINE_LEARNING_ROLE"], learning_password=environment["CONTEXT_ENGINE_LEARNING_PASSWORD"], security_operator_role=environment["CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE"], @@ -180,6 +196,7 @@ def _contract_from_environment( release_definer_role=RELEASE_DEFINER_ROLE, delivery_evidence_definer_role=DELIVERY_EVIDENCE_DEFINER_ROLE, egress_grant_definer_role=EGRESS_GRANT_DEFINER_ROLE, + action_prepare_definer_role=ACTION_PREPARE_DEFINER_ROLE, ) @@ -288,6 +305,7 @@ def provision_security_roles( _create_role_if_missing(connection, contract.control_role) _create_role_if_missing(connection, contract.identity_role) _create_role_if_missing(connection, contract.egress_role) + _create_role_if_missing(connection, contract.action_role) _create_role_if_missing(connection, contract.learning_role) _create_role_if_missing(connection, contract.security_operator_role) _create_role_if_missing(connection, contract.definer_role) @@ -296,6 +314,7 @@ def provision_security_roles( _create_role_if_missing(connection, contract.release_definer_role) _create_role_if_missing(connection, contract.delivery_evidence_definer_role) _create_role_if_missing(connection, contract.egress_grant_definer_role) + _create_role_if_missing(connection, contract.action_prepare_definer_role) connection.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public") extension = connection.execute( @@ -330,6 +349,15 @@ def provision_security_roles( sql.Literal(contract.egress_password), ) ) + connection.execute( + sql.SQL( + "ALTER ROLE {} WITH LOGIN PASSWORD {} NOSUPERUSER NOCREATEDB " + "NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS" + ).format( + sql.Identifier(contract.action_role), + sql.Literal(contract.action_password), + ) + ) connection.execute( sql.SQL( "ALTER ROLE {} WITH LOGIN PASSWORD {} NOSUPERUSER NOCREATEDB " @@ -369,6 +397,12 @@ def provision_security_roles( "NOINHERIT NOREPLICATION NOBYPASSRLS" ).format(sql.Identifier(contract.egress_grant_definer_role)) ) + connection.execute( + sql.SQL( + "ALTER ROLE {} WITH NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE " + "NOINHERIT NOREPLICATION NOBYPASSRLS" + ).format(sql.Identifier(contract.action_prepare_definer_role)) + ) connection.execute( sql.SQL( "ALTER ROLE {} WITH NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE " @@ -397,6 +431,7 @@ def provision_security_roles( _revoke_roles_granted_to(connection, contract.control_role) _revoke_roles_granted_to(connection, contract.identity_role) _revoke_roles_granted_to(connection, contract.egress_role) + _revoke_roles_granted_to(connection, contract.action_role) _revoke_roles_granted_to(connection, contract.learning_role) _revoke_roles_granted_to(connection, contract.security_operator_role) _revoke_roles_granted_to(connection, contract.definer_role) @@ -405,9 +440,11 @@ def provision_security_roles( _revoke_roles_granted_to(connection, contract.release_definer_role) _revoke_roles_granted_to(connection, contract.delivery_evidence_definer_role) _revoke_roles_granted_to(connection, contract.egress_grant_definer_role) + _revoke_roles_granted_to(connection, contract.action_prepare_definer_role) _revoke_members_of(connection, contract.control_role) _revoke_members_of(connection, contract.identity_role) _revoke_members_of(connection, contract.egress_role) + _revoke_members_of(connection, contract.action_role) _revoke_members_of(connection, contract.learning_role) _revoke_members_of(connection, contract.security_operator_role) _revoke_members_of(connection, contract.definer_role) @@ -416,6 +453,7 @@ def provision_security_roles( _revoke_members_of(connection, contract.release_definer_role) _revoke_members_of(connection, contract.delivery_evidence_definer_role) _revoke_members_of(connection, contract.egress_grant_definer_role) + _revoke_members_of(connection, contract.action_prepare_definer_role) connection.execute( sql.SQL("GRANT {} TO {} WITH ADMIN FALSE, INHERIT FALSE, SET TRUE").format( sql.Identifier(contract.definer_role), @@ -428,6 +466,12 @@ def provision_security_roles( sql.Identifier(contract.migrator_role), ) ) + connection.execute( + sql.SQL("GRANT {} TO {} WITH ADMIN FALSE, INHERIT FALSE, SET TRUE").format( + sql.Identifier(contract.action_prepare_definer_role), + sql.Identifier(contract.migrator_role), + ) + ) connection.execute( sql.SQL("GRANT {} TO {} WITH ADMIN FALSE, INHERIT FALSE, SET TRUE").format( sql.Identifier(contract.context_run_reader_definer_role), @@ -457,6 +501,7 @@ def provision_security_roles( contract.control_role, contract.identity_role, contract.egress_role, + contract.action_role, contract.learning_role, contract.security_operator_role, contract.definer_role, @@ -465,6 +510,7 @@ def provision_security_roles( contract.release_definer_role, contract.delivery_evidence_definer_role, contract.egress_grant_definer_role, + contract.action_prepare_definer_role, ): connection.execute( sql.SQL("REVOKE ALL PRIVILEGES ON DATABASE {} FROM {}").format( @@ -494,6 +540,12 @@ def provision_security_roles( sql.Identifier(contract.egress_role), ) ) + connection.execute( + sql.SQL("GRANT CONNECT ON DATABASE {} TO {}").format( + sql.Identifier(contract.database_name), + sql.Identifier(contract.action_role), + ) + ) connection.execute( sql.SQL("GRANT CONNECT ON DATABASE {} TO {}").format( sql.Identifier(contract.database_name), diff --git a/scripts/security_gate/rls.py b/scripts/security_gate/rls.py index a868e3bb..e55abc8a 100644 --- a/scripts/security_gate/rls.py +++ b/scripts/security_gate/rls.py @@ -16,6 +16,9 @@ TENANT_CLASSIFICATION = "tenant_owned" PINNED_GLOBAL_TABLES = frozenset({"alembic_version", "organization", "user_account"}) NON_OWNER_EVIDENCE_BY_TABLE: Mapping[str, str] = { + "action_delivery_attempt": "PG-ACTION-PREPARE-067", + "action_prepare_audit": "PG-ACTION-PREPARE-067", + "action_ticket": "PG-ACTION-PREPARE-067", "context_source": "PG-FILE-SOURCE-RLS-021", "delivery_evidence": "PG-DELIVERY-EVIDENCE-063", "egress_grant": "PG-EGRESS-011", diff --git a/scripts/security_gate/runner.py b/scripts/security_gate/runner.py index b4050d67..64a95236 100644 --- a/scripts/security_gate/runner.py +++ b/scripts/security_gate/runner.py @@ -74,6 +74,8 @@ "CONTEXT_ENGINE_IDENTITY_PASSWORD", "CONTEXT_ENGINE_EGRESS_ROLE", "CONTEXT_ENGINE_EGRESS_PASSWORD", + "CONTEXT_ENGINE_ACTION_ROLE", + "CONTEXT_ENGINE_ACTION_PASSWORD", "CONTEXT_ENGINE_RUNTIME_ROLE", "CONTEXT_ENGINE_RUNTIME_PASSWORD", "CONTEXT_ENGINE_WORKER_ROLE", @@ -86,6 +88,7 @@ "CONTEXT_ENGINE_CONTROL_DATABASE_URL", "CONTEXT_ENGINE_IDENTITY_DATABASE_URL", "CONTEXT_ENGINE_EGRESS_DATABASE_URL", + "CONTEXT_ENGINE_ACTION_DATABASE_URL", "CONTEXT_ENGINE_RUNTIME_DATABASE_URL", "CONTEXT_ENGINE_WORKER_DATABASE_URL", "CONTEXT_ENGINE_LEARNING_DATABASE_URL", diff --git a/scripts/validate_security_catalog.py b/scripts/validate_security_catalog.py index 51c617f9..78e84a76 100644 --- a/scripts/validate_security_catalog.py +++ b/scripts/validate_security_catalog.py @@ -400,6 +400,9 @@ "TRACE-REDACTION-012": ("PG-FIELD-PROJECTION-048",), } REQUIRED_POSTGRES_EVIDENCE["TRANSPORT-UNTRUSTED-008"] = ("PG-DELIVERY-EVIDENCE-063",) +REQUIRED_POSTGRES_EVIDENCE["ACTION-SEPARATION-014"] = ( + "PG-ACTION-PREPARE-067", +) ACCEPT_002_ACTIVE_CARRIER: dict[str, str] = { "statusAtM0": "available", @@ -1295,6 +1298,64 @@ ], } +CANONICAL_ACTION_PREPARE_ACTIVATION: dict[str, object] = { + "issueRef": "#67", + "invariantRef": "ACTION-SEPARATION-014", + "carrier": ( + "ActionPlane.prepare private create-placeholder, finalize-reply, or " + "follow-up ticket" + ), + "status": "active_fail_closed", + "policyEpochScope": "organization-v0", + "controlBoundary": ( + "trusted co-resident intent -> closed TypeScript ActionPlane.prepare -> " + "least-privilege PostgreSQL prepare function -> operation-specific " + "audience-bound ActionTicket" + ), + "testEvidence": [ + { + "id": "PG-ACTION-PREPARE-067", + "surface": ( + "tests/integration/test_action_prepare.py::" + "test_private_prepare_is_exact_idempotent_digest_only_and_restricted" + ), + "oracle": ( + "A packaged TypeScript ActionPlane calling real PostgreSQL under " + "the dedicated non-owner action role prepares distinct signed " + "CreatePlaceholder, FinalizeReply, and PrivateFollowup tickets " + "only for one exact current private delivery authority; identical " + "retry returns the same durable ticket, while every one-field " + "binding mutation, approval mismatch, expired evidence, stale or " + "disabled source, and conflicting idempotency use returns only a " + "closed zero-effect outcome. FORCE-RLS storage retains digests " + "and restricted decision categories, and the caller has no direct " + "table access or Sender surface." + ), + } + ], + "deferredEvidence": [ + "ActionPlane.perform one-shot ticket redemption", + "provider receipt and ambiguous-result reconciliation", + "production Sender conformance", + "group AudienceSnapshot revalidation", + ], + "futureCarriers": [ + "ActionPlane.perform", + "Sender", + "BotDelivery application", + "group-public delivery", + "provider receipt reconciliation", + ], + "notActive": [ + "external channel write or business effect", + "ActionTicket consumption", + "Sender or provider network call", + "Applied, AlreadyApplied, or ReconciliationRequired outcome", + "group AudienceSnapshot", + "full ACCEPT-012 pass", + ], +} + CANONICAL_ACTIVATIONS: list[dict[str, object]] = [ CANONICAL_REVOCATION_ACTIVATION, CANONICAL_UNAVAILABLE_CAPABILITY_ACTIVATION, @@ -1306,6 +1367,7 @@ CANONICAL_EGRESS_GRANT_ACTIVATION, CANONICAL_OPENAPI_V0_ACTIVATION, CANONICAL_TYPESCRIPT_SDK_ACTIVATION, + CANONICAL_ACTION_PREPARE_ACTIVATION, ] CANONICAL_ACTIVATION_ISSUE_LIST = ", ".join( f"Issue {activation['issueRef']}" for activation in CANONICAL_ACTIVATIONS diff --git a/scripts/wait_for_database.py b/scripts/wait_for_database.py index cefa3cbc..12f94598 100644 --- a/scripts/wait_for_database.py +++ b/scripts/wait_for_database.py @@ -17,6 +17,7 @@ load_harness_database_configurations, ) from engine.persistence.role_guard import ( + assert_action_role, assert_egress_role, assert_identity_role, assert_learning_role, @@ -37,6 +38,7 @@ def wait_for_database(timeout_seconds: float) -> None: configurations.control, configurations.identity, configurations.egress, + configurations.action, configurations.runtime, configurations.worker, configurations.learning, @@ -61,6 +63,8 @@ def wait_for_database(timeout_seconds: float) -> None: assert_identity_role(connection) if configuration.purpose is DatabasePurpose.TRUSTED_EGRESS: assert_egress_role(connection) + if configuration.purpose is DatabasePurpose.TRUSTED_ACTION: + assert_action_role(connection) return except SQLAlchemyError as error: last_error = error @@ -79,7 +83,7 @@ def main(argv: Sequence[str] | None = None) -> int: arguments = parser.parse_args(argv) wait_for_database(arguments.timeout) purpose_names = ( - "migration, control, identity, egress, runtime, worker, learning, " + "migration, control, identity, egress, action, runtime, worker, learning, " "security-operator, " "security-test" ) diff --git a/tests/catalog/test_m0_security_gate.py b/tests/catalog/test_m0_security_gate.py index ff4a8dc6..fe2ebc04 100644 --- a/tests/catalog/test_m0_security_gate.py +++ b/tests/catalog/test_m0_security_gate.py @@ -51,6 +51,14 @@ def test_security_gate_database_contract_includes_the_egress_role() -> None: } <= _ALLOWED_DATABASE_ENVIRONMENT_KEYS +def test_security_gate_database_contract_includes_the_action_role() -> None: + assert { + "CONTEXT_ENGINE_ACTION_ROLE", + "CONTEXT_ENGINE_ACTION_PASSWORD", + "CONTEXT_ENGINE_ACTION_DATABASE_URL", + } <= _ALLOWED_DATABASE_ENVIRONMENT_KEYS + + def complete_provenance(*, commit: str = "a" * 40) -> dict[str, object]: digest = "b" * 64 return { diff --git a/tests/catalog/test_validate_security_catalog.py b/tests/catalog/test_validate_security_catalog.py index 50229db3..6ccf6a7b 100644 --- a/tests/catalog/test_validate_security_catalog.py +++ b/tests/catalog/test_validate_security_catalog.py @@ -21,6 +21,7 @@ ACCEPT_012_UNAVAILABLE_CARRIER, ACL_PROOF_CASE_IDS, AUDIENCE_ACTION_CASE_IDS, + CANONICAL_ACTION_PREPARE_ACTIVATION, CANONICAL_ACTIVATION_ISSUE_LIST, CANONICAL_ACTIVATIONS, CANONICAL_CONTEXT_RUN_ACTIVATION, @@ -567,6 +568,7 @@ def make_catalog() -> dict[str, object]: copy.deepcopy(CANONICAL_EGRESS_GRANT_ACTIVATION), copy.deepcopy(CANONICAL_OPENAPI_V0_ACTIVATION), copy.deepcopy(CANONICAL_TYPESCRIPT_SDK_ACTIVATION), + copy.deepcopy(CANONICAL_ACTION_PREPARE_ACTIVATION), ], "invariants": invariants, "fixtures": fixtures, @@ -659,6 +661,7 @@ def make_schema() -> dict[str, object]: {"const": copy.deepcopy(CANONICAL_EGRESS_GRANT_ACTIVATION)}, {"const": copy.deepcopy(CANONICAL_OPENAPI_V0_ACTIVATION)}, {"const": copy.deepcopy(CANONICAL_TYPESCRIPT_SDK_ACTIVATION)}, + {"const": copy.deepcopy(CANONICAL_ACTION_PREPARE_ACTIVATION)}, ], "items": False, }, @@ -1301,6 +1304,35 @@ def test_activation_schema_independently_freezes_record_order_and_values( ) ) + def test_issue_67_action_prepare_activation_stops_before_effects(self) -> None: + catalog = make_catalog() + activation = object_list_at(catalog, "activations")[10] + + self.assertEqual(activation, CANONICAL_ACTION_PREPARE_ACTIVATION) + self.assertEqual(activation["invariantRef"], "ACTION-SEPARATION-014") + self.assertEqual( + object_list_at(activation, "testEvidence")[0]["id"], + "PG-ACTION-PREPARE-067", + ) + future_carriers = activation["futureCarriers"] + not_active = activation["notActive"] + assert isinstance(future_carriers, list) + assert isinstance(not_active, list) + self.assertIn("ActionPlane.perform", future_carriers) + self.assertIn( + "external channel write or business effect", + not_active, + ) + self.assertIn("full ACCEPT-012 pass", not_active) + + activation["notActive"] = [] + self.assert_catalog_error( + catalog, + "activations: must exactly preserve the canonical ordered " + f"{CANONICAL_ACTIVATION_ISSUE_LIST} activation records and their " + "future/NOT_ACTIVE boundaries", + ) + def test_schema_independently_freezes_full_accept_008_as_future(self) -> None: catalog = make_catalog() schema = load_document(DEFAULT_SCHEMA_PATH) @@ -1723,7 +1755,7 @@ def test_tracked_catalog_freezes_issue_19_authority_and_bounded_scope( self.assertEqual(catalog["catalogVersion"], "1.3.0") self.assertEqual( - issue_refs[-10:], + issue_refs[-11:], [ "#15", "#16", @@ -1735,6 +1767,7 @@ def test_tracked_catalog_freezes_issue_19_authority_and_bounded_scope( "#65", "#66", "#64", + "#67", ], ) self.assertIn( diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e45af8c1..43cfefeb 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -11,6 +11,7 @@ from engine.persistence import ( DatabaseConfiguration, HarnessDatabaseConfigurations, + assert_action_role, assert_control_role, assert_runtime_role, assert_security_operator_role, @@ -69,6 +70,28 @@ def egress_configuration( return database_configurations.egress +@pytest.fixture(scope="session") +def action_configuration( + database_configurations: HarnessDatabaseConfigurations, +) -> DatabaseConfiguration: + return database_configurations.action + + +@pytest.fixture(scope="session") +def guarded_action_engine( + action_configuration: DatabaseConfiguration, +) -> Iterator[Engine]: + """Expose only the verified non-owner ActionPlane database engine.""" + + engine = create_database_engine(action_configuration) + try: + with engine.connect() as connection: + assert_action_role(connection) + yield engine + finally: + engine.dispose() + + @pytest.fixture(scope="session") def migration_configuration( database_configurations: HarnessDatabaseConfigurations, diff --git a/tests/integration/test_action_prepare.py b/tests/integration/test_action_prepare.py new file mode 100644 index 00000000..6d44db82 --- /dev/null +++ b/tests/integration/test_action_prepare.py @@ -0,0 +1,577 @@ +from __future__ import annotations + +import json +import os +import subprocess +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import Engine, text +from sqlalchemy.exc import ProgrammingError + +from engine.control import ( + ContextControl, + ControlOperation, + ControlOperatorAuthority, + FileRootRef, + RegisterFileSource, + VerifiedControlOperatorIdentity, +) +from engine.persistence import ( + DatabaseConfiguration, + PostgreSQLControlStore, + PostgreSQLDeliveryEvidenceIssuerPort, + create_database_engine, +) +from engine.runtime.delivery_evidence import ( + DeliveryEvidenceProfile, + PrivateDeliveryEvidenceIssue, + PrivateDeliveryEvidenceIssuer, +) + +pytestmark = pytest.mark.integration +ROOT = Path(__file__).parents[2] +ACTION_PACKAGE = ROOT / "action_plane" / "typescript" +SERVICE_REF = "application:private-bot" +DESTINATION_REF = "private-chat:same-label" +CONSUMER_REF = "consumer:private-bot" +PURPOSE = "context.answer" +EVIDENCE_REF = "der_" + "7" * 64 +EXPIRED_EVIDENCE_REF = "der_" + "6" * 64 + + +class _ControlAuthenticator: + def __init__(self, organization_id: UUID, now: datetime) -> None: + self.organization_id = organization_id + self.now = now + + def authenticate(self, opaque_credential: str) -> VerifiedControlOperatorIdentity: + assert opaque_credential == "credential:action-67" + return VerifiedControlOperatorIdentity( + organization_id=self.organization_id, + operator_ref="operator:action-67", + authentication_binding_ref="binding:control-action-67", + authority_ref="authority:action-67", + allowed_operations=frozenset({ControlOperation.REGISTER_SOURCE}), + valid_from=self.now - timedelta(minutes=1), + expires_at=self.now + timedelta(minutes=10), + ) + + +def _node_database_url(configuration: DatabaseConfiguration) -> str: + return configuration.url.set(drivername="postgresql").render_as_string( + hide_password=False + ) + + +def _issue_evidence( + identity_configuration: DatabaseConfiguration, + *, + organization_id: UUID, + user_id: UUID, + membership_id: UUID, + now: datetime, + evidence_ref: str = EVIDENCE_REF, + issued_at: datetime | None = None, + expires_at: datetime | None = None, +) -> None: + identity_engine = create_database_engine(identity_configuration) + try: + issuer = PrivateDeliveryEvidenceIssuer( + PostgreSQLDeliveryEvidenceIssuerPort(identity_engine), + profile=DeliveryEvidenceProfile( + profile_ref="private-delivery-evidence-v1", + maximum_ttl=timedelta(minutes=5), + ), + reference_factory=lambda: evidence_ref, + resolution_ref_factory=lambda: ( + "dlr_" + ("8" if evidence_ref == EVIDENCE_REF else "6") * 32 + ), + ) + issued = issuer.issue_private( + PrivateDeliveryEvidenceIssue( + organization_id=organization_id, + user_id=user_id, + membership_id=membership_id, + membership_version=1, + authenticated_service_ref=SERVICE_REF, + authentication_binding_ref="binding:private-bot", + request_id=( + "delivery-request-action-67" + if evidence_ref == EVIDENCE_REF + else "delivery-request-action-67-expired" + ), + destination_ref=DESTINATION_REF, + consumer_ref=CONSUMER_REF, + purpose=PURPOSE, + policy_epoch=1, + issued_at=issued_at or now, + expires_at=expires_at or now + timedelta(minutes=4), + ) + ) + assert issued.evidence_ref == evidence_ref + finally: + identity_engine.dispose() + + +def _run_live_prepare( + action_configuration: DatabaseConfiguration, + *, + organization_id: UUID, + other_organization_id: UUID, + user_id: UUID, + membership_id: UUID, + source_id: UUID, + source_version_id: UUID, + expected_active_source: str = "prepared", +) -> dict[str, object]: + environment = { + **os.environ, + "CE_ACTION_DATABASE_URL": _node_database_url(action_configuration), + "CE_ACTION_DELIVERY_EVIDENCE_REF": EVIDENCE_REF, + "CE_ACTION_EXPIRED_EVIDENCE_REF": EXPIRED_EVIDENCE_REF, + "CE_ACTION_MEMBERSHIP_ID": str(membership_id), + "CE_ACTION_ORGANIZATION_ID": str(organization_id), + "CE_ACTION_OTHER_ORGANIZATION_ID": str(other_organization_id), + "CE_ACTION_USER_ID": str(user_id), + "CE_ACTION_SOURCE_ID": str(source_id), + "CE_ACTION_SOURCE_VERSION_ID": str(source_version_id), + "CE_ACTION_EXPECT_ACTIVE_SOURCE": expected_active_source, + } + completed = subprocess.run( + ["node", "test/live-prepare.mjs"], + cwd=ACTION_PACKAGE, + env=environment, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + assert completed.returncode == 0, completed.stderr + result = json.loads(completed.stdout) + assert isinstance(result, dict) + return result + + +@pytest.mark.security_evidence(id="PG-ACTION-PREPARE-067", layer="postgres") +def test_private_prepare_is_exact_idempotent_digest_only_and_restricted( + guarded_action_engine: Engine, + action_configuration: DatabaseConfiguration, + control_configuration: DatabaseConfiguration, + guarded_control_engine: Engine, + identity_configuration: DatabaseConfiguration, + egress_configuration: DatabaseConfiguration, + runtime_configuration: DatabaseConfiguration, + worker_configuration: DatabaseConfiguration, + learning_configuration: DatabaseConfiguration, + operator_configuration: DatabaseConfiguration, + migration_configuration: DatabaseConfiguration, +) -> None: + del guarded_action_engine + now = datetime.now(UTC).replace(microsecond=0) + organization_id, other_organization_id = uuid4(), uuid4() + user_id, membership_id = uuid4(), uuid4() + other_user_id, other_membership_id = uuid4(), uuid4() + migration_engine = create_database_engine(migration_configuration) + try: + application_roles = ( + action_configuration.expected_role, + control_configuration.expected_role, + identity_configuration.expected_role, + egress_configuration.expected_role, + runtime_configuration.expected_role, + worker_configuration.expected_role, + learning_configuration.expected_role, + operator_configuration.expected_role, + ) + with migration_engine.begin() as connection: + for role in application_roles: + observed = tuple( + connection.execute( + text( + "SELECT " + "has_table_privilege(:role, 'action_delivery_attempt', " + "'SELECT'), " + "has_table_privilege(:role, 'action_ticket', 'SELECT'), " + "has_table_privilege(:role, 'action_prepare_audit', " + "'SELECT'), " + "has_function_privilege(:role, " + "'context_action_prepare_private_effect(uuid,bytea," + "bytea,bytea,uuid,uuid,bigint,bytea,bytea,bytea,bytea," + "bytea,bigint,text,text,bytea,bytea,bytea,text,text,text," + "text,integer,bigint,uuid,uuid,text,bigint)', 'EXECUTE')" + ), + {"role": role}, + ).one() + ) + assert observed == ( + False, + False, + False, + role == action_configuration.expected_role, + ) + + catalog = connection.execute( + text( + "SELECT relation.relname, relation.relrowsecurity, " + "relation.relforcerowsecurity, owner.rolname " + "FROM pg_class AS relation " + "JOIN pg_roles AS owner ON owner.oid = relation.relowner " + "WHERE relation.relname IN ('action_delivery_attempt', " + "'action_ticket', 'action_prepare_audit') " + "ORDER BY relation.relname" + ) + ).all() + assert [tuple(row) for row in catalog] == [ + ("action_delivery_attempt", True, True, "context_engine_migrator"), + ("action_prepare_audit", True, True, "context_engine_migrator"), + ("action_ticket", True, True, "context_engine_migrator"), + ] + function_owner = connection.execute( + text( + "SELECT owner.rolname, function.prosecdef, " + "function.proconfig FROM pg_proc AS function " + "JOIN pg_roles AS owner ON owner.oid = function.proowner " + "WHERE function.oid = " + "'context_action_prepare_private_effect(uuid,bytea,bytea," + "bytea,uuid,uuid,bigint,bytea,bytea,bytea,bytea,bytea,bigint," + "text,text,bytea,bytea,bytea,text,text,text,text,integer,bigint," + "uuid,uuid,text,bigint)'::regprocedure" + ) + ).one() + assert function_owner.rolname == "context_engine_action_prepare_definer" + assert function_owner.prosecdef is True + assert sorted(function_owner.proconfig) == [ + "row_security=on", + "search_path=pg_catalog, pg_temp", + ] + definer_privileges = { + (row.table_name, row.privilege_type) + for row in connection.execute( + text( + "SELECT table_name, privilege_type " + "FROM information_schema.table_privileges " + "WHERE table_schema = 'public' AND grantee = " + "'context_engine_action_prepare_definer' AND table_name " + "IN ('action_delivery_attempt', 'action_ticket', " + "'action_prepare_audit')" + ) + ) + } + assert definer_privileges == { + ("action_delivery_attempt", "INSERT"), + ("action_delivery_attempt", "SELECT"), + ("action_prepare_audit", "INSERT"), + ("action_ticket", "INSERT"), + ("action_ticket", "SELECT"), + } + assert not any( + privilege in {"DELETE", "TRUNCATE", "UPDATE"} + for _, privilege in definer_privileges + ) + + connection.execute( + text( + "INSERT INTO organization (organization_id) " + "VALUES (:organization_id), (:other_organization_id)" + ), + { + "organization_id": organization_id, + "other_organization_id": other_organization_id, + }, + ) + connection.execute( + text( + "INSERT INTO user_account (user_id) " + "VALUES (:user_id), (:other_user_id)" + ), + {"user_id": user_id, "other_user_id": other_user_id}, + ) + connection.execute( + text( + "INSERT INTO membership (organization_id, membership_id, " + "user_id, status, membership_version, valid_from) VALUES " + "(:organization_id, :membership_id, :user_id, 'active', 1, " + ":valid_from), " + "(:other_organization_id, :other_membership_id, " + ":other_user_id, 'active', 1, :valid_from)" + ), + { + "organization_id": organization_id, + "membership_id": membership_id, + "user_id": user_id, + "other_organization_id": other_organization_id, + "other_membership_id": other_membership_id, + "other_user_id": other_user_id, + "valid_from": now - timedelta(minutes=1), + }, + ) + + action_engine = create_database_engine(action_configuration) + try: + with action_engine.connect() as connection: + wrong_operation = tuple( + connection.execute( + text( + "SELECT * FROM context_action_prepare_private_effect(" + "NULL::uuid, NULL::bytea, NULL::bytea, NULL::bytea, " + "NULL::uuid, NULL::uuid, NULL::bigint, NULL::bytea, " + "NULL::bytea, NULL::bytea, NULL::bytea, NULL::bytea, " + "NULL::bigint, :operation, NULL::text, NULL::bytea, " + "NULL::bytea, NULL::bytea, NULL::text, NULL::text, " + "NULL::text, NULL::text, NULL::integer, NULL::bigint, " + "NULL::uuid, NULL::uuid, NULL::text, NULL::bigint)" + ), + {"operation": "delete_message"}, + ).one() + ) + assert wrong_operation == ( + "generic_denied", + None, + None, + None, + None, + False, + ) + finally: + action_engine.dispose() + + _issue_evidence( + identity_configuration, + organization_id=organization_id, + user_id=user_id, + membership_id=membership_id, + now=now, + ) + _issue_evidence( + identity_configuration, + organization_id=organization_id, + user_id=user_id, + membership_id=membership_id, + now=now, + evidence_ref=EXPIRED_EVIDENCE_REF, + ) + with migration_engine.begin() as connection: + connection.execute( + text( + "UPDATE delivery_evidence SET issued_at = :issued_at, " + "expires_at = :expires_at WHERE organization_id = " + ":organization_id AND evidence_digest = digest(:evidence_ref, " + "'sha256')" + ), + { + "organization_id": organization_id, + "evidence_ref": EXPIRED_EVIDENCE_REF, + "issued_at": now - timedelta(minutes=5), + "expires_at": now - timedelta(minutes=1), + }, + ) + control_authority = ControlOperatorAuthority( + _ControlAuthenticator(organization_id, now), + call_ttl=timedelta(minutes=5), + clock=lambda: now, + ) + control = ContextControl( + store=PostgreSQLControlStore(guarded_control_engine, clock=lambda: now), + authority=control_authority, + clock=lambda: now, + ) + with control_authority.authorize( + opaque_credential="credential:action-67", + operation=ControlOperation.REGISTER_SOURCE, + request_id="register-source-action-67", + ) as call: + source = control.register_source( + call, + RegisterFileSource( + display_name="Action source", + root_ref=FileRootRef("action-source-root"), + idempotency_key="action-source-67", + ), + ) + subprocess.run( + ["npm", "run", "build"], + cwd=ACTION_PACKAGE, + check=True, + capture_output=True, + text=True, + timeout=30, + ) + result = _run_live_prepare( + action_configuration, + organization_id=organization_id, + other_organization_id=other_organization_id, + user_id=user_id, + membership_id=membership_id, + source_id=source.source_ref.value, + source_version_id=source.active_version.version_ref, + ) + closed_denials = { + "organization": "generic_denied", + "service": "generic_denied", + "binding": "generic_denied", + "destination": "generic_denied", + "consumer": "generic_denied", + "purpose": "generic_denied", + "audience": "audience_changed", + "epoch": "generic_denied", + "membership_version": "generic_denied", + } + assert result == { + "denied": { + "create_placeholder": closed_denials, + "finalize_reply": closed_denials, + "send_private_followup": closed_denials, + }, + "distinctTicketTypes": [ + "CE-CreatePlaceholderActionTicket", + "CE-FinalizeReplyActionTicket", + "CE-SendPrivateFollowupActionTicket", + ], + "effectCount": 0, + "exactRetryIdempotent": True, + "expiredEvidence": "generic_denied", + "matrixEffectCount": 0, + "operation": "create_placeholder", + "payloadConflicts": { + "create_placeholder": "generic_denied", + "finalize_reply": "generic_denied", + "send_private_followup": "generic_denied", + }, + "prepared": "prepared", + "sourceContext": { + "active": "prepared", + "stale": "generic_denied", + }, + } + + with migration_engine.connect() as connection: + counts = connection.execute( + text( + "SELECT " + "(SELECT count(*) FROM action_delivery_attempt), " + "(SELECT count(*) FROM action_ticket), " + "(SELECT count(*) FROM action_prepare_audit)" + ) + ).one() + assert counts[0:2] == (3, 4) + assert counts[2] == 25 + rows = connection.execute( + text( + "SELECT authenticated_service_digest, " + "delivery_evidence_digest, authentication_binding_digest, " + "destination_digest, consumer_digest, purpose_digest, " + "audience_digest, identity_digest, retention_policy_ref, " + "retain_until > created_at AS retained " + "FROM action_delivery_attempt" + ) + ).all() + assert rows + for row in rows: + assert all(len(bytes(value)) == 32 for value in row[:8]) + assert row.retention_policy_ref == ( + "action-digest-audit-retention-v1" + ) + assert row.retained is True + ticket_rows = connection.execute( + text( + "SELECT operation, ticket_audience, length(payload_digest), " + "length(idempotency_digest), length(approval_digest), state " + "FROM action_ticket ORDER BY operation" + ) + ).all() + assert {row.operation for row in ticket_rows} == { + "create_placeholder", + "finalize_reply", + "send_private_followup", + } + assert all(row[2:5] == (32, 32, 32) for row in ticket_rows) + assert all(row.state == "prepared" for row in ticket_rows) + serialized_rows = " ".join( + repr(row) for row in [*rows, *ticket_rows] + ) + for secret in ( + EVIDENCE_REF, + DESTINATION_REF, + SERVICE_REF, + CONSUMER_REF, + "Working…", + "Conflicting payload", + ): + assert secret not in serialized_rows + + with migration_engine.begin() as connection: + connection.execute( + text( + "UPDATE context_source SET lifecycle_state = 'disabled', " + "disabled_version_id = active_version_id, " + "disabled_at = :disabled_at WHERE organization_id = " + ":organization_id AND source_id = :source_id" + ), + { + "disabled_at": datetime.now(UTC), + "organization_id": organization_id, + "source_id": source.source_ref.value, + }, + ) + disabled_result = _run_live_prepare( + action_configuration, + organization_id=organization_id, + other_organization_id=other_organization_id, + user_id=user_id, + membership_id=membership_id, + source_id=source.source_ref.value, + source_version_id=source.active_version.version_ref, + expected_active_source="generic_denied", + ) + assert disabled_result["sourceContext"] == { + "active": "generic_denied", + "stale": "generic_denied", + } + + action_engine = create_database_engine(action_configuration) + try: + with action_engine.connect() as connection: + for table_name in ( + "action_delivery_attempt", + "action_ticket", + "action_prepare_audit", + ): + with pytest.raises(ProgrammingError): + connection.execute(text(f"SELECT * FROM {table_name}")) + connection.rollback() + finally: + action_engine.dispose() + finally: + with migration_engine.begin() as connection: + connection.execute( + text( + "DELETE FROM action_prepare_audit WHERE organization_id = " + ":organization_id" + ), + {"organization_id": organization_id}, + ) + connection.execute( + text( + "DELETE FROM action_ticket WHERE organization_id = " + ":organization_id" + ), + {"organization_id": organization_id}, + ) + connection.execute( + text( + "DELETE FROM action_delivery_attempt WHERE organization_id = " + ":organization_id" + ), + {"organization_id": organization_id}, + ) + connection.execute( + text( + "DELETE FROM delivery_evidence WHERE organization_id = " + ":organization_id" + ), + {"organization_id": organization_id}, + ) + migration_engine.dispose() diff --git a/tests/integration/test_file_import_tracer.py b/tests/integration/test_file_import_tracer.py index fccd4c2b..56a9c01f 100644 --- a/tests/integration/test_file_import_tracer.py +++ b/tests/integration/test_file_import_tracer.py @@ -1582,7 +1582,7 @@ def _assert_structural_file_import_returns_coherent_authorized_units_over_http( connection.execute( text("SELECT version_num FROM alembic_version") ).scalar_one() - == "20260723_0021" + == "20260723_0022" ) diff --git a/tests/integration/test_m0_security_gate_rls.py b/tests/integration/test_m0_security_gate_rls.py index 67711e28..88600ee2 100644 --- a/tests/integration/test_m0_security_gate_rls.py +++ b/tests/integration/test_m0_security_gate_rls.py @@ -30,7 +30,7 @@ def _manifest() -> dict[str, object]: def test_all_manifest_tenant_tables_pass_live_non_owner_rls_audit( guarded_runtime_engine: Engine, ) -> None: - """PG-RLS-ALL-TENANT-TABLES: the live denominator is exactly 40/40.""" + """PG-RLS-ALL-TENANT-TABLES: the live denominator is exactly 43/43.""" with guarded_runtime_engine.connect() as connection: report = audit_live_rls( @@ -41,13 +41,13 @@ def test_all_manifest_tenant_tables_pass_live_non_owner_rls_audit( assert report["passed"] is True assert report["denominator"] == { - "allTables": 43, - "tenantOwned": 40, + "allTables": 46, + "tenantOwned": 43, "global": 3, } assert report["coverage"] == { - "numerator": 40, - "denominator": 40, + "numerator": 43, + "denominator": 43, "percent": 100.0, } assert report["failures"] == [] @@ -86,9 +86,9 @@ def test_no_force_row_level_security_mutation_fails_and_rolls_back( assert mutated["passed"] is False assert mutated["coverage"] == { - "numerator": 39, - "denominator": 40, - "percent": 97.5, + "numerator": 42, + "denominator": 43, + "percent": 97.67, } tenant_tables = cast(list[dict[str, Any]], mutated["tenantTables"]) organization_record = next( @@ -111,8 +111,8 @@ def test_no_force_row_level_security_mutation_fails_and_rolls_back( ) assert restored["passed"] is True assert restored["coverage"] == { - "numerator": 40, - "denominator": 40, + "numerator": 43, + "denominator": 43, "percent": 100.0, } diff --git a/tests/integration/test_membership_schema.py b/tests/integration/test_membership_schema.py index 20368cb5..53245407 100644 --- a/tests/integration/test_membership_schema.py +++ b/tests/integration/test_membership_schema.py @@ -13,6 +13,7 @@ from engine.persistence import DatabaseConfiguration, create_database_engine from engine.persistence.configuration import ( + ACTION_PREPARE_DEFINER_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, EGRESS_GRANT_DEFINER_ROLE, RUNTIME_ROLE, @@ -467,7 +468,8 @@ def test_runtime_worker_and_public_grants_are_least_privilege( AND grantee IN ( 'PUBLIC', :runtime_role, :worker_role, :delivery_evidence_definer_role, - :egress_grant_definer_role + :egress_grant_definer_role, + :action_prepare_definer_role ) """ ), @@ -478,6 +480,7 @@ def test_runtime_worker_and_public_grants_are_least_privilege( DELIVERY_EVIDENCE_DEFINER_ROLE ), "egress_grant_definer_role": EGRESS_GRANT_DEFINER_ROLE, + "action_prepare_definer_role": ACTION_PREPARE_DEFINER_ROLE, }, ) } @@ -527,12 +530,14 @@ def test_runtime_worker_and_public_grants_are_least_privilege( (RUNTIME_ROLE, "membership", "SELECT"), (DELIVERY_EVIDENCE_DEFINER_ROLE, "membership", "SELECT"), (EGRESS_GRANT_DEFINER_ROLE, "membership", "SELECT"), + (ACTION_PREPARE_DEFINER_ROLE, "membership", "SELECT"), } assert security == (True, True) assert set(policies) == { "membership_current_user_actor", "membership_delivery_evidence_definer_select", "membership_egress_definer_select", + "membership_action_prepare_definer_select", "membership_file_import_definer_select", "membership_migrator_administration", } @@ -588,6 +593,13 @@ def test_runtime_worker_and_public_grants_are_least_privilege( "true", None, ) + assert policies["membership_action_prepare_definer_select"] == ( + "PERMISSIVE", + (ACTION_PREPARE_DEFINER_ROLE,), + "SELECT", + "true", + None, + ) migrator_policy = policies["membership_migrator_administration"] assert migrator_policy[:3] == ( diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 4354f12c..a3eac8e6 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -43,8 +43,11 @@ pytestmark = pytest.mark.integration ROOT = Path(__file__).parents[2] -_HEAD_REVISION = "20260723_0021" +_HEAD_REVISION = "20260723_0022" HEAD_TABLES = [ + "action_delivery_attempt", + "action_prepare_audit", + "action_ticket", "active_release_manifest", "alembic_version", "context_fragment", diff --git a/tests/integration/test_postgres_harness.py b/tests/integration/test_postgres_harness.py index ac526e79..1a6340f4 100644 --- a/tests/integration/test_postgres_harness.py +++ b/tests/integration/test_postgres_harness.py @@ -18,6 +18,8 @@ ) from engine.persistence.configuration import ( ACCESS_POLICY_DEFINER_ROLE, + ACTION_PREPARE_DEFINER_ROLE, + ACTION_ROLE, CONTEXT_RUN_READER_DEFINER_ROLE, CONTROL_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, @@ -95,6 +97,7 @@ def test_all_login_roles_have_reviewed_capabilities( control_configuration: DatabaseConfiguration, identity_configuration: DatabaseConfiguration, egress_configuration: DatabaseConfiguration, + action_configuration: DatabaseConfiguration, runtime_configuration: DatabaseConfiguration, worker_configuration: DatabaseConfiguration, learning_configuration: DatabaseConfiguration, @@ -105,6 +108,7 @@ def test_all_login_roles_have_reviewed_capabilities( control_configuration, identity_configuration, egress_configuration, + action_configuration, runtime_configuration, worker_configuration, learning_configuration, @@ -123,6 +127,7 @@ def test_all_login_roles_have_reviewed_capabilities( CONTROL_ROLE, IDENTITY_ROLE, EGRESS_ROLE, + ACTION_ROLE, RUNTIME_ROLE, WORKER_ROLE, LEARNING_ROLE, @@ -146,8 +151,10 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( guarded_control_engine: Engine, guarded_learning_engine: Engine, guarded_operator_engine: Engine, + guarded_action_engine: Engine, identity_configuration: DatabaseConfiguration, egress_configuration: DatabaseConfiguration, + action_configuration: DatabaseConfiguration, ) -> None: contract = RoleProvisioningContract( database_name=os.environ["POSTGRES_DB"], @@ -161,6 +168,8 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( identity_password=os.environ["CONTEXT_ENGINE_IDENTITY_PASSWORD"], egress_role=EGRESS_ROLE, egress_password=os.environ["CONTEXT_ENGINE_EGRESS_PASSWORD"], + action_role=ACTION_ROLE, + action_password=os.environ["CONTEXT_ENGINE_ACTION_PASSWORD"], learning_role=LEARNING_ROLE, learning_password=os.environ["CONTEXT_ENGINE_LEARNING_PASSWORD"], security_operator_role=OPERATOR_ROLE, @@ -173,16 +182,20 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( release_definer_role=RELEASE_DEFINER_ROLE, delivery_evidence_definer_role=DELIVERY_EVIDENCE_DEFINER_ROLE, egress_grant_definer_role=EGRESS_GRANT_DEFINER_ROLE, + action_prepare_definer_role=ACTION_PREPARE_DEFINER_ROLE, ) alembic_configuration = Config(ROOT / "alembic.ini") try: identity_engine = create_database_engine(identity_configuration) egress_engine = create_database_engine(egress_configuration) + action_engine = create_database_engine(action_configuration) guarded_control_engine.dispose() guarded_learning_engine.dispose() guarded_operator_engine.dispose() + guarded_action_engine.dispose() identity_engine.dispose() egress_engine.dispose() + action_engine.dispose() clear_file_source_progress_projection(migration_configuration) command.downgrade(alembic_configuration, "20260721_0004") with psycopg.connect( @@ -201,10 +214,12 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( CONTEXT_RUN_READER_DEFINER_ROLE, DELIVERY_EVIDENCE_DEFINER_ROLE, EGRESS_GRANT_DEFINER_ROLE, + ACTION_PREPARE_DEFINER_ROLE, RELEASE_DEFINER_ROLE, CONTROL_ROLE, IDENTITY_ROLE, EGRESS_ROLE, + ACTION_ROLE, LEARNING_ROLE, OPERATOR_ROLE, ): @@ -216,7 +231,7 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( SELECT count(*) FROM pg_roles WHERE rolname IN ( - %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) """, ( @@ -231,6 +246,8 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( IDENTITY_ROLE, EGRESS_ROLE, EGRESS_GRANT_DEFINER_ROLE, + ACTION_ROLE, + ACTION_PREPARE_DEFINER_ROLE, ), ).fetchone() assert missing_roles == (0,) @@ -394,6 +411,7 @@ def test_post_init_role_provisioning_repairs_a_legacy_volume_idempotently( guarded_control_engine.dispose() guarded_learning_engine.dispose() guarded_operator_engine.dispose() + guarded_action_engine.dispose() with psycopg.connect( host="127.0.0.1", port=contract.postgres_port, diff --git a/tests/integration/test_zz_file_content_noop.py b/tests/integration/test_zz_file_content_noop.py index 3058f330..5c15a705 100644 --- a/tests/integration/test_zz_file_content_noop.py +++ b/tests/integration/test_zz_file_content_noop.py @@ -278,7 +278,7 @@ def test_repeated_canonically_identical_file_import_is_an_auditable_noop( connection.execute( text("SELECT version_num FROM alembic_version") ).scalar_one() - == "20260723_0021" + == "20260723_0022" ) diff --git a/tests/unit/test_database_configuration.py b/tests/unit/test_database_configuration.py index cfeea1a6..a88f1c1c 100644 --- a/tests/unit/test_database_configuration.py +++ b/tests/unit/test_database_configuration.py @@ -8,6 +8,7 @@ from sqlalchemy.pool import QueuePool from engine.persistence.configuration import ( + ACTION_ROLE, CONTROL_ROLE, EGRESS_ROLE, IDENTITY_ROLE, @@ -46,6 +47,10 @@ def database_environment() -> dict[str, str]: "postgresql+psycopg://context_engine_egress:egress-secret@" "127.0.0.1:5432/context_engine" ), + "CONTEXT_ENGINE_ACTION_DATABASE_URL": ( + "postgresql+psycopg://context_engine_action:action-secret@" + "127.0.0.1:5432/context_engine" + ), "CONTEXT_ENGINE_WORKER_DATABASE_URL": ( "postgresql+psycopg://context_engine_worker:worker-secret@" "127.0.0.1:5432/context_engine" @@ -67,6 +72,7 @@ def database_environment() -> dict[str, str]: "CONTEXT_ENGINE_CONTROL_ROLE": CONTROL_ROLE, "CONTEXT_ENGINE_IDENTITY_ROLE": IDENTITY_ROLE, "CONTEXT_ENGINE_EGRESS_ROLE": EGRESS_ROLE, + "CONTEXT_ENGINE_ACTION_ROLE": ACTION_ROLE, "CONTEXT_ENGINE_WORKER_ROLE": WORKER_ROLE, "CONTEXT_ENGINE_LEARNING_ROLE": LEARNING_ROLE, "CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE": OPERATOR_ROLE, @@ -80,6 +86,7 @@ def database_environment() -> dict[str, str]: (DatabasePurpose.CONTROL_PLANE, "CONTEXT_ENGINE_CONTROL_DATABASE_URL"), (DatabasePurpose.TRUSTED_IDENTITY, "CONTEXT_ENGINE_IDENTITY_DATABASE_URL"), (DatabasePurpose.TRUSTED_EGRESS, "CONTEXT_ENGINE_EGRESS_DATABASE_URL"), + (DatabasePurpose.TRUSTED_ACTION, "CONTEXT_ENGINE_ACTION_DATABASE_URL"), (DatabasePurpose.API_RUNTIME, "CONTEXT_ENGINE_RUNTIME_DATABASE_URL"), (DatabasePurpose.SUPPLY_WORKER, "CONTEXT_ENGINE_WORKER_DATABASE_URL"), (DatabasePurpose.LEARNING, "CONTEXT_ENGINE_LEARNING_DATABASE_URL"), @@ -124,6 +131,11 @@ def test_runtime_never_falls_back_to_migration_credentials() -> None: "CONTEXT_ENGINE_IDENTITY_DATABASE_URL", MIGRATOR_ROLE, ), + ( + DatabasePurpose.TRUSTED_ACTION, + "CONTEXT_ENGINE_ACTION_DATABASE_URL", + MIGRATOR_ROLE, + ), ( DatabasePurpose.API_RUNTIME, "CONTEXT_ENGINE_RUNTIME_DATABASE_URL", @@ -213,6 +225,7 @@ def test_harness_contract_keeps_roles_distinct_and_test_uses_runtime() -> None: assert configurations.migration.expected_role == MIGRATOR_ROLE assert configurations.control.expected_role == CONTROL_ROLE assert configurations.egress.expected_role == EGRESS_ROLE + assert configurations.action.expected_role == ACTION_ROLE assert configurations.runtime.expected_role == RUNTIME_ROLE assert configurations.worker.expected_role == WORKER_ROLE assert configurations.learning.expected_role == LEARNING_ROLE diff --git a/tests/unit/test_database_harness_behavior.py b/tests/unit/test_database_harness_behavior.py index 5cb916bc..da58951c 100644 --- a/tests/unit/test_database_harness_behavior.py +++ b/tests/unit/test_database_harness_behavior.py @@ -371,6 +371,51 @@ def test_partial_legacy_learning_identity_is_replaced_as_one_exact_triple( ) +def test_partial_legacy_action_identity_is_replaced_as_one_exact_triple( + tmp_path: Path, +) -> None: + stub_directory = tmp_path / "bin" + stub_directory.mkdir() + _stub_harness_dependencies(stub_directory) + checkout = tmp_path / "checkout" + project, _ = _run_stubbed_harness(checkout, stub_directory) + environment_path = checkout / ".context-engine/database.env" + non_action = [ + line + for line in environment_path.read_text(encoding="utf-8").splitlines() + if not line.startswith("CONTEXT_ENGINE_ACTION_") + ] + environment_path.write_text( + "\n".join( + [ + *non_action, + "CONTEXT_ENGINE_ACTION_ROLE=context_engine_action", + "CONTEXT_ENGINE_ACTION_ROLE=duplicate_action_role", + ] + ) + + "\n", + encoding="utf-8", + ) + environment_path.chmod(0o600) + + migrated_project, _ = _run_stubbed_harness(checkout, stub_directory) + + lines = environment_path.read_text(encoding="utf-8").splitlines() + action_lines = [ + line for line in lines if line.startswith("CONTEXT_ENGINE_ACTION_") + ] + migrated = dict(line.split("=", maxsplit=1) for line in lines) + assert migrated_project == project + assert len(action_lines) == 3 + assert len({line.split("=", maxsplit=1)[0] for line in action_lines}) == 3 + password = migrated["CONTEXT_ENGINE_ACTION_PASSWORD"] + assert migrated["CONTEXT_ENGINE_ACTION_DATABASE_URL"] == ( + "postgresql+psycopg://context_engine_action:" + f"{password}@127.0.0.1:" + f"{migrated['CONTEXT_ENGINE_POSTGRES_PORT']}/context_engine" + ) + + def test_concurrent_legacy_learning_migration_generates_one_password( tmp_path: Path, ) -> None: diff --git a/tests/unit/test_database_harness_contract.py b/tests/unit/test_database_harness_contract.py index 3e60d955..7dfb0c95 100644 --- a/tests/unit/test_database_harness_contract.py +++ b/tests/unit/test_database_harness_contract.py @@ -48,6 +48,8 @@ def test_compose_project_identity_is_generated_per_checkout() -> None: "migrator_role", "control_role", "identity_role", + "egress_role", + "action_role", "runtime_role", "worker_role", "learning_role", @@ -83,6 +85,8 @@ def test_database_harness_generates_secret_state_and_never_sources_it() -> None: assert "CONTEXT_ENGINE_IDENTITY_DATABASE_URL" in script assert "CONTEXT_ENGINE_EGRESS_ROLE=context_engine_egress" in script assert "CONTEXT_ENGINE_EGRESS_DATABASE_URL" in script + assert "CONTEXT_ENGINE_ACTION_ROLE=context_engine_action" in script + assert "CONTEXT_ENGINE_ACTION_DATABASE_URL" in script assert ( "CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE=context_engine_security_operator" in script @@ -100,6 +104,8 @@ def test_compose_passes_dedicated_operator_credentials_to_bootstrap() -> None: assert "CONTEXT_ENGINE_IDENTITY_PASSWORD" in compose assert "CONTEXT_ENGINE_EGRESS_ROLE" in compose assert "CONTEXT_ENGINE_EGRESS_PASSWORD" in compose + assert "CONTEXT_ENGINE_ACTION_ROLE" in compose + assert "CONTEXT_ENGINE_ACTION_PASSWORD" in compose assert "CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE" in compose assert "CONTEXT_ENGINE_SECURITY_OPERATOR_PASSWORD" in compose assert "CONTEXT_ENGINE_LEARNING_ROLE" in compose @@ -112,6 +118,7 @@ def test_readiness_probe_includes_dedicated_operator_configuration() -> None: assert "configurations.control" in wait_script assert "configurations.identity" in wait_script assert "configurations.egress" in wait_script + assert "configurations.action" in wait_script assert "configurations.learning" in wait_script assert "configurations.operator" in wait_script assert ( @@ -123,7 +130,7 @@ def test_readiness_probe_includes_dedicated_operator_configuration() -> None: ) assert " assert_learning_role(connection)" in wait_script assert ( - "migration, control, identity, egress, runtime, worker, learning, " + "migration, control, identity, egress, action, runtime, worker, learning, " in wait_script ) assert '"security-operator, "' in wait_script @@ -218,7 +225,8 @@ def test_ci_runs_the_same_make_database_contract_as_local() -> None: assert "make db-down" in workflow assert ( "check: build lint typecheck openapi-check sdk-check sdk-build sdk-test " - "sdk-pack test catalog smoke integration security-gate" in makefile + "sdk-pack action-build action-test test catalog smoke integration " + "security-gate" in makefile ) assert "./scripts/database_harness.sh integration" in makefile assert "--baseline-ref $(OPENAPI_BASELINE_REF)" in makefile @@ -235,7 +243,8 @@ def test_ci_runs_and_retains_the_single_m0_security_gate_contract() -> None: ) assert ( "check: build lint typecheck openapi-check sdk-check sdk-build sdk-test " - "sdk-pack test catalog smoke integration security-gate" + "sdk-pack action-build action-test test catalog smoke integration " + "security-gate" in makefile.splitlines() ) assert "actions/upload-artifact@v4" in workflow diff --git a/tests/unit/test_learning_database_configuration.py b/tests/unit/test_learning_database_configuration.py index d260dd6d..3f3c87b7 100644 --- a/tests/unit/test_learning_database_configuration.py +++ b/tests/unit/test_learning_database_configuration.py @@ -3,6 +3,7 @@ import pytest from engine.persistence.configuration import ( + ACTION_ROLE, CONTROL_ROLE, EGRESS_ROLE, IDENTITY_ROLE, @@ -36,6 +37,10 @@ def _database_environment() -> dict[str, str]: "postgresql+psycopg://context_engine_egress:egress-secret@" "127.0.0.1:5432/context_engine" ), + "CONTEXT_ENGINE_ACTION_DATABASE_URL": ( + "postgresql+psycopg://context_engine_action:action-secret@" + "127.0.0.1:5432/context_engine" + ), "CONTEXT_ENGINE_RUNTIME_DATABASE_URL": ( "postgresql+psycopg://context_engine_runtime:runtime-secret@" "127.0.0.1:5432/context_engine" @@ -60,6 +65,7 @@ def _database_environment() -> dict[str, str]: "CONTEXT_ENGINE_CONTROL_ROLE": CONTROL_ROLE, "CONTEXT_ENGINE_IDENTITY_ROLE": IDENTITY_ROLE, "CONTEXT_ENGINE_EGRESS_ROLE": EGRESS_ROLE, + "CONTEXT_ENGINE_ACTION_ROLE": ACTION_ROLE, "CONTEXT_ENGINE_RUNTIME_ROLE": RUNTIME_ROLE, "CONTEXT_ENGINE_WORKER_ROLE": WORKER_ROLE, "CONTEXT_ENGINE_LEARNING_ROLE": LEARNING_ROLE, @@ -90,6 +96,8 @@ def test_harness_exposes_learning_as_a_distinct_login() -> None: configurations.migration.expected_role, configurations.control.expected_role, configurations.identity.expected_role, + configurations.egress.expected_role, + configurations.action.expected_role, configurations.runtime.expected_role, configurations.worker.expected_role, configurations.learning.expected_role, @@ -98,6 +106,8 @@ def test_harness_exposes_learning_as_a_distinct_login() -> None: MIGRATOR_ROLE, CONTROL_ROLE, IDENTITY_ROLE, + EGRESS_ROLE, + ACTION_ROLE, RUNTIME_ROLE, WORKER_ROLE, LEARNING_ROLE, diff --git a/tests/unit/test_learning_database_role_provisioning.py b/tests/unit/test_learning_database_role_provisioning.py index f11a8dfd..d506b4aa 100644 --- a/tests/unit/test_learning_database_role_provisioning.py +++ b/tests/unit/test_learning_database_role_provisioning.py @@ -19,6 +19,8 @@ def _provisioning_environment() -> dict[str, str]: "CONTEXT_ENGINE_IDENTITY_PASSWORD": "e" * 64, "CONTEXT_ENGINE_EGRESS_ROLE": "context_engine_egress", "CONTEXT_ENGINE_EGRESS_PASSWORD": "8" * 64, + "CONTEXT_ENGINE_ACTION_ROLE": "context_engine_action", + "CONTEXT_ENGINE_ACTION_PASSWORD": "9" * 64, "CONTEXT_ENGINE_LEARNING_ROLE": LEARNING_ROLE, "CONTEXT_ENGINE_LEARNING_PASSWORD": "c" * 64, "CONTEXT_ENGINE_SECURITY_OPERATOR_ROLE": "context_engine_security_operator", diff --git a/tests/unit/test_m0_delivery_carriers.py b/tests/unit/test_m0_delivery_carriers.py index 3ef28c88..521cc9bc 100644 --- a/tests/unit/test_m0_delivery_carriers.py +++ b/tests/unit/test_m0_delivery_carriers.py @@ -9,7 +9,7 @@ def test_m0_egress_carrier_is_unavailable_before_model_or_sender_bytes() -> None """The grant tracer activates without a real provider or effect process.""" assert importlib.util.find_spec("bot_delivery") is not None - assert importlib.util.find_spec("action_plane") is None + assert importlib.util.find_spec("action_plane") is not None for public_name in ( "ActionPlane", "ModelGateway", diff --git a/tests/unit/test_m0_rls_inventory.py b/tests/unit/test_m0_rls_inventory.py index 9a283473..359bae77 100644 --- a/tests/unit/test_m0_rls_inventory.py +++ b/tests/unit/test_m0_rls_inventory.py @@ -15,6 +15,9 @@ GLOBAL_TABLES = {"alembic_version", "organization", "user_account"} TENANT_TABLES = { "active_release_manifest", + "action_delivery_attempt", + "action_prepare_audit", + "action_ticket", "context_fragment", "context_fragment_field", "context_resource", @@ -137,7 +140,7 @@ def test_manifest_declares_exact_live_table_denominator_and_rls_evidence() -> No assert global_tables == GLOBAL_TABLES assert tenant_tables == TENANT_TABLES - assert len(tables) == 43 + assert len(tables) == 46 for name in sorted(GLOBAL_TABLES): rationale = tables[name]["classificationRationale"] @@ -160,8 +163,8 @@ def test_rls_auditor_requires_every_live_control_and_non_owner_evidence() -> Non assert report["passed"] is True assert report["coverage"] == { - "numerator": 40, - "denominator": 40, + "numerator": 43, + "denominator": 43, "percent": 100.0, } inventory = cast(dict[str, object], report["inventory"]) @@ -186,7 +189,7 @@ def test_rls_auditor_does_not_count_force_rls_or_evidence_gaps() -> None: assert report["passed"] is False assert report["coverage"] == { "numerator": 0, - "denominator": 40, + "denominator": 43, "percent": 0.0, } tenant_reports = cast(list[dict[str, Any]], report["tenantTables"]) diff --git a/tests/unit/test_schema_security_manifest.py b/tests/unit/test_schema_security_manifest.py index dc2f4a7d..489a1452 100644 --- a/tests/unit/test_schema_security_manifest.py +++ b/tests/unit/test_schema_security_manifest.py @@ -32,9 +32,12 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: document = manifest() tables = table_entries(document) - assert document["manifestVersion"] == "20.0.0" + assert document["manifestVersion"] == "21.0.0" assert set(tables) == { "active_release_manifest", + "action_delivery_attempt", + "action_prepare_audit", + "action_ticket", "alembic_version", "context_fragment", "context_fragment_field", @@ -98,12 +101,25 @@ def test_manifest_classifies_the_exact_current_release_schema() -> None: ) assert tables["decision_audit"]["classification"] == "tenant_owned" assert tables["delivery_evidence"]["classification"] == "tenant_owned" + assert tables["action_delivery_attempt"]["classification"] == "tenant_owned" + assert tables["action_ticket"]["classification"] == "tenant_owned" + assert tables["action_prepare_audit"]["classification"] == "tenant_owned" assert tables["egress_grant"]["classification"] == "tenant_owned" assert tables["egress_audit"]["classification"] == "tenant_owned" assert tables["service_principal"]["classification"] == "tenant_owned" assert tables["worker_noop_job"]["classification"] == "tenant_owned" assert tables["context_source"]["classification"] == "tenant_owned" assert tables["source_version"]["classification"] == "tenant_owned" + + assert tables["action_delivery_attempt"]["permittedOperations"][ + "context_engine_action_prepare_definer" + ] == ["SELECT", "INSERT"] + assert tables["action_ticket"]["permittedOperations"][ + "context_engine_action_prepare_definer" + ] == ["SELECT", "INSERT"] + assert tables["action_prepare_audit"]["permittedOperations"][ + "context_engine_action_prepare_definer" + ] == ["INSERT"] for file_import_table in ( "exact_phrase_candidate", "file_acquisition", @@ -660,6 +676,7 @@ def test_issue_21_file_source_manifest_is_closed_and_role_separated() -> None: "context_engine_security_operator": [], "context_engine_worker": [], "context_engine_worker_lease_definer": ["SELECT", "UPDATE"], + "context_engine_action_prepare_definer": ["SELECT"], } assert version["permittedOperations"] == { "context_engine_control": ["SELECT", "INSERT"], @@ -668,6 +685,7 @@ def test_issue_21_file_source_manifest_is_closed_and_role_separated() -> None: "context_engine_security_operator": [], "context_engine_worker": [], "context_engine_worker_lease_definer": ["SELECT", "INSERT"], + "context_engine_action_prepare_definer": ["SELECT"], } for entry in (source, version): assert entry["rowLevelSecurity"]["enabled"] is True @@ -1189,6 +1207,7 @@ def test_membership_manifest_requires_exact_user_actor_and_read_only_runtime() - "context_engine_worker_lease_definer": ["SELECT"], "context_engine_delivery_evidence_definer": ["SELECT"], "context_engine_egress_grant_definer": ["SELECT"], + "context_engine_action_prepare_definer": ["SELECT"], } rls = entry["rowLevelSecurity"] @@ -1747,6 +1766,7 @@ def test_policy_epoch_manifest_seals_runtime_reads_and_control_mutation() -> Non if entry["name"] == "organization_policy_epoch": expected_operations["context_engine_delivery_evidence_definer"] = ["SELECT"] expected_operations["context_engine_egress_grant_definer"] = ["SELECT"] + expected_operations["context_engine_action_prepare_definer"] = ["SELECT"] expected_operations["context_engine_control"].append( "EXECUTE context_control_tombstone_file_resource" )