feat(p9): CMP consent gate in the Capability Broker
Task S2.1: the broker now checks consent (cmp.checkPurpose) after the OPA gate + obligations and before the provider — a DENY blocks the send (BLOCKED/CONSENT_DENIED, provider never called); ALLOW/NOT_REQUIRED proceed, ALLOW carrying the consent receipt. Contracts gain CapabilityRequest.purpose + CapabilityResult.consentReceiptRef. FakeCmp is now purpose-aware (denyPurpose/allowOnly). 5 new tests: allow+receipt, deny→blocked, not-required, purpose-aware, OPA-before-CMP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,8 @@ export interface CapabilityRequest {
|
||||
target: string;
|
||||
payload: Record<string, unknown>;
|
||||
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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user