feat(capability): per-scope BYO provider credentials + Twilio SMS egress

IIOS becomes the tenant's integration/credential hub for anything it sends.
New IiosProviderCredential (one row per scope+providerType) seals the secret
with AES-256-GCM under IIOS_CRED_KEY (secret-crypto.ts); only non-secret
displayHints are ever read back. ProviderCredentialService upsert/resolve/status;
TwilioSmsProvider resolves the caller's own creds per scope at send time and
POSTs to Twilio (fail-closed NOT_CONFIGURED when unset). Registered over the
SMS sandbox only when the platform key + store are present. PUT/GET
/v1/providers/:type/credentials (scope-fenced, masked reads). 16 new unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 15:10:18 +05:30
parent 2aa2893973
commit ddd628ab6f
12 changed files with 503 additions and 3 deletions
@@ -2,15 +2,20 @@ import { Module } from '@nestjs/common';
import { MediaModule } from '../media/media.module';
import { CapabilityProviderRegistry } from './capability.registry';
import { CapabilityBroker } from './capability.broker';
import { ProviderCredentialService } from './provider-credential.service';
import { ProviderCredentialController } from './provider-credential.controller';
/**
* The governed egress boundary (P9 slice 1). Exports the broker + registry so the
* outbound layer routes all sends through policy + obligations + a pluggable
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
* Also owns per-scope BYO provider credentials (Twilio SMS, …) — sealed at rest and
* resolved by the channel providers at send time.
*/
@Module({
imports: [MediaModule],
providers: [CapabilityProviderRegistry, CapabilityBroker],
exports: [CapabilityBroker, CapabilityProviderRegistry],
controllers: [ProviderCredentialController],
providers: [CapabilityProviderRegistry, CapabilityBroker, ProviderCredentialService],
exports: [CapabilityBroker, CapabilityProviderRegistry, ProviderCredentialService],
})
export class CapabilityModule {}
@@ -4,6 +4,9 @@ import { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.provider';
import { EmailProvider } from './email.provider';
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
import { credKeyFromEnv } from './secret-crypto';
import { ProviderCredentialService } from './provider-credential.service';
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
@@ -21,7 +24,10 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
export class CapabilityProviderRegistry {
private readonly byChannel = new Map<string, CapabilityProvider>();
constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) {
constructor(
@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort,
@Optional() private readonly credentials?: ProviderCredentialService,
) {
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
for (const ch of DEFAULT_CHANNELS) {
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
@@ -36,6 +42,12 @@ export class CapabilityProviderRegistry {
const resolver = this.storage ? storageResolver(this.storage) : undefined;
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
}
// BYO SMS via each tenant's own Twilio creds (resolved per scope at send time). Registered
// only when the platform key + credential store are present — else SMS stays on the sandbox.
if (credKeyFromEnv() && this.credentials) {
const creds = this.credentials;
this.register(new TwilioSmsProvider((scopeId) => creds.resolve(scopeId, 'TWILIO_SMS') as Promise<TwilioCreds | null>));
}
}
register(provider: CapabilityProvider): void {
@@ -0,0 +1,54 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Put } from '@nestjs/common';
import { SessionVerifier } from '../platform/session.verifier';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { ProviderCredentialService } from './provider-credential.service';
import { TwilioCredentialsDto } from './provider-credential.dto';
const SUPPORTED = new Set(['TWILIO_SMS']);
/**
* Per-scope BYO integration credentials. The caller's attested session decides the scope, so a
* tenant can only read/write ITS OWN provider credentials. The secret is sealed by the service;
* GET returns a masked status (never the token). be-crm gates this behind tenant-admin policy.
*/
@Controller('v1/providers')
export class ProviderCredentialController {
constructor(
private readonly session: SessionVerifier,
private readonly actors: ActorResolver,
private readonly credentials: ProviderCredentialService,
) {}
@Put(':providerType/credentials')
async put(
@Param('providerType') providerType: string,
@Body() body: TwilioCredentialsDto,
@Headers('authorization') authorization?: string,
) {
this.assertSupported(providerType);
const scope = await this.actors.resolveScope(this.principal(authorization));
await this.credentials.upsert(scope.id, providerType, {
accountSid: body.accountSid,
authToken: body.authToken,
fromNumber: body.fromNumber,
});
return this.credentials.status(scope.id, providerType);
}
@Get(':providerType/credentials')
async get(@Param('providerType') providerType: string, @Headers('authorization') authorization?: string) {
this.assertSupported(providerType);
const scope = await this.actors.resolveScope(this.principal(authorization));
return this.credentials.status(scope.id, providerType);
}
private assertSupported(providerType: string): void {
if (!SUPPORTED.has(providerType)) throw new BadRequestException(`unsupported providerType: ${providerType}`);
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
/**
* PUT /v1/providers/TWILIO_SMS/credentials — a tenant's own Twilio credentials. Step 1 supports
* TWILIO_SMS only; when a second provider (SMTP, …) is added, switch to a per-type validated body.
*/
export class TwilioCredentialsDto {
@IsString() @IsNotEmpty() accountSid!: string;
@IsString() @IsNotEmpty() authToken!: string;
@IsString() @IsNotEmpty() fromNumber!: string;
}
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ProviderCredentialService } from './provider-credential.service';
const KEY_B64 = Buffer.alloc(32, 7).toString('base64');
/** Minimal in-memory stand-in for prisma.iiosProviderCredential (upsert/findUnique). */
function fakePrisma() {
const rows = new Map<string, Record<string, unknown>>();
const k = (scopeId: string, providerType: string) => `${scopeId}::${providerType}`;
return {
_rows: rows,
iiosProviderCredential: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async upsert({ where, create, update }: any) {
const key = k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType);
const existing = rows.get(key);
const row = existing ? { ...existing, ...update } : { ...create };
rows.set(key, row);
return row;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async findUnique({ where }: any) {
return rows.get(k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType)) ?? null;
},
},
};
}
function make(prisma: ReturnType<typeof fakePrisma>, env: NodeJS.ProcessEnv): ProviderCredentialService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = new ProviderCredentialService(prisma as any);
s.env = env;
return s;
}
describe('ProviderCredentialService', () => {
let prisma: ReturnType<typeof fakePrisma>;
let svc: ProviderCredentialService;
beforeEach(() => {
prisma = fakePrisma();
svc = make(prisma, { IIOS_CRED_KEY: KEY_B64 } as NodeJS.ProcessEnv);
});
it('seals the secret at rest — plaintext token never stored', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' });
const stored = JSON.stringify([...prisma._rows.values()]);
expect(stored).not.toContain('tok_secret');
expect(stored).toContain('cipherText');
});
it('resolves back the exact config it sealed', async () => {
const cfg = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
await svc.upsert('scope_1', 'TWILIO_SMS', cfg);
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toEqual(cfg);
});
it('status is masked — reveals hints, never the token', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC1234567', authToken: 'tok_secret', fromNumber: '+15550001111' });
const status = await svc.status('scope_1', 'TWILIO_SMS');
expect(status).toMatchObject({ configured: true, enabled: true, hints: { fromNumber: '+15550001111', sidLast4: '4567' } });
expect(JSON.stringify(status)).not.toContain('tok_secret');
});
it('reports not-configured for an unknown scope', async () => {
expect(await svc.status('nope', 'TWILIO_SMS')).toEqual({ configured: false });
expect(await svc.resolve('nope', 'TWILIO_SMS')).toBeNull();
});
it('does not resolve a disabled credential', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 't', fromNumber: '+1' }, { enabled: false });
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toBeNull();
});
it('throws when the platform key is absent (fail closed, never store plaintext)', async () => {
const noKey = make(prisma, {} as NodeJS.ProcessEnv);
await expect(noKey.upsert('s', 'TWILIO_SMS', { accountSid: 'A', authToken: 't', fromNumber: '+1' })).rejects.toThrow(/IIOS_CRED_KEY/);
});
});
@@ -0,0 +1,70 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { credKeyFromEnv, sealSecret, openSecret, type SealedSecret } from './secret-crypto';
/** A provider config is an opaque JSON bag; each provider knows its own shape. */
export type ProviderConfig = Record<string, unknown>;
export interface CredentialStatus {
configured: boolean;
enabled?: boolean;
hints?: Record<string, unknown>;
}
/** Non-secret display fields surfaced by `status` — NEVER the secret itself. */
function hintsFor(providerType: string, config: ProviderConfig): Record<string, unknown> {
if (providerType === 'TWILIO_SMS') {
const sid = String(config.accountSid ?? '');
return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) };
}
return {};
}
/**
* The tenant's BYO integration credentials, one row per (scope, providerType). The secret is
* sealed with AES-256-GCM under the platform key before it touches the DB; only non-secret
* `displayHints` are ever read back. `resolve` decrypts on demand at send time; `status` never
* returns the secret. Fail-closed: no platform key ⇒ upsert throws (we refuse to store plaintext).
*/
@Injectable()
export class ProviderCredentialService {
/** Overridable in tests; defaults to the process env (holds IIOS_CRED_KEY). */
env: NodeJS.ProcessEnv = process.env;
constructor(private readonly prisma: PrismaService) {}
async upsert(scopeId: string, providerType: string, config: ProviderConfig, opts?: { enabled?: boolean }): Promise<void> {
const key = credKeyFromEnv(this.env);
if (!key) throw new BadRequestException('IIOS_CRED_KEY is not configured — cannot store integration credentials');
const sealed = sealSecret(JSON.stringify(config), key);
const enabled = opts?.enabled ?? true;
const displayHints = hintsFor(providerType, config) as Prisma.InputJsonValue;
await this.prisma.iiosProviderCredential.upsert({
where: { scopeId_providerType: { scopeId, providerType } },
create: { scopeId, providerType, ...sealed, displayHints, enabled },
update: { ...sealed, displayHints, enabled },
});
}
/** Decrypt the config for send-time use. Null when unconfigured, disabled, or no key. */
async resolve(scopeId: string, providerType: string): Promise<ProviderConfig | null> {
const row = await this.prisma.iiosProviderCredential.findUnique({
where: { scopeId_providerType: { scopeId, providerType } },
});
if (!row || !row.enabled) return null;
const key = credKeyFromEnv(this.env);
if (!key) return null;
const sealed: SealedSecret = { cipherText: row.cipherText, iv: row.iv, authTag: row.authTag, keyVersion: row.keyVersion };
return JSON.parse(openSecret(sealed, key)) as ProviderConfig;
}
/** Masked view for the admin UI — configured + hints, never the secret. */
async status(scopeId: string, providerType: string): Promise<CredentialStatus> {
const row = await this.prisma.iiosProviderCredential.findUnique({
where: { scopeId_providerType: { scopeId, providerType } },
});
if (!row) return { configured: false };
return { configured: true, enabled: row.enabled, hints: (row.displayHints ?? {}) as Record<string, unknown> };
}
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { credKeyFromEnv, sealSecret, openSecret, SECRET_KEY_VERSION } from './secret-crypto';
const KEY = Buffer.alloc(32, 7); // deterministic 32-byte key for tests
describe('secret-crypto', () => {
it('round-trips a secret through seal → open', () => {
const sealed = sealSecret('sk_live_abc123', KEY);
expect(sealed.keyVersion).toBe(SECRET_KEY_VERSION);
expect(sealed.cipherText).not.toContain('sk_live_abc123');
expect(openSecret(sealed, KEY)).toBe('sk_live_abc123');
});
it('produces a distinct ciphertext each time (random IV)', () => {
const a = sealSecret('same', KEY);
const b = sealSecret('same', KEY);
expect(a.cipherText).not.toBe(b.cipherText);
expect(a.iv).not.toBe(b.iv);
});
it('fails to open with the wrong key', () => {
const sealed = sealSecret('top-secret', KEY);
expect(() => openSecret(sealed, Buffer.alloc(32, 9))).toThrow();
});
it('fails to open if the ciphertext is tampered (GCM auth)', () => {
const sealed = sealSecret('top-secret', KEY);
const bad = { ...sealed, cipherText: Buffer.from('deadbeef', 'hex').toString('base64') };
expect(() => openSecret(bad, KEY)).toThrow();
});
it('credKeyFromEnv returns null when unset and rejects a wrong-length key', () => {
expect(credKeyFromEnv({})).toBeNull();
expect(() => credKeyFromEnv({ IIOS_CRED_KEY: Buffer.alloc(16).toString('base64') })).toThrow(/32 bytes/);
const key = credKeyFromEnv({ IIOS_CRED_KEY: KEY.toString('base64') });
expect(key?.length).toBe(32);
});
});
@@ -0,0 +1,46 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
/**
* Envelope for a tenant secret sealed with AES-256-GCM. Stored as base64 columns on
* IiosProviderCredential; the plaintext (a provider config JSON string) never touches disk.
*/
export interface SealedSecret {
cipherText: string;
iv: string;
authTag: string;
keyVersion: number;
}
/** Bumped only when the platform master key rotates; lets old rows decrypt under an old key. */
export const SECRET_KEY_VERSION = 1;
/**
* Resolve the 32-byte platform master key from `IIOS_CRED_KEY` (base64). Returns null when
* unset (so egress providers that need it stay unregistered / fail closed) but THROWS on a
* present-but-malformed key — a wrong-length key is an operator error, not a "disabled" state.
*/
export function credKeyFromEnv(env: NodeJS.ProcessEnv = process.env): Buffer | null {
const raw = env.IIOS_CRED_KEY;
if (!raw) return null;
const key = Buffer.from(raw, 'base64');
if (key.length !== 32) throw new Error('IIOS_CRED_KEY must decode to 32 bytes (base64-encoded 256-bit key)');
return key;
}
export function sealSecret(plaintext: string, key: Buffer): SealedSecret {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return {
cipherText: enc.toString('base64'),
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
keyVersion: SECRET_KEY_VERSION,
};
}
export function openSecret(sealed: SealedSecret, key: Buffer): string {
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(sealed.iv, 'base64'));
decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(sealed.cipherText, 'base64')), decipher.final()]).toString('utf8');
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest';
import type { CapabilityRequest } from '@insignia/iios-contracts';
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
const CREDS: TwilioCreds = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
const req = (over: Partial<CapabilityRequest> = {}): CapabilityRequest => ({
capability: 'channel.send',
channelType: 'SMS',
scopeId: 'scope_1',
target: '+15557654321',
payload: { text: 'hello world' },
idempotencyKey: 'idem-1',
...over,
});
describe('TwilioSmsProvider', () => {
it('resolves the scope creds and POSTs to Twilio, returning SENT with the message SID', async () => {
const http = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({ sid: 'SM999' }), text: async () => '' });
const resolve = vi.fn().mockResolvedValue(CREDS);
const p = new TwilioSmsProvider(resolve, http);
const res = await p.send(req());
expect(resolve).toHaveBeenCalledWith('scope_1');
const [url, init] = http.mock.calls[0];
expect(url).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json');
expect(init.method).toBe('POST');
expect(init.headers.authorization).toBe(`Basic ${Buffer.from('AC123:tok_secret').toString('base64')}`);
const body = new URLSearchParams(init.body as string);
expect(body.get('To')).toBe('+15557654321');
expect(body.get('From')).toBe('+15550001111');
expect(body.get('Body')).toBe('hello world');
expect(res).toMatchObject({ outcome: 'SENT', providerRef: 'SM999' });
});
it('fails closed as NOT_CONFIGURED when the scope has no creds', async () => {
const http = vi.fn();
const p = new TwilioSmsProvider(async () => null, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('NOT_CONFIGURED');
expect(http).not.toHaveBeenCalled();
});
it('fails closed as NOT_CONFIGURED when the request has no scope', async () => {
const p = new TwilioSmsProvider(async () => CREDS, vi.fn());
const res = await p.send(req({ scopeId: undefined }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('NOT_CONFIGURED');
});
it('surfaces a Twilio API error as FAILED (never throws)', async () => {
const http = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ code: 20003, message: 'Authenticate' }), text: async () => 'Authenticate' });
const p = new TwilioSmsProvider(async () => CREDS, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('TWILIO_20003');
});
it('surfaces a transport throw as FAILED', async () => {
const http = vi.fn().mockRejectedValue(new Error('network down'));
const p = new TwilioSmsProvider(async () => CREDS, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
});
});
@@ -0,0 +1,70 @@
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
/** The decrypted Twilio config a tenant configures (BYO credentials). */
export interface TwilioCreds {
accountSid: string;
authToken: string;
fromNumber: string;
}
/** Resolves a scope's Twilio creds (decrypting on demand); null when unconfigured/disabled. */
export type TwilioCredResolver = (scopeId: string) => Promise<TwilioCreds | null>;
/** Injectable HTTP sender so tests don't hit the network; prod uses fetch. */
export type HttpSender = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
}>;
const defaultSender: HttpSender = (url, init) => fetch(url, init) as unknown as ReturnType<HttpSender>;
/**
* SMS egress via Twilio, using the CALLER'S OWN credentials (resolved per scope at send time).
* Fail-closed: no scope or no configured creds ⇒ FAILED/NOT_CONFIGURED, nothing sent. A Twilio
* API error is surfaced as FAILED (never thrown), per the provider doctrine.
*/
export class TwilioSmsProvider implements CapabilityProvider {
readonly name = 'twilio-sms';
readonly channelTypes = ['SMS'];
readonly capabilities = { canSend: true, maxPayloadBytes: 1600 };
constructor(
private readonly resolve: TwilioCredResolver,
private readonly http: HttpSender = defaultSender,
) {}
async send(req: CapabilityRequest): Promise<ProviderResult> {
const started = Date.now();
if (!req.scopeId) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
const creds = await this.resolve(req.scopeId).catch(() => null);
if (!creds) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
const body = new URLSearchParams({
To: req.target,
From: creds.fromNumber,
Body: String(req.payload.text ?? req.payload.body ?? ''),
}).toString();
try {
const res = await this.http(`https://api.twilio.com/2010-04-01/Accounts/${creds.accountSid}/Messages.json`, {
method: 'POST',
headers: {
authorization: `Basic ${Buffer.from(`${creds.accountSid}:${creds.authToken}`).toString('base64')}`,
'content-type': 'application/x-www-form-urlencoded',
},
body,
});
const latencyMs = Date.now() - started;
const payload = (await res.json().catch(() => ({}))) as { sid?: string; code?: number; message?: string };
if (!res.ok) {
return { providerRef: `twilio-${res.status}`, outcome: 'FAILED', errorCode: payload.code ? `TWILIO_${payload.code}` : `HTTP_${res.status}`, latencyMs };
}
return { providerRef: payload.sid ?? 'twilio-sent', outcome: 'SENT', latencyMs };
} catch (err) {
return { providerRef: 'twilio-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
}
}
}