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:
+25
@@ -0,0 +1,25 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IiosProviderCredential" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"scopeId" TEXT NOT NULL,
|
||||||
|
"providerType" TEXT NOT NULL,
|
||||||
|
"cipherText" TEXT NOT NULL,
|
||||||
|
"iv" TEXT NOT NULL,
|
||||||
|
"authTag" TEXT NOT NULL,
|
||||||
|
"keyVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"displayHints" JSONB,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "IiosProviderCredential_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IiosProviderCredential_scopeId_idx" ON "IiosProviderCredential"("scopeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "IiosProviderCredential_scopeId_providerType_key" ON "IiosProviderCredential"("scopeId", "providerType");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "IiosProviderCredential" ADD CONSTRAINT "IiosProviderCredential_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -317,10 +317,33 @@ model IiosScope {
|
|||||||
callbacks IiosCallbackRequest[]
|
callbacks IiosCallbackRequest[]
|
||||||
notificationSubscriptions IiosNotificationSubscription[]
|
notificationSubscriptions IiosNotificationSubscription[]
|
||||||
messageTemplates IiosMessageTemplate[]
|
messageTemplates IiosMessageTemplate[]
|
||||||
|
providerCredentials IiosProviderCredential[]
|
||||||
|
|
||||||
@@index([orgId, appId, tenantId])
|
@@index([orgId, appId, tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A tenant's own credentials for an egress provider (BYO: Twilio SMS, own SMTP, …).
|
||||||
|
/// The secret is sealed with AES-256-GCM under the platform key (IIOS_CRED_KEY); only
|
||||||
|
/// `displayHints` (non-secret, e.g. from-number / SID last-4) is ever read back out.
|
||||||
|
/// One row per (scope, providerType). Resolved at send time by the channel's provider.
|
||||||
|
model IiosProviderCredential {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scopeId String
|
||||||
|
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
|
||||||
|
providerType String // TWILIO_SMS | SMTP | WHATSAPP_CLOUD | …
|
||||||
|
cipherText String // base64(AES-256-GCM ciphertext of the JSON config)
|
||||||
|
iv String // base64 nonce
|
||||||
|
authTag String // base64 GCM auth tag
|
||||||
|
keyVersion Int @default(1)
|
||||||
|
displayHints Json? // non-secret display fields (fromNumber, sidLast4, …)
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([scopeId, providerType])
|
||||||
|
@@index([scopeId])
|
||||||
|
}
|
||||||
|
|
||||||
/// A channel-specific handle (phone/email/portal user). NOT identity — stays
|
/// A channel-specific handle (phone/email/portal user). NOT identity — stays
|
||||||
/// UNVERIFIED until MDM resolves it; no silent merge.
|
/// UNVERIFIED until MDM resolves it; no silent merge.
|
||||||
model IiosSourceHandle {
|
model IiosSourceHandle {
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import { Module } from '@nestjs/common';
|
|||||||
import { MediaModule } from '../media/media.module';
|
import { MediaModule } from '../media/media.module';
|
||||||
import { CapabilityProviderRegistry } from './capability.registry';
|
import { CapabilityProviderRegistry } from './capability.registry';
|
||||||
import { CapabilityBroker } from './capability.broker';
|
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
|
* The governed egress boundary (P9 slice 1). Exports the broker + registry so the
|
||||||
* outbound layer routes all sends through policy + obligations + a pluggable
|
* outbound layer routes all sends through policy + obligations + a pluggable
|
||||||
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
|
* 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({
|
@Module({
|
||||||
imports: [MediaModule],
|
imports: [MediaModule],
|
||||||
providers: [CapabilityProviderRegistry, CapabilityBroker],
|
controllers: [ProviderCredentialController],
|
||||||
exports: [CapabilityBroker, CapabilityProviderRegistry],
|
providers: [CapabilityProviderRegistry, CapabilityBroker, ProviderCredentialService],
|
||||||
|
exports: [CapabilityBroker, CapabilityProviderRegistry, ProviderCredentialService],
|
||||||
})
|
})
|
||||||
export class CapabilityModule {}
|
export class CapabilityModule {}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { SandboxProvider } from './sandbox.provider';
|
|||||||
import { HttpProvider } from './http.provider';
|
import { HttpProvider } from './http.provider';
|
||||||
import { EmailProvider } from './email.provider';
|
import { EmailProvider } from './email.provider';
|
||||||
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.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';
|
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
|
||||||
|
|
||||||
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
||||||
@@ -21,7 +24,10 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
|
|||||||
export class CapabilityProviderRegistry {
|
export class CapabilityProviderRegistry {
|
||||||
private readonly byChannel = new Map<string, CapabilityProvider>();
|
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) this.register(new SandboxProvider([ch]));
|
||||||
for (const ch of DEFAULT_CHANNELS) {
|
for (const ch of DEFAULT_CHANNELS) {
|
||||||
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
|
||||||
@@ -36,6 +42,12 @@ export class CapabilityProviderRegistry {
|
|||||||
const resolver = this.storage ? storageResolver(this.storage) : undefined;
|
const resolver = this.storage ? storageResolver(this.storage) : undefined;
|
||||||
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
|
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 {
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user