diff --git a/packages/iios-contracts/src/capability.ts b/packages/iios-contracts/src/capability.ts index 29b60fd..df7e852 100644 --- a/packages/iios-contracts/src/capability.ts +++ b/packages/iios-contracts/src/capability.ts @@ -22,6 +22,8 @@ export interface CapabilityRequest { target: string; payload: Record; idempotencyKey: string; + /** CMP purpose code for the consent check (e.g. 'outbound_message', 'marketing'). */ + purpose?: string; dataClass?: DataClass; } @@ -30,6 +32,8 @@ export interface CapabilityResult { outcome: CapabilityOutcome; errorCode?: string; latencyMs?: number; + /** The CMP consent receipt this egress went out under (provenance). */ + consentReceiptRef?: string; } /** What a provider reports; the broker normalizes `outcome` into CapabilityResult. */ diff --git a/packages/iios-service/src/capability/capability.broker.spec.ts b/packages/iios-service/src/capability/capability.broker.spec.ts index 3587644..8e9d096 100644 --- a/packages/iios-service/src/capability/capability.broker.spec.ts +++ b/packages/iios-service/src/capability/capability.broker.spec.ts @@ -52,4 +52,47 @@ describe('CapabilityBroker (P9 — governed egress)', () => { it('unknown channel → fails closed (throws, no silent egress)', async () => { await expect(broker(makeFakePorts()).invoke(req({ channelType: 'CARRIER_PIGEON' }))).rejects.toThrow(); }); + + // ── CMP consent-at-egress (P9 slice 2) ────────────────────────── + it('CMP allow → SENT and carries the consent receipt', async () => { + const ports = makeFakePorts(); + ports.cmp.setStatus('ALLOW'); + const r = await broker(ports).invoke(req()); + expect(r.outcome).toBe('SENT'); + expect(r.consentReceiptRef).toBe('fake-receipt'); + }); + + it('CMP DENY → BLOCKED (CONSENT_DENIED), provider never called', async () => { + const ports = makeFakePorts(); + ports.cmp.setStatus('DENY'); + const registry = new CapabilityProviderRegistry(); + let called = false; + registry.register({ name: 'spy', channelTypes: ['WEBHOOK'], capabilities: { canSend: true }, async send() { called = true; return { providerRef: 'x', outcome: 'SENT' }; } }); + const r = await new CapabilityBroker(ports, registry).invoke(req()); + expect(r.outcome).toBe('BLOCKED'); + expect(r.errorCode).toBe('CONSENT_DENIED'); + expect(called).toBe(false); + }); + + it('CMP NOT_REQUIRED → SENT (no receipt required)', async () => { + const r = await broker(makeFakePorts()).invoke(req()); // default fake is NOT_REQUIRED + expect(r.outcome).toBe('SENT'); + expect(r.consentReceiptRef).toBeUndefined(); + }); + + it('purpose-aware: deny "marketing" but allow "support" for the same target', async () => { + const ports = makeFakePorts(); + ports.cmp.denyPurpose('marketing'); + const blocked = await broker(ports).invoke(req({ purpose: 'marketing' })); + const sent = await broker(ports).invoke(req({ purpose: 'support', idempotencyKey: 'k2' })); + expect(blocked.outcome).toBe('BLOCKED'); + expect(sent.outcome).toBe('SENT'); + }); + + it('OPA is consulted before CMP (policy deny short-circuits consent)', async () => { + const ports = makeFakePorts(); + ports.opa.deny('not allowed'); + ports.cmp.setStatus('ALLOW'); + await expect(broker(ports).invoke(req())).rejects.toBeInstanceOf(PolicyDeniedError); + }); }); diff --git a/packages/iios-service/src/capability/capability.broker.ts b/packages/iios-service/src/capability/capability.broker.ts index 3ea77ad..67adf8c 100644 --- a/packages/iios-service/src/capability/capability.broker.ts +++ b/packages/iios-service/src/capability/capability.broker.ts @@ -27,21 +27,34 @@ export class CapabilityBroker { target: req.target, }); - // 2. Enforce obligations BEFORE touching a provider. + // 2. Enforce policy obligations BEFORE touching a provider. for (const ob of decision.obligations) { if (ob.kind === 'DENY_OUTBOUND' || ob.kind === 'REVIEW') { return { providerRef: 'blocked', outcome: 'BLOCKED', errorCode: ob.kind }; } } + + // 3. Consent gate (CMP). Authorization (OPA) is not consent — a DENY here means the + // recipient did not consent to this purpose, so nothing is sent. Checked at send + // time, not enqueue time. NOT_REQUIRED/ALLOW proceed; ALLOW carries a receipt. + const consent = await this.ports.cmp.checkPurpose({ + purpose: req.purpose ?? 'outbound_message', + scopeId: req.scopeId, + subject: req.target, + }); + if (consent.status === 'DENY') { + return { providerRef: 'blocked', outcome: 'BLOCKED', errorCode: 'CONSENT_DENIED' }; + } + const payload = this.applyMasks(req.payload, decision.obligations); - // 3. Route to the channel's provider (unknown channel fails closed in the registry). + // 4. Route to the channel's provider (unknown channel fails closed in the registry). const provider = this.registry.forChannel(req.channelType); const result = await provider.send({ ...req, payload }); - // 4. Normalize the provider's outcome. + // 5. Normalize the provider's outcome + record the consent receipt for provenance. const outcome = result.outcome === 'SENT' ? 'SENT' : result.outcome === 'RATE_LIMITED' ? 'RATE_LIMITED' : 'FAILED'; - return { providerRef: result.providerRef, outcome, errorCode: result.errorCode, latencyMs: result.latencyMs }; + return { providerRef: result.providerRef, outcome, errorCode: result.errorCode, latencyMs: result.latencyMs, consentReceiptRef: consent.receiptRef }; } private applyMasks( diff --git a/packages/iios-testkit/src/fakes/cmp.ts b/packages/iios-testkit/src/fakes/cmp.ts index 2ef94a2..2d8dc2a 100644 --- a/packages/iios-testkit/src/fakes/cmp.ts +++ b/packages/iios-testkit/src/fakes/cmp.ts @@ -1,20 +1,36 @@ import type { IiosPlatformPorts } from '@insignia/iios-contracts'; type CmpPort = IiosPlatformPorts['cmp']; +type CmpStatus = 'ALLOW' | 'DENY' | 'NOT_REQUIRED'; -/** Deterministic CMP (consent) fake. Defaults to NOT_REQUIRED. */ +/** Deterministic CMP (consent) fake. Defaults to NOT_REQUIRED; supports purpose-aware decisions. */ export class FakeCmp implements CmpPort { - private status: 'ALLOW' | 'DENY' | 'NOT_REQUIRED' = 'NOT_REQUIRED'; + private status: CmpStatus = 'NOT_REQUIRED'; + private deniedPurposes = new Set(); + private allowedPurpose?: string; - setStatus(s: 'ALLOW' | 'DENY' | 'NOT_REQUIRED'): this { + setStatus(s: CmpStatus): this { this.status = s; return this; } - async checkPurpose(_input: unknown): Promise<{ receiptRef?: string; status: 'ALLOW' | 'DENY' | 'NOT_REQUIRED' }> { - return { - receiptRef: this.status === 'ALLOW' ? 'fake-receipt' : undefined, - status: this.status, - }; + /** Deny one specific purpose while other purposes follow the default status. */ + denyPurpose(purpose: string): this { + this.deniedPurposes.add(purpose); + return this; + } + + /** Allow only this purpose; every other purpose is denied. */ + allowOnly(purpose: string): this { + this.allowedPurpose = purpose; + return this; + } + + async checkPurpose(input: unknown): Promise<{ receiptRef?: string; status: CmpStatus }> { + const purpose = (input as { purpose?: string } | undefined)?.purpose; + let status = this.status; + if (purpose && this.deniedPurposes.has(purpose)) status = 'DENY'; + if (this.allowedPurpose !== undefined) status = purpose === this.allowedPurpose ? 'ALLOW' : 'DENY'; + return { receiptRef: status === 'ALLOW' ? 'fake-receipt' : undefined, status }; } }