tenant-secrets.ts153 lines · main
1import { newId } from '@briven/shared';
2import { and, eq } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { tenantSecrets } from '../db/schema.js';
6import { log } from '../lib/logger.js';
7
8import {
9 decryptTenantSecret,
10 encryptTenantSecret,
11 type TenantService,
12} from './tenant-secret-store.js';
13
14/**
15 * Persistence layer for per-tenant encrypted secrets (OAuth client secrets,
16 * mittera API keys, webhook signing keys). The crypto lives in
17 * `tenant-secret-store.ts` (HKDF-SHA256 per-tenant key + AES-256-GCM); this
18 * file is the store-it / read-it helper around it, backed by the
19 * control-plane `tenant_secrets` table.
20 *
21 * Identity is the (projectId, service, name) triple — the same namespace the
22 * encryption is scoped to. `service` ('auth' | 'pay') keeps the two briven
23 * services' secrets isolated without separate tables. Plaintext never lands
24 * in the database and `hasTenantSecret` never decrypts.
25 */
26
27// Re-export so callers can type their `service` argument without reaching
28// into the crypto primitive directly.
29export type { TenantService } from './tenant-secret-store.js';
30
31/**
32 * Store (or overwrite) a secret. Encrypts the plaintext via
33 * `encryptTenantSecret`, then UPSERTs keyed by (projectId, service, name).
34 * `createdBy` is recorded on insert only — an overwrite leaves the original
35 * actor in place and just refreshes `encryptedValue` + `updatedAt`.
36 *
37 * Control plane is Postgres 17, so `onConflictDoUpdate` is available (unlike
38 * the DoltGres data plane which needs a manual insert-then-update emulation).
39 */
40export async function setTenantSecret(
41 projectId: string,
42 service: TenantService,
43 name: string,
44 plaintext: string,
45 createdBy?: string | null,
46): Promise<void> {
47 const db = getDb();
48 const encryptedValue = encryptTenantSecret({ service, projectId, plaintext });
49 await db
50 .insert(tenantSecrets)
51 .values({
52 id: newId('tsec'),
53 projectId,
54 service,
55 name,
56 encryptedValue,
57 createdBy: createdBy ?? null,
58 })
59 .onConflictDoUpdate({
60 target: [tenantSecrets.projectId, tenantSecrets.service, tenantSecrets.name],
61 set: { encryptedValue, updatedAt: new Date() },
62 });
63}
64
65/**
66 * Read and decrypt a secret. Returns the plaintext, or `null` when no row
67 * exists for the (projectId, service, name) triple.
68 */
69export async function getTenantSecret(
70 projectId: string,
71 service: TenantService,
72 name: string,
73): Promise<string | null> {
74 const db = getDb();
75 const [row] = await db
76 .select()
77 .from(tenantSecrets)
78 .where(
79 and(
80 eq(tenantSecrets.projectId, projectId),
81 eq(tenantSecrets.service, service),
82 eq(tenantSecrets.name, name),
83 ),
84 )
85 .limit(1);
86 if (!row) return null;
87 try {
88 return decryptTenantSecret({
89 service,
90 projectId,
91 ciphertext: row.encryptedValue,
92 });
93 } catch (err) {
94 // Row exists but ciphertext won't open (e.g. master key rotated). Callers
95 // treat null as "not configured" so the dashboard asks the user to re-save.
96 const message = err instanceof Error ? err.message : String(err);
97 log.warn('tenant_secret_decrypt_failed', {
98 projectId,
99 service,
100 name,
101 message,
102 });
103 return null;
104 }
105}
106
107/**
108 * Presence check only — returns whether a secret exists for the
109 * (projectId, service, name) triple. NEVER reads or decrypts the
110 * ciphertext, so it's safe on a hot path that only needs the "is it
111 * configured?" answer.
112 */
113export async function hasTenantSecret(
114 projectId: string,
115 service: TenantService,
116 name: string,
117): Promise<boolean> {
118 const db = getDb();
119 const [row] = await db
120 .select({ id: tenantSecrets.id })
121 .from(tenantSecrets)
122 .where(
123 and(
124 eq(tenantSecrets.projectId, projectId),
125 eq(tenantSecrets.service, service),
126 eq(tenantSecrets.name, name),
127 ),
128 )
129 .limit(1);
130 return row !== undefined;
131}
132
133/**
134 * Permanently remove a secret row. Idempotent — missing row is success.
135 */
136export async function deleteTenantSecret(
137 projectId: string,
138 service: TenantService,
139 name: string,
140): Promise<boolean> {
141 const db = getDb();
142 const deleted = await db
143 .delete(tenantSecrets)
144 .where(
145 and(
146 eq(tenantSecrets.projectId, projectId),
147 eq(tenantSecrets.service, service),
148 eq(tenantSecrets.name, name),
149 ),
150 )
151 .returning({ id: tenantSecrets.id });
152 return deleted.length > 0;
153}