/** * T12 · Enforcement entry point `onUpgrade` — the SOLE owner of the WS-upgrade authorization * DECISION (INDEX §6a). P1's `authorizeUpgrade` is a thin adapter that DELEGATES here. The full * decision composes, in order: Origin/CSWSH (retained, INV15) → pre-auth throttle (Finding-5) → * deny-by-default authz incl. DPoP proof-of-possession + cross-tenant gate (T3/INV1) → per-tenant * rate → step-up gate (T8/Finding-3, v0.10) → single-use jti burn (Finding-4) → audit (T4/INV10). * * Deny-by-default: every branch that is not an explicit allow returns 401/403 and emits exactly one * `deny` AuditEvent. No allow path writes payload (INV10). */ import type { CapabilityRight, HostRecord } from 'relay-contracts' import type { AuditAction, AuthenticatedPrincipal, AuditSink, HostRegistryPort, RevocationStore, SessionRegistryPort, StepUpPolicy, TokenBucketStore, } from '../types.js' import { authorizeConnect, authorizeReattach, type AuthzOutcome } from '../authz/decide.js' import type { DpopContext } from '../capability/verify.js' import { peekExp } from '../capability/verify.js' import { checkPreAuthRate, checkConnectRate, checkConcurrentSessions, policyForPlan, } from '../ratelimit/quota.js' import { needsStepUp } from '../human/stepup/stepup.js' import { buildAuditEvent, audit } from '../audit/log.js' export interface UpgradeContext { readonly capabilityRaw: string readonly originHeader: string readonly expectedAud: string readonly requestedHostId: string readonly requiredRight: CapabilityRight readonly remoteAddrHash: string readonly activeSessionCount: number readonly dpop: DpopContext readonly principal: AuthenticatedPrincipal | null // session principal for step-up (T8) } export interface EnforceDeps { readonly hosts: HostRegistryPort readonly sessions: SessionRegistryPort readonly revocation: RevocationStore readonly buckets: TokenBucketStore readonly audit: AuditSink /** v0.10; v0.9 injects a "never required" policy (only consulted when a session principal exists). */ readonly stepUpPolicyFor: (host: HostRecord) => StepUpPolicy } /** v0.9 default: no per-host tier lookup port here; the real tier comes from P3's account registry * (INTEGRATION POINT). A conservative fixed policy bounds per-tenant abuse until then. */ const DEFAULT_TENANT_POLICY = policyForPlan('personal') function deny(status: 401 | 403, reason: string): AuthzOutcome { return { ok: false, status, reason } } function auditActionFor(base: AuditAction, reason: string): AuditAction { return reason === 'cross_tenant' || reason === 'cross_tenant_session' ? 'cross-tenant-attempt' : base } async function emitDeny( deps: EnforceDeps, ctx: UpgradeContext, base: AuditAction, reason: string, now: number, ): Promise { await audit( deps.audit, buildAuditEvent({ action: auditActionFor(base, reason), principal: ctx.principal, hostId: ctx.requestedHostId, sessionId: null, jti: null, outcome: 'deny', reason, remoteAddrHash: ctx.remoteAddrHash, now, }), ) } /** Shared enforcement pipeline for connect (`base='attach'`) and reattach (`base='reattach'`). */ export async function runEnforcement( ctx: UpgradeContext, deps: EnforceDeps, allowedOrigins: readonly string[], now: number, authorize: () => Promise, base: AuditAction, sessionId: string | null, ): Promise { // 1. Origin / CSWSH (retained base-app check, INV15). if (!allowedOrigins.includes(ctx.originHeader)) { await emitDeny(deps, ctx, base, 'bad_origin', now) return deny(401, 'bad_origin') } // 2. Pre-auth throttle BEFORE any token work (Finding-5) — keyed on the IP hash, no principal yet. if (!(await checkPreAuthRate(ctx.remoteAddrHash, deps.buckets, now))) { await emitDeny(deps, ctx, base, 'pre_auth_throttled', now) return deny(403, 'pre_auth_throttled') } // 3. Deny-by-default authz (token verify + DPoP PoP + INV1 cross-tenant gate). const outcome = await authorize() if (!outcome.ok) { await emitDeny(deps, ctx, base, outcome.reason, now) return outcome } const accountId = outcome.principal.accountId // 4. Per-tenant rate (after the principal is known). if (!(await checkConnectRate(accountId, DEFAULT_TENANT_POLICY, deps.buckets, now))) { await emitDeny(deps, ctx, base, 'rate_limited', now) return deny(403, 'rate_limited') } if (!checkConcurrentSessions(accountId, ctx.activeSessionCount, DEFAULT_TENANT_POLICY)) { await emitDeny(deps, ctx, base, 'too_many_sessions', now) return deny(403, 'too_many_sessions') } // 5. Step-up gate (Finding-3/F1/F2) — HOST-DRIVEN and FAIL-CLOSED on connect AND reattach. // When a host REQUIRES step-up, DENY unless an authenticated principal proves a FRESH step-up // of the required method. A missing host, a null principal, or a stale/wrong-method step-up all // fail closed. When the policy does not require step-up, this gate is a no-op. const host = await deps.hosts.getById(outcome.hostId) const policy = deps.stepUpPolicyFor(host as HostRecord) if (policy.required) { if (host === null || ctx.principal === null || needsStepUp(ctx.principal, policy, now)) { await emitDeny(deps, ctx, 'stepup', 'step_up_required', now) return deny(403, 'step_up_required') } } // 6. Single-use consume (Finding-4) — burn the jti on the first fully-allowed upgrade. const exp = peekExp(ctx.capabilityRaw) if (!(await deps.revocation.consumeOnce(outcome.jti, exp))) { await emitDeny(deps, ctx, base, 'token_replayed', now) return deny(403, 'token_replayed') } // 7. Audit allow + return. await audit( deps.audit, buildAuditEvent({ action: base, principal: outcome.principal, hostId: outcome.hostId, sessionId, jti: outcome.jti, outcome: 'allow', reason: 'ok', remoteAddrHash: ctx.remoteAddrHash, now, }), ) return outcome } export function onUpgrade( ctx: UpgradeContext, deps: EnforceDeps, allowedOrigins: readonly string[], now: number, ): Promise { return runEnforcement( ctx, deps, allowedOrigins, now, () => authorizeConnect( { capabilityRaw: ctx.capabilityRaw, expectedAud: ctx.expectedAud, requestedHostId: ctx.requestedHostId, requiredRight: ctx.requiredRight, }, ctx.dpop, deps.hosts, deps.revocation, now, ), 'attach', null, ) }