db.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import { Hono, type Context } from 'hono';
  2. import { z } from 'zod';
  3. import { rateLimit } from '../middleware/rate-limit.js';
  4. import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js';
  5. import { requireServiceProduct } from '../middleware/service-product.js';
  6. import { requireRecentMfa } from '../middleware/step-up.js';
  7. import type { ProjectAppEnv as AppEnv } from '../types/app-env.js';
  8. import { audit, hashIp } from '../services/audit.js';
  9. import { issueShellToken } from '../services/db-shell.js';
  10. import { getProjectInfo } from '../services/projects.js';
  11. import { createSnapshot, listSnapshots, restoreSnapshot } from '../services/snapshots.js';
  12. import {
  13. checkProjectDbHealth,
  14. dropProjectDatabase,
  15. evictProjectPool,
  16. provisionProjectDatabase,
  17. } from '../db/data-plane.js';
  18. function ipHash(c: Context<AppEnv>): string | null {
  19. const fwd = c.req.raw.headers.get('x-forwarded-for');
  20. const ip = fwd ? fwd.split(',')[0]!.trim() : null;
  21. return hashIp(ip);
  22. }
  23. export const dbRouter = new Hono<AppEnv>();
  24. // `db/shell-token` rotates a privileged DSN — admin-tier. Doltgres wall only.
  25. dbRouter.use(
  26. '/v1/projects/:id/db/*',
  27. requireProjectAuth(),
  28. requireServiceProduct('db'),
  29. requireProjectRole('admin'),
  30. );
  31. // why: 5/min per project is enough for a human-driven `briven db shell`
  32. // loop and restrictive enough that a leaked api key can't silently
  33. // harvest fresh DSNs.
  34. dbRouter.post(
  35. '/v1/projects/:id/db/shell-token',
  36. rateLimit({
  37. scope: 'db-shell-token',
  38. limit: 5,
  39. windowMs: 60_000,
  40. key: (c) => c.req.param('id') ?? null,
  41. }),
  42. async (c) => {
  43. const projectId = c.req.param('id');
  44. const user = c.get('user');
  45. const apiKeyId = c.get('apiKeyId');
  46. const { dsn, role, expiresAt } = await issueShellToken(projectId);
  47. await audit({
  48. actorId: user?.id ?? null,
  49. projectId,
  50. action: 'db.shell_token',
  51. ipHash: ipHash(c),
  52. userAgent: c.req.header('user-agent') ?? null,
  53. // why: record expiry only; DSN + password are never audit-logged.
  54. metadata: { expiresAt: expiresAt.toISOString(), via: apiKeyId ? 'api_key' : 'session' },
  55. });
  56. return c.json({ dsn, role, expiresAt: expiresAt.toISOString() });
  57. },
  58. );
  59. /* ─── customer: per-project database lifecycle ──────────────────────── */
  60. //
  61. // Same capability as the admin database card, scoped to the caller's own
  62. // project. Router-level gate above already requires project role 'admin';
  63. // reprovision additionally requires 'owner'. The three MUTATIONS carry the
  64. // same recent-step-up rule as admin mutations (requireRecentMfa(10)) — the
  65. // dashboard surfaces an inline password prompt on 403 step_up_required.
  66. // That makes them session-only in practice: api keys / CLI JWTs can't
  67. // attest step-up, so agents use the MCP db_* tools instead.
  68. const dbMfa = requireRecentMfa(10);
  69. /**
  70. * Health probe — reachability, latency, user-table count, HEAD commit.
  71. * Fail-soft in the service (never throws), so this always answers 200 for
  72. * an authorised caller. Also returns the caller's effective project role
  73. * so the dashboard card can hide owner-only controls without a second
  74. * round-trip.
  75. */
  76. dbRouter.get('/v1/projects/:id/db/health', async (c) => {
  77. const projectId = c.req.param('id');
  78. const user = c.get('user');
  79. const apiKeyId = c.get('apiKeyId');
  80. const health = await checkProjectDbHealth(projectId);
  81. await audit({
  82. actorId: user?.id ?? null,
  83. projectId,
  84. action: 'project.database.health',
  85. ipHash: ipHash(c),
  86. userAgent: c.req.header('user-agent') ?? null,
  87. metadata: { reachable: health.reachable, via: apiKeyId ? 'api_key' : 'session' },
  88. });
  89. return c.json({ health, role: c.get('projectRole') });
  90. });
  91. /** List the project's snapshots (recovery points), newest first. */
  92. dbRouter.get('/v1/projects/:id/db/snapshots', async (c) => {
  93. const projectId = c.req.param('id');
  94. const user = c.get('user');
  95. const apiKeyId = c.get('apiKeyId');
  96. const snapshots = await listSnapshots(projectId);
  97. await audit({
  98. actorId: user?.id ?? null,
  99. projectId,
  100. action: 'project.database.snapshots',
  101. ipHash: ipHash(c),
  102. userAgent: c.req.header('user-agent') ?? null,
  103. metadata: { count: snapshots.length, via: apiKeyId ? 'api_key' : 'session' },
  104. });
  105. return c.json({ snapshots });
  106. });
  107. /**
  108. * Restart the project's database connections: evict the cached pool so the
  109. * very next query opens fresh with a fresh auth handshake. Clears the
  110. * stuck-connection / stale-auth class of incidents without touching any
  111. * data. Returns the post-restart health so the UI confirms in one trip.
  112. */
  113. dbRouter.post(
  114. '/v1/projects/:id/db/restart',
  115. rateLimit({
  116. scope: 'db-restart',
  117. limit: 5,
  118. windowMs: 60_000,
  119. key: (c) => c.req.param('id') ?? null,
  120. }),
  121. dbMfa,
  122. async (c) => {
  123. const projectId = c.req.param('id');
  124. const user = c.get('user');
  125. await evictProjectPool(projectId);
  126. const health = await checkProjectDbHealth(projectId);
  127. await audit({
  128. actorId: user?.id ?? null,
  129. projectId,
  130. action: 'project.database.restart',
  131. ipHash: ipHash(c),
  132. userAgent: c.req.header('user-agent') ?? null,
  133. metadata: { reachable: health.reachable },
  134. });
  135. return c.json({ restarted: true, health });
  136. },
  137. );
  138. const dbRecoverBody = z.object({
  139. snapshotId: z.string().min(1),
  140. confirm: z.string(),
  141. });
  142. /**
  143. * Recover the project's database to a snapshot. Requires the literal
  144. * confirm word "RECOVER" (same rule as the MCP db_recover tool). Always
  145. * takes a fresh manual safety snapshot FIRST — so the recover itself is
  146. * reversible — then hard-resets to the target and evicts the pool so no
  147. * connection keeps serving pre-recover state. Audited with both ids.
  148. */
  149. dbRouter.post(
  150. '/v1/projects/:id/db/recover',
  151. rateLimit({
  152. scope: 'db-recover',
  153. limit: 3,
  154. windowMs: 300_000,
  155. key: (c) => c.req.param('id') ?? null,
  156. }),
  157. dbMfa,
  158. async (c) => {
  159. const projectId = c.req.param('id');
  160. const user = c.get('user');
  161. const parsed = dbRecoverBody.safeParse(await c.req.json().catch(() => null));
  162. if (!parsed.success) {
  163. return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
  164. }
  165. if (parsed.data.confirm !== 'RECOVER') {
  166. return c.json(
  167. { code: 'confirm_mismatch', message: 'type RECOVER to confirm this recovery' },
  168. 400,
  169. );
  170. }
  171. const pre = await createSnapshot(projectId, `pre-recover ${parsed.data.snapshotId}`, {
  172. auto: false,
  173. });
  174. const { restored } = await restoreSnapshot(projectId, parsed.data.snapshotId);
  175. await evictProjectPool(projectId);
  176. await audit({
  177. actorId: user?.id ?? null,
  178. projectId,
  179. action: 'project.database.recover',
  180. ipHash: ipHash(c),
  181. userAgent: c.req.header('user-agent') ?? null,
  182. metadata: { snapshotId: parsed.data.snapshotId, preRecoverySnapshotId: pre.id },
  183. });
  184. return c.json({
  185. recovered: true,
  186. preRecoverySnapshotId: pre.id,
  187. tablesAfterRecover: restored,
  188. });
  189. },
  190. );
  191. const dbReprovisionBody = z.object({
  192. confirmName: z.string().min(1),
  193. force: z.boolean().optional(),
  194. });
  195. /**
  196. * Nuke-and-rebuild the project's database: drop it (data AND snapshots
  197. * gone permanently) and provision a fresh empty one. Owner-only — api
  198. * keys can never be minted at 'owner', so this is session-only by
  199. * construction. Guarded by a typed confirmation (the project's slug or
  200. * name, same as admin) and, like the MCP db_reprovision tool, refuses a
  201. * healthy non-empty database unless `force` is set — a working database
  202. * should be recovered, not razed.
  203. */
  204. dbRouter.post(
  205. '/v1/projects/:id/db/reprovision',
  206. rateLimit({
  207. scope: 'db-reprovision',
  208. limit: 2,
  209. windowMs: 3_600_000,
  210. key: (c) => c.req.param('id') ?? null,
  211. }),
  212. requireProjectRole('owner'),
  213. dbMfa,
  214. async (c) => {
  215. const projectId = c.req.param('id');
  216. const user = c.get('user');
  217. const parsed = dbReprovisionBody.safeParse(await c.req.json().catch(() => null));
  218. if (!parsed.success) {
  219. return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
  220. }
  221. const project = await getProjectInfo(projectId);
  222. if (parsed.data.confirmName !== project.slug && parsed.data.confirmName !== project.name) {
  223. return c.json(
  224. {
  225. code: 'confirm_mismatch',
  226. message: 'confirmation does not match the project slug or name',
  227. },
  228. 400,
  229. );
  230. }
  231. const prior = await checkProjectDbHealth(projectId);
  232. if (prior.reachable && (prior.tableCount ?? 0) > 0 && parsed.data.force !== true) {
  233. return c.json(
  234. {
  235. code: 'healthy_database',
  236. message: `the database is healthy with ${prior.tableCount} table(s) — recover it instead, or pass force to destroy everything`,
  237. },
  238. 409,
  239. );
  240. }
  241. await evictProjectPool(projectId);
  242. await dropProjectDatabase(projectId);
  243. await provisionProjectDatabase(projectId);
  244. await audit({
  245. actorId: user?.id ?? null,
  246. projectId,
  247. action: 'project.database.reprovision',
  248. ipHash: ipHash(c),
  249. userAgent: c.req.header('user-agent') ?? null,
  250. metadata: { slug: project.slug, forced: parsed.data.force === true },
  251. });
  252. return c.json({ reprovisioned: true, health: await checkProjectDbHealth(projectId) });
  253. },
  254. );