@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.
- package/dist/{chunk-VUKRIBO5.js → chunk-CYA6BHV6.js} +150 -7
- package/dist/chunk-CYA6BHV6.js.map +1 -0
- package/dist/chunk-XRVNI2GB.js +896 -0
- package/dist/chunk-XRVNI2GB.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1166 -265
- package/dist/index.js.map +1 -1
- package/dist/provision-roles.d.ts +324 -38
- package/dist/provision-roles.js +1 -1
- package/dist/{schema-CnpD6BcX.d.ts → schema-CeXWlYcG.d.ts} +441 -12
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +1 -1
- package/drizzle/0122_codex_capacity_same_turn.sql +59 -0
- package/drizzle/0123_session_tool_policy_version.sql +14 -0
- package/drizzle/0124_session_event_duplicate_lookup.sql +4 -0
- package/drizzle/0125_document_drops_visibility.sql +29 -0
- package/drizzle/0126_document_access_constraints.sql +138 -0
- package/drizzle/0127_document_default_base_index.sql +5 -0
- package/drizzle/0128_github_installation_authority.sql +69 -0
- package/drizzle/0129_retained_process_reconciliation.sql +356 -0
- package/package.json +4 -3
- package/src/event-payload-sanitizer.ts +167 -55
- package/src/index.ts +1494 -258
- package/src/new-session-drafts.ts +127 -6
- package/src/provision-roles.ts +184 -2
- package/src/runtime-posture-cli.ts +57 -0
- package/src/runtime-posture.ts +760 -0
- package/src/schema.ts +156 -3
- package/src/session-control.ts +1 -0
- package/dist/chunk-BMFDXFPA.js +0 -155
- package/dist/chunk-BMFDXFPA.js.map +0 -1
- package/dist/chunk-VUKRIBO5.js.map +0 -1
|
@@ -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
|
+
}
|