ddd628ab6f
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>
71 lines
3.3 KiB
TypeScript
71 lines
3.3 KiB
TypeScript
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> };
|
|
}
|
|
}
|