@opengeni/db 0.12.0 → 0.12.6

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