diff --git a/package.json b/package.json index 595d2206..cb0e1179 100644 --- a/package.json +++ b/package.json @@ -111,9 +111,10 @@ "test:redaction": "tsx tests/redaction.test.ts", "test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts", "test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts", - "test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-allocation-lifecycle.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-queue-batch.test.ts && tsx tests/cloudflare-runtime.test.ts && tsx tests/cloudflare-public-reader.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", + "test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-allocation-lifecycle.test.ts && tsx tests/cloudflare-principal-credential-repository.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-queue-batch.test.ts && tsx tests/cloudflare-runtime.test.ts && tsx tests/cloudflare-public-reader.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", "test:cloudflare-queue": "tsx tests/cloudflare-queue-batch.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", "test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts", + "test:cloudflare-principal-credentials": "tsx tests/cloudflare-principal-credential-repository.test.ts && tsx tests/cloudflare-provisioning-api.test.ts", "test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts", "test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts", "test:agent-task-contracts": "tsx tests/agent-task-contracts.test.ts && tsx tests/agent-task-canonical-evidence.test.ts && npm run test:agent-task-workflow-interface && npm run test:runtime-sources-materialization && tsx tests/agent-task-reusable-workflow.test.ts", diff --git a/packages/runtime-cloudflare/src/principal-credential-repository.ts b/packages/runtime-cloudflare/src/principal-credential-repository.ts new file mode 100644 index 00000000..eb833847 --- /dev/null +++ b/packages/runtime-cloudflare/src/principal-credential-repository.ts @@ -0,0 +1,163 @@ +const SCOPES = new Set(["sites:create", "sites:read", "sites:import", "operations:read"]) +const DEFAULT_AUDIT_LIMIT = 10_000 +const UNKNOWN_AUDIT_BUCKET_MS = 5 * 60_000 + +export interface PrincipalCredentialPolicy { + principal: string + scopes: string[] + expiresAt: string + maxSites: number + sites?: string[] +} +export interface PrincipalCredentialVersion extends PrincipalCredentialPolicy { + credentialId: string + version: string + digest: string +} +export interface AuthenticatedPrincipal extends PrincipalCredentialPolicy { + credentialId: string + version: string +} +export interface CredentialAuditEvent { + id: number + at: number + kind: "registered" | "revoked" | "authenticated" | "denied" + principal: string | null + credentialId: string | null + version: string | null + reason: "ok" | "unknown" | "expired" | "revoked" | "scope" | "site" +} +export type CredentialAuthorizationDecision = + | { status: "not-found" } + | { status: "allowed"; principal: AuthenticatedPrincipal } + | { status: "denied"; reason: "expired" | "revoked" | "scope" | "site"; principal: string; credentialId: string; version: string } + +/** Durable bearer policy storage. Bearers are hashed before they cross this boundary. */ +export class D1PrincipalCredentialRepository { + constructor(private readonly db: D1Database, private readonly auditLimit = DEFAULT_AUDIT_LIMIT) { + if (!Number.isSafeInteger(auditLimit) || auditLimit < 1 || auditLimit > DEFAULT_AUDIT_LIMIT) throw new Error("Credential audit limit is invalid.") + } + + async initialize(): Promise { + await this.db.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_principal_credentials (credential_id TEXT NOT NULL, version TEXT NOT NULL, digest TEXT NOT NULL UNIQUE, principal TEXT NOT NULL, scopes TEXT NOT NULL, expires_at INTEGER NOT NULL, max_sites INTEGER NOT NULL, sites TEXT, revoked_at INTEGER, created_at INTEGER NOT NULL, PRIMARY KEY (credential_id, version))").run() + await this.db.prepare("CREATE TABLE IF NOT EXISTS wp_codebox_principal_credential_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('registered','revoked','authenticated','denied')), principal TEXT, credential_id TEXT, version TEXT, reason TEXT NOT NULL CHECK (reason IN ('ok','unknown','expired','revoked','scope','site')), dedupe_key TEXT UNIQUE)").run() + } + + async register(input: PrincipalCredentialVersion): Promise { + validate(input) + await this.initialize() + const expiresAt = Date.parse(input.expiresAt) + const scopes = JSON.stringify(input.scopes) + const sites = input.sites ? JSON.stringify(input.sites) : null + const existing = await this.db.prepare("SELECT digest, principal, scopes, expires_at, max_sites, sites FROM wp_codebox_principal_credentials WHERE credential_id = ? AND version = ?").bind(input.credentialId, input.version).first>() + if (existing) { + if (sameCredential(existing, input, scopes, sites, expiresAt)) return + throw new Error("Credential version is already registered with different identity or policy.") + } + try { + const now = Date.now() + await this.db.batch([ + this.db.prepare("INSERT INTO wp_codebox_principal_credentials (credential_id, version, digest, principal, scopes, expires_at, max_sites, sites, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)").bind(input.credentialId, input.version, input.digest, input.principal, scopes, expiresAt, input.maxSites, sites, now), + this.auditStatement("registered", input.principal, input.credentialId, input.version, "ok", now), + this.trimAuditStatement(), + ]) + } catch (error) { + if (!(error instanceof Error) || !/unique|constraint/i.test(error.message)) throw error + const raced = await this.db.prepare("SELECT digest, principal, scopes, expires_at, max_sites, sites FROM wp_codebox_principal_credentials WHERE credential_id = ? AND version = ?").bind(input.credentialId, input.version).first>() + if (raced && sameCredential(raced, input, scopes, sites, expiresAt)) return + throw new Error("Credential digest is already registered.") + } + } + + async revoke(credentialId: string, version: string, now = Date.now()): Promise { + if (!identifier(credentialId) || !identifier(version) || !Number.isSafeInteger(now) || now < 1) throw new Error("Credential revocation request is invalid.") + await this.initialize() + const row = await this.db.prepare("SELECT principal FROM wp_codebox_principal_credentials WHERE credential_id = ? AND version = ?").bind(credentialId, version).first<{ principal: string }>() + if (!row) return false + const [result] = await this.db.batch([ + this.db.prepare("UPDATE wp_codebox_principal_credentials SET revoked_at = ? WHERE credential_id = ? AND version = ? AND revoked_at IS NULL").bind(now, credentialId, version), + this.db.prepare("INSERT INTO wp_codebox_principal_credential_audit (at, kind, principal, credential_id, version, reason, dedupe_key) SELECT ?, 'revoked', ?, ?, ?, 'ok', NULL WHERE changes() = 1").bind(now, row.principal, credentialId, version), + this.trimAuditStatement(), + ]) + return result.meta.changes === 1 + } + + async authenticate(bearer: string, now = Date.now()): Promise { + const decision = await this.authorizeDigest(await bearerDigest(bearer), undefined, undefined, now) + if (decision.status === "not-found") await this.recordUnknownDenial(now) + return decision.status === "allowed" ? decision.principal : null + } + + async authorize(bearer: string, scope: string, siteId?: string, now = Date.now()): Promise { + const decision = await this.authorizeDigest(await bearerDigest(bearer), scope, siteId, now) + if (decision.status === "not-found") await this.recordUnknownDenial(now) + return decision.status === "allowed" ? decision.principal : null + } + + async authorizeDigest(digest: string, scope?: string, siteId?: string, now = Date.now()): Promise { + if (!/^[a-f0-9]{64}$/.test(digest) || (scope !== undefined && !SCOPES.has(scope)) || (siteId !== undefined && !validSiteId(siteId)) || !Number.isSafeInteger(now) || now < 1) throw new Error("Credential authorization request is invalid.") + await this.initialize() + const row = await this.db.prepare("SELECT credential_id, version, principal, scopes, expires_at, max_sites, sites, revoked_at FROM wp_codebox_principal_credentials WHERE digest = ?").bind(digest).first>() + if (!row) return { status: "not-found" } + const value = hydrate(row) + const reason = row.revoked_at !== null ? "revoked" : Date.parse(value.expiresAt) <= now ? "expired" : scope && !value.scopes.includes(scope) ? "scope" : siteId && value.sites && !value.sites.includes(siteId) ? "site" : null + if (reason) { + await this.audit("denied", value.principal, value.credentialId, value.version, reason, now) + return { status: "denied", reason, principal: value.principal, credentialId: value.credentialId, version: value.version } + } + await this.audit("authenticated", value.principal, value.credentialId, value.version, "ok", now) + return { status: "allowed", principal: value } + } + + async recordUnknownDenial(now = Date.now()): Promise { + if (!Number.isSafeInteger(now) || now < 1) throw new Error("Credential audit time is invalid.") + await this.initialize() + const bucket = Math.floor(now / UNKNOWN_AUDIT_BUCKET_MS) + await this.db.batch([ + this.db.prepare("INSERT OR IGNORE INTO wp_codebox_principal_credential_audit (at, kind, principal, credential_id, version, reason, dedupe_key) VALUES (?, 'denied', NULL, NULL, NULL, 'unknown', ?)").bind(now, `unknown:${bucket}`), + this.trimAuditStatement(), + ]) + } + + async auditEvents(limit = 100): Promise { + await this.initialize() + const bounded = Number.isSafeInteger(limit) ? Math.min(Math.max(limit, 1), 100) : 100 + const rows = await this.db.prepare("SELECT id, at, kind, principal, credential_id, version, reason FROM wp_codebox_principal_credential_audit ORDER BY id DESC LIMIT ?").bind(bounded).all() + return rows.results.map(({ credential_id: credentialId, ...event }) => Object.freeze({ ...event, credentialId })) + } + + private async audit(kind: CredentialAuditEvent["kind"], principal: string | null, credentialId: string | null, version: string | null, reason: CredentialAuditEvent["reason"], at = Date.now()): Promise { + await this.db.batch([this.auditStatement(kind, principal, credentialId, version, reason, at), this.trimAuditStatement()]) + } + + private auditStatement(kind: CredentialAuditEvent["kind"], principal: string | null, credentialId: string | null, version: string | null, reason: CredentialAuditEvent["reason"], at: number): D1PreparedStatement { + const dedupeKey = kind === "denied" ? `denied:${credentialId}:${version}:${reason}:${Math.floor(at / UNKNOWN_AUDIT_BUCKET_MS)}` : null + return this.db.prepare("INSERT OR IGNORE INTO wp_codebox_principal_credential_audit (at, kind, principal, credential_id, version, reason, dedupe_key) VALUES (?, ?, ?, ?, ?, ?, ?)").bind(at, kind, principal, credentialId, version, reason, dedupeKey) + } + private trimAuditStatement(): D1PreparedStatement { return this.db.prepare("DELETE FROM wp_codebox_principal_credential_audit WHERE id IN (SELECT id FROM wp_codebox_principal_credential_audit ORDER BY id DESC LIMIT -1 OFFSET ?)").bind(this.auditLimit) } +} + +function validate(input: PrincipalCredentialVersion): void { + if (!identifier(input.credentialId) || !identifier(input.version) || !principalId(input.principal) || !/^[a-f0-9]{64}$/.test(input.digest) || !validPolicy(input)) throw new Error("Credential policy is invalid.") +} +function validPolicy(value: PrincipalCredentialPolicy): boolean { + let canonicalExpiry: string + try { canonicalExpiry = new Date(value.expiresAt).toISOString() } catch { return false } + return Array.isArray(value.scopes) && value.scopes.length > 0 && value.scopes.length <= 4 && new Set(value.scopes).size === value.scopes.length && value.scopes.every((scope) => SCOPES.has(scope)) && Number.isInteger(value.maxSites) && value.maxSites >= 0 && value.maxSites <= 10_000 && canonicalExpiry === value.expiresAt && Date.parse(value.expiresAt) > 0 && (value.sites === undefined || (Array.isArray(value.sites) && value.sites.length <= 256 && new Set(value.sites).size === value.sites.length && value.sites.every(validSiteId))) +} +function hydrate(row: Record): AuthenticatedPrincipal { + const scopes = parseArray(row.scopes) + const sites = row.sites === null ? undefined : parseArray(row.sites) + let expiresAt: string + try { expiresAt = new Date(Number(row.expires_at)).toISOString() } catch { throw new Error("Stored credential policy is invalid.") } + if (!identifier(row.credential_id) || !identifier(row.version) || !principalId(row.principal) || !scopes || (row.sites !== null && !sites)) throw new Error("Stored credential policy is invalid.") + const value: AuthenticatedPrincipal = { credentialId: row.credential_id, version: row.version, principal: row.principal, scopes, expiresAt, maxSites: Number(row.max_sites), ...(sites ? { sites } : {}) } + if (!validPolicy(value)) throw new Error("Stored credential policy is invalid.") + return value +} +function sameCredential(row: Record, input: PrincipalCredentialVersion, scopes: string, sites: string | null, expiresAt: number): boolean { return row.digest === input.digest && row.principal === input.principal && row.scopes === scopes && row.expires_at === expiresAt && row.max_sites === input.maxSites && row.sites === sites } +function parseArray(value: unknown): string[] | null { try { const parsed = JSON.parse(value as string); return Array.isArray(parsed) && parsed.every((item) => typeof item === "string") ? parsed : null } catch { return null } } +function identifier(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9._-]{1,64}$/.test(value) } +function principalId(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value) } +function validSiteId(value: unknown): value is string { return typeof value === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && value.length <= 63 } +async function bearerDigest(value: string): Promise { const bytes = new TextEncoder().encode(value); if (!value || bytes.byteLength > 4_096) throw new Error("Credential bearer is invalid."); return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes as Uint8Array)), (byte) => byte.toString(16).padStart(2, "0")).join("") } diff --git a/packages/runtime-cloudflare/src/provisioning-api.ts b/packages/runtime-cloudflare/src/provisioning-api.ts index 8d5be16d..acfffa81 100644 --- a/packages/runtime-cloudflare/src/provisioning-api.ts +++ b/packages/runtime-cloudflare/src/provisioning-api.ts @@ -4,6 +4,7 @@ import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactI import { allocatePreviewSiteContext, parseSiteContexts, previewDomain, siteStorageKeys, type SiteContext } from "./site-context.js" import { deriveSiteCredential } from "./wordpress-auth.js" import { allocationIdentity, CloudflareAllocationLifecycle, type AllocationLifecycle } from "./allocation-lifecycle.js" +import { D1PrincipalCredentialRepository, type AuthenticatedPrincipal } from "./principal-credential-repository.js" export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1" export const PROVISIONING_CREATE_REQUEST_SCHEMA = "wp-codebox/provisioning-create-request/v1" @@ -263,14 +264,23 @@ function idempotencyKey(request: Request): string | Response { const key = reque async function authenticate(request: Request, env: ProvisioningEnv, scope: string): Promise { const bearer = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/)?.[1] if (!bearer) return apiError(401, "unauthorized", "Bearer authentication is required.") - let tokens: Token[]; try { tokens = parseTokens(env.WORDPRESS_API_TOKENS) } catch { return apiError(401, "unauthorized", "Bearer authentication is unavailable.") } const digest = await shaText(bearer) + const credentials = new D1PrincipalCredentialRepository(env.WORDPRESS_STATE_DATABASE) + const siteId = requestedSiteId(request) + let durable: Awaited> + try { durable = await credentials.authorizeDigest(digest, scope, siteId) } catch { return apiError(401, "unauthorized", "Bearer authentication is unavailable.") } + if (durable.status === "allowed") return tokenFromPrincipal(durable.principal) + if (durable.status === "denied") return durable.reason === "scope" ? apiError(403, "forbidden", "The bearer token lacks the required scope.") : durable.reason === "site" ? notFound() : apiError(401, "unauthorized", "Bearer authentication failed.") + let tokens: Token[]; try { tokens = parseTokens(env.WORDPRESS_API_TOKENS) } catch { await credentials.recordUnknownDenial(); return apiError(401, "unauthorized", "Bearer authentication is unavailable.") } for (const token of tokens) if (await equal(digest, token.digest)) { if (Date.parse(token.expiresAt) <= Date.now()) return apiError(401, "unauthorized", "Bearer authentication failed.") return token.scopes.includes(scope) ? token : apiError(403, "forbidden", "The bearer token lacks the required scope.") } + await credentials.recordUnknownDenial() return apiError(401, "unauthorized", "Bearer authentication failed.") } +function tokenFromPrincipal(value: AuthenticatedPrincipal): Token { return { id: `${value.credentialId}.${value.version}`, digest: "", principal: value.principal, scopes: value.scopes, expiresAt: value.expiresAt, maxSites: value.maxSites, ...(value.sites ? { sites: value.sites } : {}) } } +function requestedSiteId(request: Request): string | undefined { const parts = new URL(request.url).pathname.split("/").filter(Boolean); return parts[1] === "sites" && parts.length > 2 && validSiteId(parts[2]) ? parts[2] : undefined } function parseTokens(value: string | undefined): Token[] { const tokens: unknown = JSON.parse(value ?? "") if (!Array.isArray(tokens) || !tokens.length || tokens.length > 64) throw new Error("Invalid token configuration.") diff --git a/tests/cloudflare-principal-credential-repository.test.ts b/tests/cloudflare-principal-credential-repository.test.ts new file mode 100644 index 00000000..8fe2c2e9 --- /dev/null +++ b/tests/cloudflare-principal-credential-repository.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict" +import { createHash } from "node:crypto" +import { DatabaseSync } from "node:sqlite" +import test from "node:test" +import { D1PrincipalCredentialRepository } from "../packages/runtime-cloudflare/src/principal-credential-repository.js" + +const digest = (value: string) => createHash("sha256").update(value).digest("hex") +function database(): D1Database & { sqlite: DatabaseSync } { + const sqlite = new DatabaseSync(":memory:") + return Object.assign({ prepare(query: string) { const statement = sqlite.prepare(query); let values: unknown[] = []; return { bind(...next: unknown[]) { values = next; return this }, async run() { return { meta: { changes: statement.run(...values).changes } } }, async first() { return statement.get(...values) as T | null }, async all() { return { results: statement.all(...values) as T[] } } } }, async batch(statements: Array<{ run: () => Promise<{ meta: { changes: number } }> }>) { sqlite.exec("BEGIN"); try { const results = []; for (const statement of statements) results.push(await statement.run()); sqlite.exec("COMMIT"); return results } catch (error) { sqlite.exec("ROLLBACK"); throw error } } } as unknown as D1Database, { sqlite }) +} +const policy = (change: Record = {}) => ({ credentialId: "issuer-key", version: "v1", digest: digest("secret-bearer"), principal: "principal-a", scopes: ["sites:create", "sites:read"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites: 1, ...change }) + +test("credential repository persists supplied digests only and rejects malformed policy", async () => { + const db = database(); const repository = new D1PrincipalCredentialRepository(db) + await repository.register(policy()) + await repository.register(policy()) + const stored = JSON.stringify(db.sqlite.prepare("SELECT * FROM wp_codebox_principal_credentials").all()) + assert.match(stored, new RegExp(digest("secret-bearer"))) + assert.doesNotMatch(stored, /secret-bearer/) + await assert.rejects(() => repository.register(policy({ version: "v2", scopes: ["root"] })), /policy is invalid/) + await assert.rejects(() => repository.register(policy({ version: "v2", sites: ["INVALID"] })), /policy is invalid/) +}) + +test("credential repository enforces scope, site policy, expiry, overlap, and immediate revocation", async () => { + const repository = new D1PrincipalCredentialRepository(database()) + await repository.register(policy({ sites: ["alpha"] })) + assert.equal((await repository.authorize("secret-bearer", "sites:create", "alpha"))?.principal, "principal-a") + assert.equal(await repository.authorize("secret-bearer", "sites:import"), null) + assert.equal(await repository.authorize("secret-bearer", "sites:create", "beta"), null) + await repository.register(policy({ version: "v2", digest: digest("rotated-bearer") })) + assert.ok(await repository.authenticate("secret-bearer")) + assert.ok(await repository.authenticate("rotated-bearer")) + assert.equal(await repository.revoke("issuer-key", "v1"), true) + assert.equal(await repository.authenticate("secret-bearer"), null) + await repository.register(policy({ credentialId: "expired", version: "v1", digest: digest("expired-bearer"), expiresAt: "2000-01-01T00:00:00.000Z" })) + assert.equal(await repository.authenticate("expired-bearer"), null) +}) + +test("credential audit is bounded, immutable, redacted evidence", async () => { + const db = database(); const repository = new D1PrincipalCredentialRepository(db, 3) + await repository.register(policy()) + await repository.authenticate("secret-bearer") + await repository.authorize("secret-bearer", "sites:import", undefined, 1_000_000) + await repository.authorize("secret-bearer", "sites:import", undefined, 1_000_001) + const events = await repository.auditEvents() + assert.ok(Object.isFrozen(events[0])) + assert.throws(() => { events[0].reason = "ok" }, TypeError) + assert.ok(events.some((event) => event.kind === "denied" && event.reason === "scope")) + assert.equal(events.filter((event) => event.reason === "scope").length, 1, "known denials coalesce within a bounded time bucket") + const evidence = JSON.stringify(events) + assert.doesNotMatch(evidence, /secret-bearer|[a-f0-9]{64}/) + assert.equal((await repository.auditEvents(1_000)).length, events.length, "audit reads remain bounded to 100 records") + await repository.authenticate("secret-bearer") + assert.equal(countAudit(db), 3, "audit retention remains bounded while preserving the newest evidence") + await repository.authenticate("unknown-a", 1_000_000) + await repository.authenticate("unknown-b", 1_000_001) + assert.equal((await repository.auditEvents()).filter((event) => event.reason === "unknown").length, 1, "unknown attempts coalesce within a bounded time bucket") +}) + +test("durable lookup distinguishes static fallback from known credential denial", async () => { + const repository = new D1PrincipalCredentialRepository(database()) + await repository.register(policy()) + assert.deepEqual(await repository.authorizeDigest(digest("static-bearer"), "sites:create"), { status: "not-found" }) + assert.equal((await repository.auditEvents()).some((event) => event.reason === "unknown"), false) + await repository.revoke("issuer-key", "v1") + assert.equal((await repository.authorizeDigest(digest("secret-bearer"), "sites:create")).status, "denied") +}) + +test("malformed persisted credential policy fails closed", async () => { + const db = database(); const repository = new D1PrincipalCredentialRepository(db) + await repository.initialize() + db.sqlite.prepare("INSERT INTO wp_codebox_principal_credentials (credential_id, version, digest, principal, scopes, expires_at, max_sites, sites, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)").run("corrupt", "v1", digest("corrupt-bearer"), "principal-a", "not-json", Date.parse("2099-01-01T00:00:00.000Z"), 1, Date.now()) + await assert.rejects(() => repository.authorizeDigest(digest("corrupt-bearer"), "sites:create"), /Stored credential policy is invalid/) +}) + +test("credential registration and revocation are atomic with their audit evidence", async () => { + const db = database(); const repository = new D1PrincipalCredentialRepository(db) + await repository.initialize() + db.sqlite.exec("CREATE TRIGGER fail_registration_audit BEFORE INSERT ON wp_codebox_principal_credential_audit WHEN NEW.kind = 'registered' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END") + await assert.rejects(() => repository.register(policy()), /audit unavailable/) + assert.equal(Number((db.sqlite.prepare("SELECT COUNT(*) AS count FROM wp_codebox_principal_credentials").get() as { count: number }).count), 0) + db.sqlite.exec("DROP TRIGGER fail_registration_audit") + await repository.register(policy()) + db.sqlite.exec("CREATE TRIGGER fail_revocation_audit BEFORE INSERT ON wp_codebox_principal_credential_audit WHEN NEW.kind = 'revoked' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END") + await assert.rejects(() => repository.revoke("issuer-key", "v1"), /audit unavailable/) + assert.equal((db.sqlite.prepare("SELECT revoked_at FROM wp_codebox_principal_credentials WHERE credential_id = 'issuer-key' AND version = 'v1'").get() as { revoked_at: number | null }).revoked_at, null) +}) + +function countAudit(db: D1Database & { sqlite: DatabaseSync }): number { return Number((db.sqlite.prepare("SELECT COUNT(*) AS count FROM wp_codebox_principal_credential_audit").get() as { count: number }).count) } diff --git a/tests/cloudflare-provisioning-api.test.ts b/tests/cloudflare-provisioning-api.test.ts index b820587a..178d74b4 100644 --- a/tests/cloudflare-provisioning-api.test.ts +++ b/tests/cloudflare-provisioning-api.test.ts @@ -6,6 +6,7 @@ import { D1OperationRepository } from "../packages/runtime-cloudflare/src/d1-ope import { allocationIdentity, CloudflareAllocationLifecycle } from "../packages/runtime-cloudflare/src/allocation-lifecycle.js" import { PROVISIONING_ARTIFACT_RESOURCE_SCHEMA, PROVISIONING_CREATE_REQUEST_SCHEMA, resumeProvisioningAllocation, routeProvisioningApi } from "../packages/runtime-cloudflare/src/provisioning-api.js" import { STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA } from "../packages/runtime-cloudflare/src/static-artifact-import.js" +import { D1PrincipalCredentialRepository } from "../packages/runtime-cloudflare/src/principal-credential-repository.js" const hash = (value: string | Uint8Array) => createHash("sha256").update(value).digest("hex") const artifactText = JSON.stringify({ schema: "blocks-engine/php-transformer/site-artifact/v1", root: "website", entrypoint: "website/index.html", files: [{ path: "website/index.html", content: "ok" }] }) @@ -46,6 +47,32 @@ test("auth failures, malformed config, expiry, and scope stop before body and R2 const r = runtime(database(), new Bucket(), config); const response = await routeProvisioningApi(createRequest("key", {}, config === undefined ? "bad" : "good"), r.env, r.operations); assert.ok([401, 403].includes(response.status)); assert.equal(r.bucket.gets, 0) } }) +test("durable credentials retain provisioning quota semantics while JSON tokens remain compatible", async () => { + const r = runtime(database(), new Bucket(), undefined, 9) + const credentials = new D1PrincipalCredentialRepository(r.db) + await credentials.register({ credentialId: "issuer", version: "v1", digest: hash("durable"), principal: "durable-principal", scopes: ["sites:create", "sites:read"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites: 1 }) + delete r.env.WORDPRESS_API_TOKENS + assert.equal((await routeProvisioningApi(createRequest("durable-1", {}, "durable"), r.env, r.operations)).status, 202) + assert.equal((await routeProvisioningApi(createRequest("durable-2", {}, "durable"), r.env, r.operations)).status, 429) + const staticAdapter = runtime() + assert.equal((await create(staticAdapter)).status, 202) + assert.equal(count(staticAdapter.db, "wp_codebox_principal_credential_audit"), 0, "static fallback does not record a false durable denial") +}) +test("known durable revocation cannot fall through to the static compatibility adapter", async () => { + const r = runtime() + const credentials = new D1PrincipalCredentialRepository(r.db) + await credentials.register({ credentialId: "managed", version: "v1", digest: hash("good"), principal: "a", scopes: ["sites:create"], expiresAt: "2099-01-01T00:00:00.000Z", maxSites: 2 }) + await credentials.revoke("managed", "v1") + assert.equal((await create(r)).status, 401) + assert.equal(count(r.db, "wp_codebox_api_sites"), 0) +}) +test("malformed matching durable policy fails closed before request body reads", async () => { + const r = runtime() + await new D1PrincipalCredentialRepository(r.db).initialize() + r.db.sqlite.prepare("INSERT INTO wp_codebox_principal_credentials (credential_id, version, digest, principal, scopes, expires_at, max_sites, sites, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)").run("corrupt", "v1", hash("corrupt"), "a", "not-json", Date.parse("2099-01-01T00:00:00.000Z"), 1, Date.now()) + assert.equal((await routeProvisioningApi(createRequest("corrupt", {}, "corrupt"), r.env, r.operations)).status, 401) + assert.equal(r.bucket.gets, 0) +}) test("invalid, missing, and oversized artifacts create zero allocations", async () => { for (const change of [{ sha256: "bad" }, { sha256: "f".repeat(64) }, { size: 5 * 1024 * 1024 }]) { const r = runtime(); assert.ok((await routeProvisioningApi(createRequest("key", change), r.env, r.operations)).status >= 400); assert.equal(count(r.db, "wp_codebox_api_sites"), 0) } })