| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153 |
- import { newId } from '@briven/shared';
- import { and, eq } from 'drizzle-orm';
- import { getDb } from '../db/client.js';
- import { tenantSecrets } from '../db/schema.js';
- import { log } from '../lib/logger.js';
- import {
- decryptTenantSecret,
- encryptTenantSecret,
- type TenantService,
- } from './tenant-secret-store.js';
- /**
- * Persistence layer for per-tenant encrypted secrets (OAuth client secrets,
- * mittera API keys, webhook signing keys). The crypto lives in
- * `tenant-secret-store.ts` (HKDF-SHA256 per-tenant key + AES-256-GCM); this
- * file is the store-it / read-it helper around it, backed by the
- * control-plane `tenant_secrets` table.
- *
- * Identity is the (projectId, service, name) triple — the same namespace the
- * encryption is scoped to. `service` ('auth' | 'pay') keeps the two briven
- * services' secrets isolated without separate tables. Plaintext never lands
- * in the database and `hasTenantSecret` never decrypts.
- */
- // Re-export so callers can type their `service` argument without reaching
- // into the crypto primitive directly.
- export type { TenantService } from './tenant-secret-store.js';
- /**
- * Store (or overwrite) a secret. Encrypts the plaintext via
- * `encryptTenantSecret`, then UPSERTs keyed by (projectId, service, name).
- * `createdBy` is recorded on insert only — an overwrite leaves the original
- * actor in place and just refreshes `encryptedValue` + `updatedAt`.
- *
- * Control plane is Postgres 17, so `onConflictDoUpdate` is available (unlike
- * the DoltGres data plane which needs a manual insert-then-update emulation).
- */
- export async function setTenantSecret(
- projectId: string,
- service: TenantService,
- name: string,
- plaintext: string,
- createdBy?: string | null,
- ): Promise<void> {
- const db = getDb();
- const encryptedValue = encryptTenantSecret({ service, projectId, plaintext });
- await db
- .insert(tenantSecrets)
- .values({
- id: newId('tsec'),
- projectId,
- service,
- name,
- encryptedValue,
- createdBy: createdBy ?? null,
- })
- .onConflictDoUpdate({
- target: [tenantSecrets.projectId, tenantSecrets.service, tenantSecrets.name],
- set: { encryptedValue, updatedAt: new Date() },
- });
- }
- /**
- * Read and decrypt a secret. Returns the plaintext, or `null` when no row
- * exists for the (projectId, service, name) triple.
- */
- export async function getTenantSecret(
- projectId: string,
- service: TenantService,
- name: string,
- ): Promise<string | null> {
- const db = getDb();
- const [row] = await db
- .select()
- .from(tenantSecrets)
- .where(
- and(
- eq(tenantSecrets.projectId, projectId),
- eq(tenantSecrets.service, service),
- eq(tenantSecrets.name, name),
- ),
- )
- .limit(1);
- if (!row) return null;
- try {
- return decryptTenantSecret({
- service,
- projectId,
- ciphertext: row.encryptedValue,
- });
- } catch (err) {
- // Row exists but ciphertext won't open (e.g. master key rotated). Callers
- // treat null as "not configured" so the dashboard asks the user to re-save.
- const message = err instanceof Error ? err.message : String(err);
- log.warn('tenant_secret_decrypt_failed', {
- projectId,
- service,
- name,
- message,
- });
- return null;
- }
- }
- /**
- * Presence check only — returns whether a secret exists for the
- * (projectId, service, name) triple. NEVER reads or decrypts the
- * ciphertext, so it's safe on a hot path that only needs the "is it
- * configured?" answer.
- */
- export async function hasTenantSecret(
- projectId: string,
- service: TenantService,
- name: string,
- ): Promise<boolean> {
- const db = getDb();
- const [row] = await db
- .select({ id: tenantSecrets.id })
- .from(tenantSecrets)
- .where(
- and(
- eq(tenantSecrets.projectId, projectId),
- eq(tenantSecrets.service, service),
- eq(tenantSecrets.name, name),
- ),
- )
- .limit(1);
- return row !== undefined;
- }
- /**
- * Permanently remove a secret row. Idempotent — missing row is success.
- */
- export async function deleteTenantSecret(
- projectId: string,
- service: TenantService,
- name: string,
- ): Promise<boolean> {
- const db = getDb();
- const deleted = await db
- .delete(tenantSecrets)
- .where(
- and(
- eq(tenantSecrets.projectId, projectId),
- eq(tenantSecrets.service, service),
- eq(tenantSecrets.name, name),
- ),
- )
- .returning({ id: tenantSecrets.id });
- return deleted.length > 0;
- }
|