Skip to content

Commit 47df4a9

Browse files
committed
Add durable principal credential enforcement
1 parent c2c6fe3 commit 47df4a9

5 files changed

Lines changed: 293 additions & 2 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,10 @@
111111
"test:redaction": "tsx tests/redaction.test.ts",
112112
"test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts",
113113
"test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts",
114-
"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",
114+
"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",
115115
"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",
116116
"test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts",
117+
"test:cloudflare-principal-credentials": "tsx tests/cloudflare-principal-credential-repository.test.ts && tsx tests/cloudflare-provisioning-api.test.ts",
117118
"test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts",
118119
"test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts",
119120
"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",
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
const SCOPES = new Set(["sites:create", "sites:read", "sites:import", "operations:read"])
2+
const DEFAULT_AUDIT_LIMIT = 10_000
3+
const UNKNOWN_AUDIT_BUCKET_MS = 5 * 60_000
4+
5+
export interface PrincipalCredentialPolicy {
6+
principal: string
7+
scopes: string[]
8+
expiresAt: string
9+
maxSites: number
10+
sites?: string[]
11+
}
12+
export interface PrincipalCredentialVersion extends PrincipalCredentialPolicy {
13+
credentialId: string
14+
version: string
15+
digest: string
16+
}
17+
export interface AuthenticatedPrincipal extends PrincipalCredentialPolicy {
18+
credentialId: string
19+
version: string
20+
}
21+
export interface CredentialAuditEvent {
22+
id: number
23+
at: number
24+
kind: "registered" | "revoked" | "authenticated" | "denied"
25+
principal: string | null
26+
credentialId: string | null
27+
version: string | null
28+
reason: "ok" | "unknown" | "expired" | "revoked" | "scope" | "site"
29+
}
30+
export type CredentialAuthorizationDecision =
31+
| { status: "not-found" }
32+
| { status: "allowed"; principal: AuthenticatedPrincipal }
33+
| { status: "denied"; reason: "expired" | "revoked" | "scope" | "site"; principal: string; credentialId: string; version: string }
34+
35+
/** Durable bearer policy storage. Bearers are hashed before they cross this boundary. */
36+
export class D1PrincipalCredentialRepository {
37+
constructor(private readonly db: D1Database, private readonly auditLimit = DEFAULT_AUDIT_LIMIT) {
38+
if (!Number.isSafeInteger(auditLimit) || auditLimit < 1 || auditLimit > DEFAULT_AUDIT_LIMIT) throw new Error("Credential audit limit is invalid.")
39+
}
40+
41+
async initialize(): Promise<void> {
42+
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()
43+
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()
44+
}
45+
46+
async register(input: PrincipalCredentialVersion): Promise<void> {
47+
validate(input)
48+
await this.initialize()
49+
const expiresAt = Date.parse(input.expiresAt)
50+
const scopes = JSON.stringify(input.scopes)
51+
const sites = input.sites ? JSON.stringify(input.sites) : null
52+
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<Record<string, unknown>>()
53+
if (existing) {
54+
if (sameCredential(existing, input, scopes, sites, expiresAt)) return
55+
throw new Error("Credential version is already registered with different identity or policy.")
56+
}
57+
try {
58+
const now = Date.now()
59+
await this.db.batch([
60+
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),
61+
this.auditStatement("registered", input.principal, input.credentialId, input.version, "ok", now),
62+
this.trimAuditStatement(),
63+
])
64+
} catch (error) {
65+
if (!(error instanceof Error) || !/unique|constraint/i.test(error.message)) throw error
66+
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<Record<string, unknown>>()
67+
if (raced && sameCredential(raced, input, scopes, sites, expiresAt)) return
68+
throw new Error("Credential digest is already registered.")
69+
}
70+
}
71+
72+
async revoke(credentialId: string, version: string, now = Date.now()): Promise<boolean> {
73+
if (!identifier(credentialId) || !identifier(version) || !Number.isSafeInteger(now) || now < 1) throw new Error("Credential revocation request is invalid.")
74+
await this.initialize()
75+
const row = await this.db.prepare("SELECT principal FROM wp_codebox_principal_credentials WHERE credential_id = ? AND version = ?").bind(credentialId, version).first<{ principal: string }>()
76+
if (!row) return false
77+
const [result] = await this.db.batch([
78+
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),
79+
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),
80+
this.trimAuditStatement(),
81+
])
82+
return result.meta.changes === 1
83+
}
84+
85+
async authenticate(bearer: string, now = Date.now()): Promise<AuthenticatedPrincipal | null> {
86+
const decision = await this.authorizeDigest(await bearerDigest(bearer), undefined, undefined, now)
87+
if (decision.status === "not-found") await this.recordUnknownDenial(now)
88+
return decision.status === "allowed" ? decision.principal : null
89+
}
90+
91+
async authorize(bearer: string, scope: string, siteId?: string, now = Date.now()): Promise<AuthenticatedPrincipal | null> {
92+
const decision = await this.authorizeDigest(await bearerDigest(bearer), scope, siteId, now)
93+
if (decision.status === "not-found") await this.recordUnknownDenial(now)
94+
return decision.status === "allowed" ? decision.principal : null
95+
}
96+
97+
async authorizeDigest(digest: string, scope?: string, siteId?: string, now = Date.now()): Promise<CredentialAuthorizationDecision> {
98+
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.")
99+
await this.initialize()
100+
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<Record<string, unknown>>()
101+
if (!row) return { status: "not-found" }
102+
const value = hydrate(row)
103+
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
104+
if (reason) {
105+
await this.audit("denied", value.principal, value.credentialId, value.version, reason, now)
106+
return { status: "denied", reason, principal: value.principal, credentialId: value.credentialId, version: value.version }
107+
}
108+
await this.audit("authenticated", value.principal, value.credentialId, value.version, "ok", now)
109+
return { status: "allowed", principal: value }
110+
}
111+
112+
async recordUnknownDenial(now = Date.now()): Promise<void> {
113+
if (!Number.isSafeInteger(now) || now < 1) throw new Error("Credential audit time is invalid.")
114+
await this.initialize()
115+
const bucket = Math.floor(now / UNKNOWN_AUDIT_BUCKET_MS)
116+
await this.db.batch([
117+
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}`),
118+
this.trimAuditStatement(),
119+
])
120+
}
121+
122+
async auditEvents(limit = 100): Promise<CredentialAuditEvent[]> {
123+
await this.initialize()
124+
const bounded = Number.isSafeInteger(limit) ? Math.min(Math.max(limit, 1), 100) : 100
125+
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<CredentialAuditEvent & { credential_id: string | null }>()
126+
return rows.results.map(({ credential_id: credentialId, ...event }) => Object.freeze({ ...event, credentialId }))
127+
}
128+
129+
private async audit(kind: CredentialAuditEvent["kind"], principal: string | null, credentialId: string | null, version: string | null, reason: CredentialAuditEvent["reason"], at = Date.now()): Promise<void> {
130+
await this.db.batch([this.auditStatement(kind, principal, credentialId, version, reason, at), this.trimAuditStatement()])
131+
}
132+
133+
private auditStatement(kind: CredentialAuditEvent["kind"], principal: string | null, credentialId: string | null, version: string | null, reason: CredentialAuditEvent["reason"], at: number): D1PreparedStatement {
134+
const dedupeKey = kind === "denied" ? `denied:${credentialId}:${version}:${reason}:${Math.floor(at / UNKNOWN_AUDIT_BUCKET_MS)}` : null
135+
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)
136+
}
137+
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) }
138+
}
139+
140+
function validate(input: PrincipalCredentialVersion): void {
141+
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.")
142+
}
143+
function validPolicy(value: PrincipalCredentialPolicy): boolean {
144+
let canonicalExpiry: string
145+
try { canonicalExpiry = new Date(value.expiresAt).toISOString() } catch { return false }
146+
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)))
147+
}
148+
function hydrate(row: Record<string, unknown>): AuthenticatedPrincipal {
149+
const scopes = parseArray(row.scopes)
150+
const sites = row.sites === null ? undefined : parseArray(row.sites)
151+
let expiresAt: string
152+
try { expiresAt = new Date(Number(row.expires_at)).toISOString() } catch { throw new Error("Stored credential policy is invalid.") }
153+
if (!identifier(row.credential_id) || !identifier(row.version) || !principalId(row.principal) || !scopes || (row.sites !== null && !sites)) throw new Error("Stored credential policy is invalid.")
154+
const value: AuthenticatedPrincipal = { credentialId: row.credential_id, version: row.version, principal: row.principal, scopes, expiresAt, maxSites: Number(row.max_sites), ...(sites ? { sites } : {}) }
155+
if (!validPolicy(value)) throw new Error("Stored credential policy is invalid.")
156+
return value
157+
}
158+
function sameCredential(row: Record<string, unknown>, 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 }
159+
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 } }
160+
function identifier(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9._-]{1,64}$/.test(value) }
161+
function principalId(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value) }
162+
function validSiteId(value: unknown): value is string { return typeof value === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && value.length <= 63 }
163+
async function bearerDigest(value: string): Promise<string> { 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<ArrayBuffer>)), (byte) => byte.toString(16).padStart(2, "0")).join("") }

packages/runtime-cloudflare/src/provisioning-api.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MAX_STATIC_ARTIFACT_BYTES, readBoundedRequestBytes, readStaticArtifactI
44
import { allocatePreviewSiteContext, parseSiteContexts, previewDomain, siteStorageKeys, type SiteContext } from "./site-context.js"
55
import { deriveSiteCredential } from "./wordpress-auth.js"
66
import { allocationIdentity, CloudflareAllocationLifecycle, type AllocationLifecycle } from "./allocation-lifecycle.js"
7+
import { D1PrincipalCredentialRepository, type AuthenticatedPrincipal } from "./principal-credential-repository.js"
78

89
export const PROVISIONING_API_SCHEMA = "wp-codebox/provisioning-api/v1"
910
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
263264
async function authenticate(request: Request, env: ProvisioningEnv, scope: string): Promise<Token | Response> {
264265
const bearer = request.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/)?.[1]
265266
if (!bearer) return apiError(401, "unauthorized", "Bearer authentication is required.")
266-
let tokens: Token[]; try { tokens = parseTokens(env.WORDPRESS_API_TOKENS) } catch { return apiError(401, "unauthorized", "Bearer authentication is unavailable.") }
267267
const digest = await shaText(bearer)
268+
const credentials = new D1PrincipalCredentialRepository(env.WORDPRESS_STATE_DATABASE)
269+
const siteId = requestedSiteId(request)
270+
let durable: Awaited<ReturnType<typeof credentials.authorizeDigest>>
271+
try { durable = await credentials.authorizeDigest(digest, scope, siteId) } catch { return apiError(401, "unauthorized", "Bearer authentication is unavailable.") }
272+
if (durable.status === "allowed") return tokenFromPrincipal(durable.principal)
273+
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.")
274+
let tokens: Token[]; try { tokens = parseTokens(env.WORDPRESS_API_TOKENS) } catch { await credentials.recordUnknownDenial(); return apiError(401, "unauthorized", "Bearer authentication is unavailable.") }
268275
for (const token of tokens) if (await equal(digest, token.digest)) {
269276
if (Date.parse(token.expiresAt) <= Date.now()) return apiError(401, "unauthorized", "Bearer authentication failed.")
270277
return token.scopes.includes(scope) ? token : apiError(403, "forbidden", "The bearer token lacks the required scope.")
271278
}
279+
await credentials.recordUnknownDenial()
272280
return apiError(401, "unauthorized", "Bearer authentication failed.")
273281
}
282+
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 } : {}) } }
283+
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 }
274284
function parseTokens(value: string | undefined): Token[] {
275285
const tokens: unknown = JSON.parse(value ?? "")
276286
if (!Array.isArray(tokens) || !tokens.length || tokens.length > 64) throw new Error("Invalid token configuration.")

0 commit comments

Comments
 (0)