@opengeni/db 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/chunk-ELMUCYZF.js +906 -0
  2. package/dist/chunk-ELMUCYZF.js.map +1 -0
  3. package/dist/chunk-N4WTE6SB.js +4033 -0
  4. package/dist/chunk-N4WTE6SB.js.map +1 -0
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +3594 -2059
  7. package/dist/index.js.map +1 -1
  8. package/dist/provision-roles.d.ts +472 -43
  9. package/dist/provision-roles.js +1 -1
  10. package/dist/{schema-BejThLcd.d.ts → schema-Bh1Hr7xY.d.ts} +2507 -970
  11. package/dist/schema.d.ts +1 -1
  12. package/dist/schema.js +9 -1
  13. package/drizzle/0122_codex_capacity_same_turn.sql +59 -0
  14. package/drizzle/0123_session_tool_policy_version.sql +14 -0
  15. package/drizzle/0124_session_event_duplicate_lookup.sql +4 -0
  16. package/drizzle/0125_document_drops_visibility.sql +29 -0
  17. package/drizzle/0126_document_access_constraints.sql +138 -0
  18. package/drizzle/0127_document_default_base_index.sql +5 -0
  19. package/drizzle/0128_github_installation_authority.sql +69 -0
  20. package/drizzle/0129_retained_process_reconciliation.sql +356 -0
  21. package/drizzle/0130_workspace_instruction_policies.sql +330 -0
  22. package/drizzle/0131_slack_bot_install_and_post_idempotency.sql +133 -0
  23. package/package.json +4 -3
  24. package/src/event-payload-sanitizer.ts +167 -55
  25. package/src/index.ts +1850 -264
  26. package/src/new-session-drafts.ts +127 -6
  27. package/src/provision-roles.ts +184 -2
  28. package/src/runtime-posture-cli.ts +57 -0
  29. package/src/runtime-posture.ts +770 -0
  30. package/src/schema.ts +243 -3
  31. package/src/session-control.ts +1 -0
  32. package/src/workspace-instruction-policies-schema.ts +140 -0
  33. package/src/workspace-instruction-policies.ts +624 -0
  34. package/dist/chunk-BMFDXFPA.js +0 -155
  35. package/dist/chunk-BMFDXFPA.js.map +0 -1
  36. package/dist/chunk-VUKRIBO5.js +0 -3679
  37. package/dist/chunk-VUKRIBO5.js.map +0 -1
