feat(p9): Capability Broker — governed egress + fail-closed gate + obligations
Tasks 9.1+9.2: contracts (CapabilityRequest/Result/Provider, PolicyObligation) + service CapabilityBroker that fails closed via opa (PolicyDeniedError, nothing sent), enforces obligations before any provider call (DENY_OUTBOUND/REVIEW → BLOCKED; MASK/REDACT → mask payload field), then routes to a CapabilityProviderRegistry (SandboxProvider default; env-gated HttpProvider overrides per channel; unknown channel fails closed). 5 tests: allow/deny/block/redact/unknown-channel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||
import { PolicyDeniedError, type CapabilityRequest } from '@insignia/iios-contracts';
|
||||
import { CapabilityBroker } from './capability.broker';
|
||||
import { CapabilityProviderRegistry } from './capability.registry';
|
||||
import type { FakePorts } from '@insignia/iios-testkit';
|
||||
|
||||
// No DB — the broker is pure governance over an in-memory provider registry.
|
||||
const req = (over: Partial<CapabilityRequest> = {}): CapabilityRequest => ({
|
||||
capability: 'channel.send',
|
||||
scopeId: 'scope1',
|
||||
channelType: 'WEBHOOK',
|
||||
target: 'user@x',
|
||||
payload: { text: 'hello' },
|
||||
idempotencyKey: 'k1',
|
||||
...over,
|
||||
});
|
||||
const broker = (ports: FakePorts) => new CapabilityBroker(ports, new CapabilityProviderRegistry());
|
||||
|
||||
describe('CapabilityBroker (P9 — governed egress)', () => {
|
||||
it('allow + no obligations → provider SENT', async () => {
|
||||
const r = await broker(makeFakePorts()).invoke(req());
|
||||
expect(r.outcome).toBe('SENT');
|
||||
expect(r.providerRef).toContain('sim-WEBHOOK');
|
||||
});
|
||||
|
||||
it('fails closed: opa deny → PolicyDeniedError, provider never called', async () => {
|
||||
const ports = makeFakePorts();
|
||||
ports.opa.deny('no egress');
|
||||
await expect(broker(ports).invoke(req())).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||
});
|
||||
|
||||
it('DENY_OUTBOUND obligation → BLOCKED, provider never called', async () => {
|
||||
const ports = makeFakePorts();
|
||||
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'user opted out' }] });
|
||||
const r = await broker(ports).invoke(req());
|
||||
expect(r.outcome).toBe('BLOCKED');
|
||||
expect(r.errorCode).toBe('DENY_OUTBOUND');
|
||||
});
|
||||
|
||||
it('REDACT obligation → provider receives masked payload', async () => {
|
||||
const ports = makeFakePorts();
|
||||
ports.opa.setDecision({ allow: true, obligations: [{ kind: 'REDACT', target: 'text', reason: 'PII' }] });
|
||||
const registry = new CapabilityProviderRegistry();
|
||||
let seen: unknown;
|
||||
registry.register({ name: 'spy', channelTypes: ['WEBHOOK'], capabilities: { canSend: true }, async send(r) { seen = r.payload; return { providerRef: 'spy-1', outcome: 'SENT' }; } });
|
||||
const r = await new CapabilityBroker(ports, registry).invoke(req());
|
||||
expect(r.outcome).toBe('SENT');
|
||||
expect((seen as { text: string }).text).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
it('unknown channel → fails closed (throws, no silent egress)', async () => {
|
||||
await expect(broker(makeFakePorts()).invoke(req({ channelType: 'CARRIER_PIGEON' }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user