@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.
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  NewSessionDraftOptions,
3
3
  ReasoningEffort,
4
+ RepositoryResourceRef,
4
5
  ResourceRef,
5
6
  ToolRef,
6
7
  } from "@opengeni/contracts";
@@ -26,6 +27,34 @@ export class NewSessionDraftAccessError extends Error {
26
27
  }
27
28
  }
28
29
 
30
+ type StoredNewSessionDraftOptions = NewSessionDraftOptions & {
31
+ /** JSONB-only compatibility marker; deliberately not part of public options. */
32
+ toolsProvided?: boolean;
33
+ };
34
+
35
+ function storedOptions(
36
+ options: NewSessionDraftOptions,
37
+ toolsProvided: boolean,
38
+ ): StoredNewSessionDraftOptions {
39
+ return { ...options, toolsProvided };
40
+ }
41
+
42
+ export function newSessionDraftToolsProvided(row: NewSessionDraftRow): boolean {
43
+ const options = row.sessionOptions as StoredNewSessionDraftOptions;
44
+ // Rows written before the explicitness marker was introduced always carried
45
+ // a `tools` array. Treat a markerless row as explicit rather than widening a
46
+ // narrowed (including empty) legacy selection to today's workspace defaults.
47
+ // New rows always write the marker, so an explicit false remains omitted /
48
+ // workspace-default policy.
49
+ return options.toolsProvided === true || !Object.hasOwn(options, "toolsProvided");
50
+ }
51
+
52
+ export function publicNewSessionDraftOptions(row: NewSessionDraftRow): NewSessionDraftOptions {
53
+ const options = { ...(row.sessionOptions as StoredNewSessionDraftOptions) };
54
+ delete options.toolsProvided;
55
+ return options;
56
+ }
57
+
29
58
  export async function getNewSessionDraftInTransaction(
30
59
  db: Database,
31
60
  input: { workspaceId: string; subjectId: string; lock?: boolean },
@@ -54,6 +83,7 @@ export async function saveNewSessionDraftInTransaction(
54
83
  text: string;
55
84
  resources: ResourceRef[];
56
85
  tools: ToolRef[];
86
+ toolsProvided: boolean;
57
87
  model: string;
58
88
  reasoningEffort: ReasoningEffort;
59
89
  options: NewSessionDraftOptions;
@@ -96,7 +126,9 @@ export async function saveNewSessionDraftInTransaction(
96
126
  tools: input.tools,
97
127
  model: input.model,
98
128
  reasoningEffort: input.reasoningEffort,
99
- sessionOptions: input.options,
129
+ // Keep the explicit/omitted policy in the existing JSONB extension point;
130
+ // adding a column here would turn a client preference into a migration.
131
+ sessionOptions: storedOptions(input.options, input.toolsProvided),
100
132
  updatedAt: new Date(),
101
133
  };
102
134
  if (current) {
@@ -124,21 +156,110 @@ export async function saveNewSessionDraftInTransaction(
124
156
  throw new NewSessionDraftConflictError(raced?.revision ?? 0);
125
157
  }
126
158
 
127
- /** Delete only the submitted revision; a newer sibling-tab revision survives. */
128
- export async function consumeNewSessionDraftInTransaction(
159
+ function safeRepositoryResource(resource: RepositoryResourceRef): RepositoryResourceRef {
160
+ // A repository's URI/ref/mount and GitHub identity are ordinary selection
161
+ // state. Credential bindings, connection refs, access intent, and generic
162
+ // provider ids are per-session authorization/runtime state and must not seed
163
+ // the next create.
164
+ return {
165
+ kind: "repository",
166
+ uri: resource.uri,
167
+ ref: resource.ref,
168
+ ...(resource.mountPath ? { mountPath: resource.mountPath } : {}),
169
+ ...(resource.subpath ? { subpath: resource.subpath } : {}),
170
+ ...(resource.githubInstallationId
171
+ ? { githubInstallationId: resource.githubInstallationId }
172
+ : {}),
173
+ ...(resource.githubRepositoryId ? { githubRepositoryId: resource.githubRepositoryId } : {}),
174
+ };
175
+ }
176
+
177
+ function safeWorkingDir(value: unknown, targetSandboxId: string | undefined): string | undefined {
178
+ if (!targetSandboxId || typeof value !== "string") return undefined;
179
+ const trimmed = value.trim();
180
+ // Only a workspace-root-relative path is safe to remember. Absolute host
181
+ // paths and traversal would leak or unexpectedly target a different machine
182
+ // location after the user changes machines.
183
+ if (
184
+ !trimmed ||
185
+ trimmed === "." ||
186
+ trimmed === ".." ||
187
+ trimmed.includes("\u0000") ||
188
+ trimmed.startsWith("/") ||
189
+ trimmed.startsWith("\\") ||
190
+ /^[A-Za-z]:[\\/]/.test(trimmed) ||
191
+ trimmed.split(/[\\/]+/).some((part) => part === "..")
192
+ ) {
193
+ return undefined;
194
+ }
195
+ return trimmed;
196
+ }
197
+
198
+ /**
199
+ * Replace one exact accepted draft with the next-create safe seed. The row is
200
+ * locked before the revision check so a concurrent save either commits first
201
+ * and wins (this returns false), or observes the incremented seed revision and
202
+ * reports a typed OCC conflict. A missing row is an idempotent no-op.
203
+ */
204
+ export async function seedNewSessionDraftInTransaction(
129
205
  db: Database,
130
206
  input: { workspaceId: string; subjectId: string; expectedRevision: number },
131
207
  ): Promise<boolean> {
132
208
  if (input.expectedRevision === 0) return false;
133
- const deleted = await db
134
- .delete(schema.newSessionDrafts)
209
+ const [current] = await db
210
+ .select()
211
+ .from(schema.newSessionDrafts)
135
212
  .where(
136
213
  and(
137
214
  eq(schema.newSessionDrafts.workspaceId, input.workspaceId),
138
215
  eq(schema.newSessionDrafts.subjectId, input.subjectId),
216
+ ),
217
+ )
218
+ .for("update")
219
+ .limit(1);
220
+ if (!current || current.revision !== input.expectedRevision) return false;
221
+
222
+ const options = current.sessionOptions as StoredNewSessionDraftOptions;
223
+ const targetSandboxId =
224
+ typeof options.targetSandboxId === "string" ? options.targetSandboxId : undefined;
225
+ const safeOptions: NewSessionDraftOptions = {
226
+ ...(options.sandboxBackend ? { sandboxBackend: options.sandboxBackend } : {}),
227
+ ...(targetSandboxId ? { targetSandboxId } : {}),
228
+ ...(safeWorkingDir(options.workingDir, targetSandboxId)
229
+ ? { workingDir: safeWorkingDir(options.workingDir, targetSandboxId) }
230
+ : {}),
231
+ ...(options.variableSetId ? { variableSetId: options.variableSetId } : {}),
232
+ ...(options.rigId ? { rigId: options.rigId } : {}),
233
+ };
234
+ const resources = (Array.isArray(current.resources) ? current.resources : []).flatMap((raw) => {
235
+ if (!raw || typeof raw !== "object" || (raw as { kind?: unknown }).kind !== "repository") {
236
+ return [];
237
+ }
238
+ return [safeRepositoryResource(raw as RepositoryResourceRef)];
239
+ });
240
+ const [seeded] = await db
241
+ .update(schema.newSessionDrafts)
242
+ .set({
243
+ revision: current.revision + 1,
244
+ text: "",
245
+ resources,
246
+ // The explicit array is retained only when the caller explicitly pinned
247
+ // tools. Omitted workspace-default policy is represented by [] + false.
248
+ tools: newSessionDraftToolsProvided(current) ? current.tools : [],
249
+ model: current.model,
250
+ reasoningEffort: current.reasoningEffort,
251
+ sessionOptions: storedOptions(safeOptions, newSessionDraftToolsProvided(current)),
252
+ updatedAt: new Date(),
253
+ })
254
+ .where(
255
+ and(
256
+ eq(schema.newSessionDrafts.id, current.id),
139
257
  eq(schema.newSessionDrafts.revision, input.expectedRevision),
140
258
  ),
141
259
  )
142
260
  .returning({ id: schema.newSessionDrafts.id });
143
- return deleted.length > 0;
261
+ return Boolean(seeded);
144
262
  }
263
+
264
+ /** @deprecated Kept as a compatibility name for low-level callers. */
265
+ export const consumeNewSessionDraftInTransaction = seedNewSessionDraftInTransaction;
@@ -1,5 +1,10 @@
1
1
  import postgres from "postgres";
2
2
  import type { RlsStrategy } from "./index";
3
+ import {
4
+ RUNTIME_FULL_DML_TABLES,
5
+ RUNTIME_READ_INSERT_TABLES,
6
+ RUNTIME_READ_ONLY_TABLES,
7
+ } from "./runtime-posture";
3
8
 
4
9
  export type ProvisionResult = {
5
10
  appRole: string | null;
@@ -101,7 +106,12 @@ export async function provisionRoles(
101
106
  "OPENGENI_APP_DATABASE_PASSWORD (or appPassword) is required for rlsStrategy 'force'",
102
107
  );
103
108
  }
104
- await ensureLoginRole(sql, appRole, appPassword);
109
+ // Ownership and role-graph edges cannot be safely guessed away. Refuse to
110
+ // mutate an existing role until an operator has explicitly transferred
111
+ // objects/removed memberships; role attributes and direct grants, however,
112
+ // are deterministic and are converged below on every run.
113
+ await assertAppRoleSafeToNormalize(sql, appRole);
114
+ await ensureRestrictedAppLoginRole(sql, appRole, appPassword);
105
115
  provisionedAppRole = appRole;
106
116
  }
107
117
 
@@ -166,6 +176,108 @@ END $$;
166
176
  `);
167
177
  }
168
178
 
179
+ async function ensureRestrictedAppLoginRole(
180
+ sql: postgres.Sql,
181
+ role: string,
182
+ password: string,
183
+ ): Promise<void> {
184
+ await sql.unsafe(`
185
+ DO $$
186
+ BEGIN
187
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${literal(role)}) THEN
188
+ EXECUTE format(
189
+ 'CREATE ROLE %I WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEROLE NOCREATEDB NOREPLICATION NOINHERIT PASSWORD %L',
190
+ ${literal(role)},
191
+ ${literal(password)}
192
+ );
193
+ ELSE
194
+ EXECUTE format(
195
+ 'ALTER ROLE %I WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEROLE NOCREATEDB NOREPLICATION NOINHERIT PASSWORD %L',
196
+ ${literal(role)},
197
+ ${literal(password)}
198
+ );
199
+ END IF;
200
+ END $$;
201
+ `);
202
+ }
203
+
204
+ /**
205
+ * Fail rather than silently revoking role relationships or transferring owned
206
+ * objects. Those operations have effects outside OpenGeni's runtime grant
207
+ * contract and require an explicit, audited operator decision.
208
+ */
209
+ async function assertAppRoleSafeToNormalize(sql: postgres.Sql, role: string): Promise<void> {
210
+ const exists = await sql<{ exists: boolean }[]>`
211
+ select exists(select 1 from pg_roles where rolname = ${role}) as exists
212
+ `;
213
+ if (!exists[0]?.exists) {
214
+ return;
215
+ }
216
+
217
+ const memberships = await sql<{ relationship: string }[]>`
218
+ select ('inherits:' || parent.rolname)::text as relationship
219
+ from pg_auth_members membership
220
+ join pg_roles member on member.oid = membership.member
221
+ join pg_roles parent on parent.oid = membership.roleid
222
+ where member.rolname = ${role}
223
+ union all
224
+ select ('member:' || member.rolname)::text as relationship
225
+ from pg_auth_members membership
226
+ join pg_roles member on member.oid = membership.member
227
+ join pg_roles parent on parent.oid = membership.roleid
228
+ where parent.rolname = ${role}
229
+ order by relationship
230
+ `;
231
+ if (memberships.length > 0) {
232
+ throw new Error(
233
+ `Refusing to normalize app role ${role}: remove role relationships first (${memberships
234
+ .map((row) => row.relationship)
235
+ .join(", ")})`,
236
+ );
237
+ }
238
+
239
+ const ownedObjects = await sql<{ object_name: string }[]>`
240
+ select ('database:' || d.datname)::text as object_name
241
+ from pg_database d
242
+ join pg_roles owner on owner.oid = d.datdba
243
+ where owner.rolname = ${role}
244
+ union all
245
+ select ('schema:' || n.nspname)::text as object_name
246
+ from pg_namespace n
247
+ join pg_roles owner on owner.oid = n.nspowner
248
+ where owner.rolname = ${role}
249
+ and n.nspname <> 'information_schema'
250
+ and n.nspname !~ '^pg_'
251
+ union all
252
+ select ('relation:' || n.nspname || '.' || c.relname)::text as object_name
253
+ from pg_class c
254
+ join pg_namespace n on n.oid = c.relnamespace
255
+ join pg_roles owner on owner.oid = c.relowner
256
+ where owner.rolname = ${role}
257
+ and n.nspname <> 'information_schema'
258
+ and n.nspname !~ '^pg_'
259
+ union all
260
+ select (
261
+ 'routine:' || n.nspname || '.' || p.proname || '(' ||
262
+ pg_get_function_identity_arguments(p.oid) || ')'
263
+ )::text as object_name
264
+ from pg_proc p
265
+ join pg_namespace n on n.oid = p.pronamespace
266
+ join pg_roles owner on owner.oid = p.proowner
267
+ where owner.rolname = ${role}
268
+ and n.nspname <> 'information_schema'
269
+ and n.nspname !~ '^pg_'
270
+ order by object_name
271
+ `;
272
+ if (ownedObjects.length > 0) {
273
+ throw new Error(
274
+ `Refusing to normalize app role ${role}: transfer owned objects first (${ownedObjects
275
+ .map((row) => row.object_name)
276
+ .join(", ")})`,
277
+ );
278
+ }
279
+ }
280
+
169
281
  async function ensureDatabase(sql: postgres.Sql, database: string, owner: string): Promise<void> {
170
282
  const existing = await sql<{ exists: boolean }[]>`
171
283
  select exists(select 1 from pg_database where datname = ${database}) as exists
@@ -203,16 +315,86 @@ async function grantAppRoleIfSchemaExists(
203
315
  role: string,
204
316
  schema: string,
205
317
  ): Promise<void> {
318
+ const runtimeFullDmlTables = `ARRAY[${RUNTIME_FULL_DML_TABLES.map(literal).join(", ")}]`;
319
+ const runtimeReadOnlyTables = `ARRAY[${RUNTIME_READ_ONLY_TABLES.map(literal).join(", ")}]`;
320
+ const runtimeReadInsertTables = `ARRAY[${RUNTIME_READ_INSERT_TABLES.map(literal).join(", ")}]`;
206
321
  await sql.unsafe(`
207
322
  DO $$
323
+ DECLARE
324
+ owner_role text := current_user;
325
+ runtime_table text;
208
326
  BEGIN
327
+ EXECUTE format('REVOKE CREATE ON DATABASE %I FROM %I', current_database(), ${literal(role)});
209
328
  IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = ${literal(schema)}) THEN
210
329
  EXECUTE format('GRANT USAGE ON SCHEMA %I TO %I', ${literal(schema)}, ${literal(role)});
211
- EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA %I TO %I', ${literal(schema)}, ${literal(role)});
330
+ EXECUTE format('REVOKE CREATE ON SCHEMA %I FROM %I', ${literal(schema)}, ${literal(role)});
331
+ EXECUTE format('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA %I FROM %I', ${literal(schema)}, ${literal(role)});
332
+ FOREACH runtime_table IN ARRAY ${runtimeFullDmlTables} LOOP
333
+ IF to_regclass(format('%I.%I', ${literal(schema)}, runtime_table)) IS NOT NULL THEN
334
+ EXECUTE format(
335
+ 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE %I.%I TO %I',
336
+ ${literal(schema)},
337
+ runtime_table,
338
+ ${literal(role)}
339
+ );
340
+ END IF;
341
+ END LOOP;
342
+ FOREACH runtime_table IN ARRAY ${runtimeReadOnlyTables} LOOP
343
+ IF to_regclass(format('%I.%I', ${literal(schema)}, runtime_table)) IS NOT NULL THEN
344
+ EXECUTE format(
345
+ 'GRANT SELECT ON TABLE %I.%I TO %I',
346
+ ${literal(schema)},
347
+ runtime_table,
348
+ ${literal(role)}
349
+ );
350
+ END IF;
351
+ END LOOP;
352
+ FOREACH runtime_table IN ARRAY ${runtimeReadInsertTables} LOOP
353
+ IF to_regclass(format('%I.%I', ${literal(schema)}, runtime_table)) IS NOT NULL THEN
354
+ EXECUTE format(
355
+ 'GRANT SELECT, INSERT ON TABLE %I.%I TO %I',
356
+ ${literal(schema)},
357
+ runtime_table,
358
+ ${literal(role)}
359
+ );
360
+ END IF;
361
+ END LOOP;
362
+ -- Migration 0110 creates this target-schema-local SECURITY DEFINER
363
+ -- capability before opengeni_app may exist. Re-converge its exact EXECUTE
364
+ -- grant here so the supported migrate-then-provision order is equivalent to
365
+ -- provisioning the role before that migration.
366
+ IF to_regprocedure(
367
+ format('%I.lock_nested_agent_depth_configuration()', ${literal(schema)})
368
+ ) IS NOT NULL THEN
369
+ EXECUTE format(
370
+ 'GRANT EXECUTE ON FUNCTION %I.lock_nested_agent_depth_configuration() TO %I',
371
+ ${literal(schema)},
372
+ ${literal(role)}
373
+ );
374
+ END IF;
375
+ EXECUTE format('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA %I TO %I', ${literal(schema)}, ${literal(role)});
376
+ EXECUTE format(
377
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I REVOKE ALL PRIVILEGES ON TABLES FROM %I',
378
+ owner_role,
379
+ ${literal(schema)},
380
+ ${literal(role)}
381
+ );
382
+ EXECUTE format(
383
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT USAGE, SELECT ON SEQUENCES TO %I',
384
+ owner_role,
385
+ ${literal(schema)},
386
+ ${literal(role)}
387
+ );
212
388
  END IF;
213
389
  IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'opengeni_private') THEN
214
390
  EXECUTE format('GRANT USAGE ON SCHEMA opengeni_private TO %I', ${literal(role)});
391
+ EXECUTE format('REVOKE CREATE ON SCHEMA opengeni_private FROM %I', ${literal(role)});
215
392
  EXECUTE format('GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA opengeni_private TO %I', ${literal(role)});
393
+ EXECUTE format(
394
+ 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA opengeni_private GRANT EXECUTE ON FUNCTIONS TO %I',
395
+ owner_role,
396
+ ${literal(role)}
397
+ );
216
398
  END IF;
217
399
  END $$;
218
400
  `);
@@ -0,0 +1,57 @@
1
+ import { dbSearchPath, getSettings } from "@opengeni/config";
2
+ import {
3
+ assertRuntimeDatabasePosture,
4
+ createDb,
5
+ FORCE_RLS_TABLES,
6
+ PROTECTED_NO_DIRECT_DML_TABLES,
7
+ RUNTIME_FULL_DML_TABLES,
8
+ RUNTIME_READ_INSERT_TABLES,
9
+ RUNTIME_READ_ONLY_TABLES,
10
+ } from "./index";
11
+
12
+ const settings = getSettings();
13
+ const searchPath = dbSearchPath(settings);
14
+ const client = createDb(settings.databaseUrl, {
15
+ ...(searchPath ? { searchPath } : {}),
16
+ rlsStrategy: settings.rlsStrategy,
17
+ max: 1,
18
+ });
19
+
20
+ try {
21
+ const posture = await assertRuntimeDatabasePosture(client.db, {
22
+ rlsStrategy: settings.rlsStrategy,
23
+ expectedRole: settings.runtimeDatabaseRole,
24
+ targetSchema: settings.dbSchema.trim() || "public",
25
+ });
26
+ // Structural evidence only: never print a connection string, secret, GUC, or
27
+ // tenant row. The command is intended for release Jobs and audit artifacts.
28
+ console.log(
29
+ JSON.stringify({
30
+ ok: true,
31
+ rlsStrategy: settings.rlsStrategy,
32
+ currentUser: posture.identity.currentUser,
33
+ sessionUser: posture.identity.sessionUser,
34
+ memberships: posture.memberships.length,
35
+ ownedSchemas: posture.ownedSchemas.length,
36
+ ownedRelations: posture.ownedRelations.length,
37
+ declaredProtectedTables: FORCE_RLS_TABLES.length,
38
+ activeProtectedTables: posture.tables.filter((table) => table.rlsActive).length,
39
+ declaredFullDmlTables: RUNTIME_FULL_DML_TABLES.length,
40
+ privilegedFullDmlTables: posture.tables.filter(
41
+ (table) =>
42
+ table.select &&
43
+ table.insert &&
44
+ table.update &&
45
+ table.delete &&
46
+ !table.truncate &&
47
+ !table.references &&
48
+ !table.trigger,
49
+ ).length,
50
+ declaredReadOnlyTables: RUNTIME_READ_ONLY_TABLES.length,
51
+ declaredReadInsertTables: RUNTIME_READ_INSERT_TABLES.length,
52
+ declaredProtectedNoDirectDmlTables: PROTECTED_NO_DIRECT_DML_TABLES.length,
53
+ }),
54
+ );
55
+ } finally {
56
+ await client.close();
57
+ }