@@ -0,0 +1,770 @@
1
+ import { sql } from "drizzle-orm";
2
+ import type { Database, RlsStrategy } from "./index";
3
+
4
+ /**
5
+ * The complete standalone tenant-table contract. Adding or removing a
6
+ * FORCE-RLS table is an architectural change: update this list in the same
7
+ * commit as the migration so startup cannot silently accept an unreviewed gap.
8
+ */
9
+ export const FORCE_RLS_TABLES = [
10
+ "agent_run_states",
11
+ "api_keys",
12
+ "audit_events",
13
+ "billing_customers",
14
+ "capability_catalog_items",
15
+ "capability_installations",
16
+ "codex_capacity_waiters",
17
+ "codex_credential_leases",
18
+ "codex_reset_redemption_attempts",
19
+ "codex_rotation_settings",
20
+ "codex_subscription_credentials",
21
+ "composer_drafts",
22
+ "connections",
23
+ "credit_ledger_entries",
24
+ "device_enrollment_requests",
25
+ "document_bases",
26
+ "document_chunks",
27
+ "documents",
28
+ "enrollments",
29
+ "file_uploads",
30
+ "files",
31
+ "github_installation_repositories",
32
+ "github_installations",
33
+ "host_export_config",
34
+ "host_export_consumers",
35
+ "host_export_cursor_state",
36
+ "host_export_dead_letters",
37
+ "host_export_outbox",
38
+ "import_batches",
39
+ "integration_oauth_state_nonces",
40
+ "knowledge_memories",
41
+ "machine_metrics_latest",
42
+ "machine_metrics_series",
43
+ "new_session_drafts",
44
+ "pack_installations",
45
+ "rig_changes",
46
+ "rig_versions",
47
+ "rigs",
48
+ "sandbox_lease_holders",
49
+ "sandbox_leases",
50
+ "sandbox_pty_sessions",
51
+ "sandbox_retained_processes",
52
+ "sandbox_session_envelopes",
53
+ "sandbox_workspace_mutation_admissions",
54
+ "sandboxes",
55
+ "scheduled_task_runs",
56
+ "scheduled_tasks",
57
+ "session_attempt_interruptions",
58
+ "session_command_receipts",
59
+ "session_events",
60
+ "session_goals",
61
+ "session_history_items",
62
+ "session_human_input_requests",
63
+ "session_list_snapshots",
64
+ "session_mcp_servers",
65
+ "session_pending_tool_calls",
66
+ "session_pins",
67
+ "session_recordings",
68
+ "session_spawn_denials",
69
+ "session_stream_acknowledgments",
70
+ "session_system_update_outbox",
71
+ "session_system_updates",
72
+ "session_turn_attempts",
73
+ "session_turns",
74
+ "session_workflow_wake_outbox",
75
+ "sessions",
76
+ "slack_bot_post_operations",
77
+ "social_connections",
78
+ "social_posts",
79
+ "usage_events",
80
+ "workspace_captures",
81
+ "workspace_control_events",
82
+ "workspace_inference_controls",
83
+ "workspace_instruction_policy_activation_events",
84
+ "workspace_instruction_policy_heads",
85
+ "workspace_instruction_policy_revisions",
86
+ "workspace_model_policies",
87
+ "workspace_packs",
88
+ "workspace_session_activity_revisions",
89
+ "workspace_variable_set_variables",
90
+ "workspace_variable_sets",
91
+ ] as const;
92
+
93
+ /**
94
+ * Deployment-global and authentication tables used by ordinary API/worker
95
+ * traffic. They intentionally do not carry workspace RLS: their access model
96
+ * is implemented by the authentication/access layer or by exact global keys.
97
+ */
98
+ export const NON_RLS_RUNTIME_TABLES = [
99
+ "auth_identities",
100
+ "auth_rate_limits",
101
+ "auth_sessions",
102
+ "auth_users",
103
+ "auth_verifications",
104
+ "integration_oauth_clients",
105
+ "managed_accounts",
106
+ "nested_agent_depth_configuration",
107
+ "stripe_webhook_events",
108
+ "workspace_memberships",
109
+ "workspaces",
110
+ ] as const;
111
+
112
+ /**
113
+ * Exact full-CRUD class for the standalone runtime role. This is deliberately
114
+ * explicit instead of being derived from FORCE_RLS_TABLES: adding a protected
115
+ * table must not silently grant it broader DML than its migration intended.
116
+ */
117
+ export const RUNTIME_FULL_DML_TABLES = [
118
+ "agent_run_states",
119
+ "api_keys",
120
+ "audit_events",
121
+ "auth_identities",
122
+ "auth_rate_limits",
123
+ "auth_sessions",
124
+ "auth_users",
125
+ "auth_verifications",
126
+ "billing_customers",
127
+ "capability_catalog_items",
128
+ "capability_installations",
129
+ "codex_capacity_waiters",
130
+ "codex_credential_leases",
131
+ "codex_reset_redemption_attempts",
132
+ "codex_rotation_settings",
133
+ "codex_subscription_credentials",
134
+ "composer_drafts",
135
+ "connections",
136
+ "credit_ledger_entries",
137
+ "device_enrollment_requests",
138
+ "document_bases",
139
+ "document_chunks",
140
+ "documents",
141
+ "enrollments",
142
+ "file_uploads",
143
+ "files",
144
+ "github_installation_repositories",
145
+ "github_installations",
146
+ "import_batches",
147
+ "integration_oauth_clients",
148
+ "integration_oauth_state_nonces",
149
+ "knowledge_memories",
150
+ "machine_metrics_latest",
151
+ "machine_metrics_series",
152
+ "managed_accounts",
153
+ "new_session_drafts",
154
+ "pack_installations",
155
+ "rig_changes",
156
+ "rig_versions",
157
+ "rigs",
158
+ "sandbox_lease_holders",
159
+ "sandbox_leases",
160
+ "sandbox_pty_sessions",
161
+ "sandbox_retained_processes",
162
+ "sandbox_session_envelopes",
163
+ "sandbox_workspace_mutation_admissions",
164
+ "sandboxes",
165
+ "scheduled_task_runs",
166
+ "scheduled_tasks",
167
+ "session_attempt_interruptions",
168
+ "session_command_receipts",
169
+ "session_events",
170
+ "session_goals",
171
+ "session_history_items",
172
+ "session_human_input_requests",
173
+ "session_list_snapshots",
174
+ "session_mcp_servers",
175
+ "session_pending_tool_calls",
176
+ "session_pins",
177
+ "session_recordings",
178
+ "session_stream_acknowledgments",
179
+ "session_system_update_outbox",
180
+ "session_system_updates",
181
+ "session_turn_attempts",
182
+ "session_turns",
183
+ "session_workflow_wake_outbox",
184
+ "sessions",
185
+ "slack_bot_post_operations",
186
+ "social_connections",
187
+ "social_posts",
188
+ "stripe_webhook_events",
189
+ "usage_events",
190
+ "workspace_captures",
191
+ "workspace_control_events",
192
+ "workspace_inference_controls",
193
+ "workspace_instruction_policy_heads",
194
+ "workspace_memberships",
195
+ "workspace_model_policies",
196
+ "workspace_packs",
197
+ "workspace_session_activity_revisions",
198
+ "workspace_variable_set_variables",
199
+ "workspace_variable_sets",
200
+ "workspaces",
201
+ ] as const;
202
+
203
+ /** Configuration is deployment-global and intentionally read-only at runtime. */
204
+ export const RUNTIME_READ_ONLY_TABLES = ["nested_agent_depth_configuration"] as const;
205
+
206
+ /** Append-only evidence/revision tables are insertable and queryable, never mutable. */
207
+ export const RUNTIME_READ_INSERT_TABLES = [
208
+ "session_spawn_denials",
209
+ "workspace_instruction_policy_activation_events",
210
+ "workspace_instruction_policy_revisions",
211
+ ] as const;
212
+
213
+ /**
214
+ * These FORCE-RLS tables are owned by security-definer host-export routines.
215
+ * The ordinary application role must have no direct table privileges on them.
216
+ */
217
+ export const PROTECTED_NO_DIRECT_DML_TABLES = [
218
+ "host_export_config",
219
+ "host_export_consumers",
220
+ "host_export_cursor_state",
221
+ "host_export_dead_letters",
222
+ "host_export_outbox",
223
+ ] as const;
224
+
225
+ export type RuntimeTableDmlPrivilege = "SELECT" | "INSERT" | "UPDATE" | "DELETE";
226
+ export type RuntimeTablePrivilegeContract = Readonly<
227
+ Record<string, readonly RuntimeTableDmlPrivilege[]>
228
+ >;
229
+
230
+ const FULL_DML_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE"] as const;
231
+
232
+ /** Exact per-table DML contract. Absence means no direct table privileges. */
233
+ export const RUNTIME_TABLE_PRIVILEGES: RuntimeTablePrivilegeContract = Object.freeze({
234
+ ...Object.fromEntries(RUNTIME_FULL_DML_TABLES.map((table) => [table, FULL_DML_PRIVILEGES])),
235
+ ...Object.fromEntries(RUNTIME_READ_ONLY_TABLES.map((table) => [table, ["SELECT"] as const])),
236
+ ...Object.fromEntries(
237
+ RUNTIME_READ_INSERT_TABLES.map((table) => [table, ["SELECT", "INSERT"] as const]),
238
+ ),
239
+ });
240
+
241
+ /** All tables with any direct runtime DML; retained as the aggregate public contract. */
242
+ export const RUNTIME_DML_TABLES = Object.freeze(
243
+ Object.keys(RUNTIME_TABLE_PRIVILEGES).sort((a, b) => a.localeCompare(b)),
244
+ );
245
+
246
+ export type RuntimeDatabasePostureOptions = {
247
+ rlsStrategy: RlsStrategy;
248
+ expectedRole?: string;
249
+ targetSchema?: string;
250
+ protectedTables?: readonly string[];
251
+ tablePrivileges?: RuntimeTablePrivilegeContract;
252
+ protectedNoDirectDmlTables?: readonly string[];
253
+ };
254
+
255
+ export type RuntimeDatabaseIdentity = {
256
+ currentUser: string;
257
+ sessionUser: string;
258
+ databaseOwner: string;
259
+ canConnectDatabase: boolean;
260
+ canCreateInDatabase: boolean;
261
+ rowSecurity: string;
262
+ canLogin: boolean;
263
+ superuser: boolean;
264
+ inherit: boolean;
265
+ createRole: boolean;
266
+ createDatabase: boolean;
267
+ replication: boolean;
268
+ bypassRls: boolean;
269
+ };
270
+
271
+ export type RuntimeSchemaPosture = {
272
+ name: string;
273
+ owner: string;
274
+ usage: boolean;
275
+ create: boolean;
276
+ };
277
+
278
+ export type RuntimeTablePosture = {
279
+ name: string;
280
+ owner: string;
281
+ rlsEnabled: boolean;
282
+ rlsForced: boolean;
283
+ rlsActive: boolean;
284
+ policyCount: number;
285
+ select: boolean;
286
+ insert: boolean;
287
+ update: boolean;
288
+ delete: boolean;
289
+ truncate: boolean;
290
+ references: boolean;
291
+ trigger: boolean;
292
+ };
293
+
294
+ export type RuntimeRoutinePosture = {
295
+ name: string;
296
+ owner: string;
297
+ execute: boolean;
298
+ };
299
+
300
+ export type RuntimeDatabasePosture = {
301
+ identity: RuntimeDatabaseIdentity;
302
+ memberships: string[];
303
+ schemas: RuntimeSchemaPosture[];
304
+ ownedSchemas: string[];
305
+ ownedRelations: string[];
306
+ tables: RuntimeTablePosture[];
307
+ privateRoutines: RuntimeRoutinePosture[];
308
+ };
309
+
310
+ export class RuntimeDatabasePostureError extends Error {
311
+ readonly violations: readonly string[];
312
+
313
+ constructor(violations: readonly string[]) {
314
+ super(`Runtime database posture check failed: ${violations.join("; ")}`);
315
+ this.name = "RuntimeDatabasePostureError";
316
+ this.violations = violations;
317
+ }
318
+ }
319
+
320
+ type IdentityRow = {
321
+ current_user: string;
322
+ session_user: string;
323
+ database_owner: string;
324
+ can_connect_database: boolean;
325
+ can_create_in_database: boolean;
326
+ row_security: string;
327
+ rolcanlogin: boolean;
328
+ rolsuper: boolean;
329
+ rolinherit: boolean;
330
+ rolcreaterole: boolean;
331
+ rolcreatedb: boolean;
332
+ rolreplication: boolean;
333
+ rolbypassrls: boolean;
334
+ };
335
+
336
+ function resultRows<T>(result: unknown): T[] {
337
+ if (Array.isArray(result)) {
338
+ return result as T[];
339
+ }
340
+ const rows = (result as { rows?: unknown } | null)?.rows;
341
+ if (Array.isArray(rows)) {
342
+ return rows as T[];
343
+ }
344
+ throw new Error("Runtime database posture query returned an unsupported result shape");
345
+ }
346
+
347
+ function sorted(values: Iterable<string>): string[] {
348
+ return [...values].sort((a, b) => a.localeCompare(b));
349
+ }
350
+
351
+ function difference(left: ReadonlySet<string>, right: ReadonlySet<string>): string[] {
352
+ return sorted([...left].filter((value) => !right.has(value)));
353
+ }
354
+
355
+ /** Inspect only PostgreSQL catalogs and privilege functions; no tenant rows. */
356
+ export async function inspectRuntimeDatabasePosture(
357
+ db: Database,
358
+ options: RuntimeDatabasePostureOptions,
359
+ ): Promise<RuntimeDatabasePosture> {
360
+ const targetSchema = options.targetSchema?.trim() || "public";
361
+
362
+ return await db.transaction(
363
+ async (tx) => {
364
+ const identityRows = resultRows<IdentityRow>(
365
+ await tx.execute(sql`
366
+ select
367
+ current_user::text as current_user,
368
+ session_user::text as session_user,
369
+ pg_get_userbyid(d.datdba)::text as database_owner,
370
+ has_database_privilege(current_user, d.oid, 'CONNECT') as can_connect_database,
371
+ has_database_privilege(current_user, d.oid, 'CREATE') as can_create_in_database,
372
+ current_setting('row_security')::text as row_security,
373
+ r.rolcanlogin,
374
+ r.rolsuper,
375
+ r.rolinherit,
376
+ r.rolcreaterole,
377
+ r.rolcreatedb,
378
+ r.rolreplication,
379
+ r.rolbypassrls
380
+ from pg_roles r
381
+ join pg_database d on d.datname = current_database()
382
+ where r.rolname = current_user
383
+ `),
384
+ );
385
+ const identity = identityRows[0];
386
+ if (!identity) {
387
+ throw new Error("Runtime database posture could not resolve the current PostgreSQL role");
388
+ }
389
+
390
+ const mappedIdentity: RuntimeDatabaseIdentity = {
391
+ currentUser: identity.current_user,
392
+ sessionUser: identity.session_user,
393
+ databaseOwner: identity.database_owner,
394
+ canConnectDatabase: identity.can_connect_database,
395
+ canCreateInDatabase: identity.can_create_in_database,
396
+ rowSecurity: identity.row_security,
397
+ canLogin: identity.rolcanlogin,
398
+ superuser: identity.rolsuper,
399
+ inherit: identity.rolinherit,
400
+ createRole: identity.rolcreaterole,
401
+ createDatabase: identity.rolcreatedb,
402
+ replication: identity.rolreplication,
403
+ bypassRls: identity.rolbypassrls,
404
+ };
405
+
406
+ // Scoped/embedded topology deliberately leaves ownership and isolation to
407
+ // the host. Prove the connection identity is coherent, but do not impose
408
+ // the standalone opengeni_app object/grant contract on the host's role.
409
+ if (options.rlsStrategy === "scoped") {
410
+ return {
411
+ identity: mappedIdentity,
412
+ memberships: [],
413
+ schemas: [],
414
+ ownedSchemas: [],
415
+ ownedRelations: [],
416
+ tables: [],
417
+ privateRoutines: [],
418
+ };
419
+ }
420
+
421
+ const memberships = resultRows<{ relationship: string }>(
422
+ await tx.execute(sql`
423
+ with recursive inherited_roles(oid, rolname) as (
424
+ select parent.oid, parent.rolname
425
+ from pg_auth_members membership
426
+ join pg_roles member on member.oid = membership.member
427
+ join pg_roles parent on parent.oid = membership.roleid
428
+ where member.rolname = current_user
429
+ union
430
+ select parent.oid, parent.rolname
431
+ from inherited_roles inherited
432
+ join pg_auth_members membership on membership.member = inherited.oid
433
+ join pg_roles parent on parent.oid = membership.roleid
434
+ )
435
+ select ('inherits:' || rolname)::text as relationship from inherited_roles
436
+ union
437
+ select ('member:' || member.rolname)::text as relationship
438
+ from pg_auth_members membership
439
+ join pg_roles parent on parent.oid = membership.roleid
440
+ join pg_roles member on member.oid = membership.member
441
+ where parent.rolname = current_user
442
+ order by relationship
443
+ `),
444
+ ).map((row) => row.relationship);
445
+
446
+ const schemas = resultRows<{
447
+ name: string;
448
+ owner: string;
449
+ usage: boolean;
450
+ create: boolean;
451
+ }>(
452
+ await tx.execute(sql`
453
+ select
454
+ n.nspname::text as name,
455
+ pg_get_userbyid(n.nspowner)::text as owner,
456
+ has_schema_privilege(current_user, n.oid, 'USAGE') as usage,
457
+ has_schema_privilege(current_user, n.oid, 'CREATE') as create
458
+ from pg_namespace n
459
+ where n.nspname in (${targetSchema}, 'opengeni_private')
460
+ order by n.nspname
461
+ `),
462
+ );
463
+
464
+ const ownedSchemas = resultRows<{ name: string }>(
465
+ await tx.execute(sql`
466
+ select n.nspname::text as name
467
+ from pg_namespace n
468
+ join pg_roles r on r.oid = n.nspowner
469
+ where r.rolname = current_user
470
+ and n.nspname <> 'information_schema'
471
+ and n.nspname !~ '^pg_'
472
+ order by n.nspname
473
+ `),
474
+ ).map((row) => row.name);
475
+
476
+ const ownedRelations = resultRows<{ name: string }>(
477
+ await tx.execute(sql`
478
+ select (n.nspname || '.' || c.relname)::text as name
479
+ from pg_class c
480
+ join pg_namespace n on n.oid = c.relnamespace
481
+ join pg_roles r on r.oid = c.relowner
482
+ where r.rolname = current_user
483
+ and c.relkind in ('r', 'p', 'S', 'v', 'm', 'f')
484
+ and n.nspname <> 'information_schema'
485
+ and n.nspname !~ '^pg_'
486
+ order by n.nspname, c.relname
487
+ `),
488
+ ).map((row) => row.name);
489
+
490
+ const tables = resultRows<{
491
+ name: string;
492
+ owner: string;
493
+ rls_enabled: boolean;
494
+ rls_forced: boolean;
495
+ rls_active: boolean;
496
+ policy_count: number;
497
+ can_select: boolean;
498
+ can_insert: boolean;
499
+ can_update: boolean;
500
+ can_delete: boolean;
501
+ can_truncate: boolean;
502
+ can_references: boolean;
503
+ can_trigger: boolean;
504
+ }>(
505
+ await tx.execute(sql`
506
+ select
507
+ c.relname::text as name,
508
+ pg_get_userbyid(c.relowner)::text as owner,
509
+ c.relrowsecurity as rls_enabled,
510
+ c.relforcerowsecurity as rls_forced,
511
+ row_security_active(c.oid) as rls_active,
512
+ (select count(*)::int from pg_policy policy where policy.polrelid = c.oid) as policy_count,
513
+ has_table_privilege(current_user, c.oid, 'SELECT') as can_select,
514
+ has_table_privilege(current_user, c.oid, 'INSERT') as can_insert,
515
+ has_table_privilege(current_user, c.oid, 'UPDATE') as can_update,
516
+ has_table_privilege(current_user, c.oid, 'DELETE') as can_delete,
517
+ has_table_privilege(current_user, c.oid, 'TRUNCATE') as can_truncate,
518
+ has_table_privilege(current_user, c.oid, 'REFERENCES') as can_references,
519
+ has_table_privilege(current_user, c.oid, 'TRIGGER') as can_trigger
520
+ from pg_class c
521
+ join pg_namespace n on n.oid = c.relnamespace
522
+ where n.nspname = ${targetSchema}
523
+ and c.relkind in ('r', 'p')
524
+ order by c.relname
525
+ `),
526
+ ).map((row) => ({
527
+ name: row.name,
528
+ owner: row.owner,
529
+ rlsEnabled: row.rls_enabled,
530
+ rlsForced: row.rls_forced,
531
+ rlsActive: row.rls_active,
532
+ policyCount: row.policy_count,
533
+ select: row.can_select,
534
+ insert: row.can_insert,
535
+ update: row.can_update,
536
+ delete: row.can_delete,
537
+ truncate: row.can_truncate,
538
+ references: row.can_references,
539
+ trigger: row.can_trigger,
540
+ }));
541
+
542
+ const privateRoutines = resultRows<{
543
+ name: string;
544
+ owner: string;
545
+ can_execute: boolean;
546
+ }>(
547
+ await tx.execute(sql`
548
+ select
549
+ (p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')')::text as name,
550
+ pg_get_userbyid(p.proowner)::text as owner,
551
+ has_function_privilege(current_user, p.oid, 'EXECUTE') as can_execute
552
+ from pg_proc p
553
+ join pg_namespace n on n.oid = p.pronamespace
554
+ where n.nspname = 'opengeni_private'
555
+ and p.prokind in ('f', 'p')
556
+ order by p.proname, pg_get_function_identity_arguments(p.oid)
557
+ `),
558
+ ).map((row) => ({ name: row.name, owner: row.owner, execute: row.can_execute }));
559
+
560
+ return {
561
+ identity: mappedIdentity,
562
+ memberships,
563
+ schemas,
564
+ ownedSchemas,
565
+ ownedRelations,
566
+ tables,
567
+ privateRoutines,
568
+ };
569
+ },
570
+ { isolationLevel: "repeatable read", accessMode: "read only" },
571
+ );
572
+ }
573
+
574
+ /** Pure deterministic evaluator used by startup/readiness and unit tests. */
575
+ export function evaluateRuntimeDatabasePosture(
576
+ posture: RuntimeDatabasePosture,
577
+ options: RuntimeDatabasePostureOptions,
578
+ ): string[] {
579
+ const violations: string[] = [];
580
+ const identity = posture.identity;
581
+
582
+ if (!identity.currentUser || !identity.sessionUser) {
583
+ violations.push("database identity is empty");
584
+ }
585
+ if (identity.currentUser !== identity.sessionUser) {
586
+ violations.push(
587
+ `current_user ${identity.currentUser} does not match session_user ${identity.sessionUser}`,
588
+ );
589
+ }
590
+ if (!identity.canConnectDatabase) {
591
+ violations.push("runtime role lacks CONNECT on the current database");
592
+ }
593
+
594
+ if (options.rlsStrategy === "scoped") {
595
+ return violations;
596
+ }
597
+
598
+ const expectedRole = options.expectedRole?.trim() || "opengeni_app";
599
+ const targetSchema = options.targetSchema?.trim() || "public";
600
+ const protectedTables = new Set(options.protectedTables ?? FORCE_RLS_TABLES);
601
+ const tablePrivileges = options.tablePrivileges ?? RUNTIME_TABLE_PRIVILEGES;
602
+ const directRuntimeTables = new Set(Object.keys(tablePrivileges));
603
+ const protectedNoDirectDmlTables = new Set(
604
+ options.protectedNoDirectDmlTables ??
605
+ (options.protectedTables ? [] : PROTECTED_NO_DIRECT_DML_TABLES),
606
+ );
607
+
608
+ if (identity.currentUser !== expectedRole || identity.sessionUser !== expectedRole) {
609
+ violations.push(
610
+ `runtime identity must be ${expectedRole} (current_user=${identity.currentUser}, session_user=${identity.sessionUser})`,
611
+ );
612
+ }
613
+ if (!identity.canLogin) violations.push("runtime role is not LOGIN");
614
+ if (identity.superuser) violations.push("runtime role is SUPERUSER");
615
+ if (identity.bypassRls) violations.push("runtime role has BYPASSRLS");
616
+ if (identity.createRole) violations.push("runtime role has CREATEROLE");
617
+ if (identity.createDatabase) violations.push("runtime role has CREATEDB");
618
+ if (identity.replication) violations.push("runtime role has REPLICATION");
619
+ if (identity.inherit) violations.push("runtime role must be NOINHERIT");
620
+ if (identity.databaseOwner === expectedRole) violations.push("runtime role owns the database");
621
+ if (identity.canCreateInDatabase) {
622
+ violations.push("runtime role has CREATE on the current database");
623
+ }
624
+ if (identity.rowSecurity.toLowerCase() !== "on") {
625
+ violations.push(`row_security is ${identity.rowSecurity}, expected on`);
626
+ }
627
+ if (posture.memberships.length > 0) {
628
+ violations.push(`runtime role has memberships: ${sorted(posture.memberships).join(", ")}`);
629
+ }
630
+ if (posture.ownedSchemas.length > 0) {
631
+ violations.push(`runtime role owns schemas: ${sorted(posture.ownedSchemas).join(", ")}`);
632
+ }
633
+ if (posture.ownedRelations.length > 0) {
634
+ violations.push(`runtime role owns relations: ${sorted(posture.ownedRelations).join(", ")}`);
635
+ }
636
+
637
+ for (const schemaName of new Set([targetSchema, "opengeni_private"])) {
638
+ const schema = posture.schemas.find((candidate) => candidate.name === schemaName);
639
+ if (!schema) {
640
+ violations.push(`required schema ${schemaName} is missing`);
641
+ continue;
642
+ }
643
+ if (schema.owner === expectedRole) {
644
+ violations.push(`runtime role owns schema ${schemaName}`);
645
+ }
646
+ if (!schema.usage) violations.push(`runtime role lacks USAGE on schema ${schemaName}`);
647
+ if (schema.create) violations.push(`runtime role has CREATE on schema ${schemaName}`);
648
+ }
649
+
650
+ const tableByName = new Map(posture.tables.map((table) => [table.name, table]));
651
+ const actualRlsTables = new Set(
652
+ posture.tables.filter((table) => table.rlsEnabled).map((table) => table.name),
653
+ );
654
+ const classifiedProtectedTables = new Set([
655
+ ...directRuntimeTables,
656
+ ...protectedNoDirectDmlTables,
657
+ ]);
658
+ const unclassifiedProtectedTables = difference(protectedTables, classifiedProtectedTables);
659
+ if (unclassifiedProtectedTables.length > 0) {
660
+ violations.push(
661
+ `protected tables lack an explicit privilege class: ${unclassifiedProtectedTables.join(", ")}`,
662
+ );
663
+ }
664
+ const protectedNoDirectDmlOverlap = difference(
665
+ protectedNoDirectDmlTables,
666
+ new Set([...protectedNoDirectDmlTables].filter((table) => !directRuntimeTables.has(table))),
667
+ );
668
+ if (protectedNoDirectDmlOverlap.length > 0) {
669
+ violations.push(
670
+ `protected no-direct-DML tables also declare runtime privileges: ${protectedNoDirectDmlOverlap.join(", ")}`,
671
+ );
672
+ }
673
+ const nonProtectedNoDirectDmlTables = difference(protectedNoDirectDmlTables, protectedTables);
674
+ if (nonProtectedNoDirectDmlTables.length > 0) {
675
+ violations.push(
676
+ `no-direct-DML tables are absent from the protected contract: ${nonProtectedNoDirectDmlTables.join(", ")}`,
677
+ );
678
+ }
679
+ const catalogTables = new Set(tableByName.keys());
680
+ const missingRuntimeTables = difference(directRuntimeTables, catalogTables);
681
+ if (missingRuntimeTables.length > 0) {
682
+ violations.push(`runtime privilege tables are missing: ${missingRuntimeTables.join(", ")}`);
683
+ }
684
+ const missingTables = difference(protectedTables, catalogTables);
685
+ if (missingTables.length > 0) {
686
+ violations.push(`protected tables are missing: ${missingTables.join(", ")}`);
687
+ }
688
+ const undeclaredRlsTables = difference(actualRlsTables, protectedTables);
689
+ if (undeclaredRlsTables.length > 0) {
690
+ violations.push(
691
+ `RLS tables are absent from the declared contract: ${undeclaredRlsTables.join(", ")}`,
692
+ );
693
+ }
694
+
695
+ for (const table of posture.tables) {
696
+ const privileges = [
697
+ ["SELECT", table.select],
698
+ ["INSERT", table.insert],
699
+ ["UPDATE", table.update],
700
+ ["DELETE", table.delete],
701
+ ["TRUNCATE", table.truncate],
702
+ ["REFERENCES", table.references],
703
+ ["TRIGGER", table.trigger],
704
+ ] as const;
705
+ const expectedPrivileges = new Set<string>(tablePrivileges[table.name] ?? []);
706
+ if (table.owner === expectedRole) {
707
+ violations.push(`runtime role owns table ${table.name}`);
708
+ }
709
+ const missingPrivileges = privileges
710
+ .filter(([privilege, granted]) => expectedPrivileges.has(privilege) && !granted)
711
+ .map(([privilege]) => privilege);
712
+ if (missingPrivileges.length > 0) {
713
+ violations.push(
714
+ `table ${table.name} lacks required runtime privileges: ${missingPrivileges.join(", ")}`,
715
+ );
716
+ }
717
+ const excessPrivileges = privileges
718
+ .filter(([privilege, granted]) => !expectedPrivileges.has(privilege) && granted)
719
+ .map(([privilege]) => privilege);
720
+ if (excessPrivileges.length > 0) {
721
+ violations.push(
722
+ `table ${table.name} grants excess runtime privileges: ${excessPrivileges.join(", ")}`,
723
+ );
724
+ }
725
+ }
726
+
727
+ for (const tableName of protectedTables) {
728
+ const table = tableByName.get(tableName);
729
+ if (!table) continue;
730
+ if (!table.rlsEnabled) violations.push(`table ${tableName} does not ENABLE RLS`);
731
+ if (!table.rlsForced) violations.push(`table ${tableName} does not FORCE RLS`);
732
+ if (!table.rlsActive) violations.push(`table ${tableName} has inactive RLS for runtime role`);
733
+ if (table.policyCount < 1) violations.push(`table ${tableName} has no RLS policy`);
734
+ }
735
+
736
+ if (posture.privateRoutines.length === 0) {
737
+ violations.push("opengeni_private has no helper routines");
738
+ }
739
+ for (const routine of posture.privateRoutines) {
740
+ if (routine.owner === expectedRole) {
741
+ violations.push(`runtime role owns private routine ${routine.name}`);
742
+ }
743
+ if (!routine.execute) {
744
+ violations.push(`runtime role lacks EXECUTE on private routine ${routine.name}`);
745
+ }
746
+ }
747
+
748
+ return violations;
749
+ }
750
+
751
+ export async function assertRuntimeDatabasePosture(
752
+ db: Database,
753
+ options: RuntimeDatabasePostureOptions,
754
+ ): Promise<RuntimeDatabasePosture> {
755
+ const posture = await inspectRuntimeDatabasePosture(db, options);
756
+ const violations = evaluateRuntimeDatabasePosture(posture, options);
757
+ if (violations.length > 0) {
758
+ throw new RuntimeDatabasePostureError(violations);
759
+ }
760
+ return posture;
761
+ }
762
+
763
+ export function runtimeDatabaseReadyCheck(
764
+ db: Database,
765
+ options: RuntimeDatabasePostureOptions,
766
+ ): () => Promise<void> {
767
+ return async () => {
768
+ await assertRuntimeDatabasePosture(db, options);
769
+ };
770
+ }