tenant-secrets.ts 4.4 KB

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