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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user