pgkiln 0.36.0 → 0.37.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.
@@ -1,5 +1,8 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
- import { owner } from '../db.ts';
2
+ import { owner, type Db } from '../db.ts';
3
+ import { devDbForWorkspace, workspacesChanged } from '../workspace-db.ts';
4
+ import { ownerUrl, runtimeUrl } from '../db.ts';
5
+ import { secretKeyConfigured } from '../secrets.ts';
3
6
  import { html, raw } from '../html.ts';
4
7
  import { icon } from '../icons.ts';
5
8
  import { clientIp, logActivity, type Session } from '../session.ts';
@@ -68,6 +71,19 @@ export async function appAllowed(s: Session, appId: number | string) {
68
71
  return true;
69
72
  }
70
73
 
74
+ /**
75
+ * The connection for a developer's own SQL (the SQL Workshop's tools): the owner, or in an
76
+ * isolated workspace (090) its developer login, which reaches the workspace's application
77
+ * roles only. Administrators keep the owner.
78
+ */
79
+ export async function workshopDb(s: Session): Promise<Db> {
80
+ if (!s.workspace || (await isAdmin(s.username))) return owner;
81
+ return (await devDbForWorkspace(s.workspace.id)) ?? owner;
82
+ }
83
+
84
+ /** Who the SQL Workshop runs as, for its notes. */
85
+ export const workshopRunsAs = (db: Db) => (db === owner ? 'the builder\'s owner connection' : 'this isolated workspace\'s developer login (its applications\' roles, nothing else)');
86
+
71
87
  /** Put an application in a workspace (inside the caller's transaction, or on the owner pool). */
72
88
  export async function placeApp(db: { query: (sql: string, params?: unknown[]) => Promise<unknown> }, appId: number, workspaceId: number) {
73
89
  await db.query(
@@ -99,6 +115,18 @@ async function adminOnly(s: Session, reply: FastifyReply) {
99
115
  return false;
100
116
  }
101
117
 
118
+ /** An administrator, or a workspace administrator of this workspace (090). */
119
+ export async function workspaceAdmin(username: string | null, workspaceId: number | string) {
120
+ if (!username) return false;
121
+ if (await isAdmin(username)) return true;
122
+ return !!(await owner.one('select 1 as ok from meta.workspace_member where workspace_id = $1 and username = $2 and is_admin', [workspaceId, username]));
123
+ }
124
+
125
+ async function workspaceAdminOnly(s: Session, reply: FastifyReply, workspaceId: string) {
126
+ if (await workspaceAdmin(s.username, workspaceId)) return true;
127
+ return adminOnly(s, reply);
128
+ }
129
+
102
130
  const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Workspaces', `${BASE}/workspaces`], ...extra];
103
131
 
104
132
  const nameError = (e: unknown) => {
@@ -108,6 +136,49 @@ const nameError = (e: unknown) => {
108
136
  return m;
109
137
  };
110
138
 
139
+ const ROLE = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
140
+
141
+ /** Why this installation can't isolate a workspace (090), or null. */
142
+ export function isolationProblem(): string | null {
143
+ if (!secretKeyConfigured()) return 'set PGKILN_SECRET_KEY (at least 32 characters) in the server\'s environment: the workspace\'s database logins get passwords derived from it.';
144
+ if (runtimeUrl === ownerUrl) return 'set RUNTIME_DATABASE_URL: applications run on the owner connection, so no workspace can be isolated.';
145
+ return null;
146
+ }
147
+
148
+ /** A role problem from meta.workspace_sync_roles (or a duplicate role) in words. */
149
+ const roleError = (e: unknown) => {
150
+ const m = (e as Error).message;
151
+ if (m.includes('workspace_role_role_name_key')) return 'this role belongs to another workspace already.';
152
+ if (m.includes('workspace_role_pkey')) return 'the workspace has this role already.';
153
+ return m;
154
+ };
155
+
156
+ /** The Isolation region of a workspace's page. */
157
+ function isolationRegion(s: Session, w: { id: number; name: string; isolated: boolean; run_role: string | null; dev_role: string | null },
158
+ roles: { role_name: string; exists: boolean; apps: string | null }[]) {
159
+ const problem = w.isolated ? null : isolationProblem();
160
+ const body = w.isolated
161
+ ? html`<p class="u-mt0"><span class="tag">${icon('lock')} isolated</span> Its applications run as <code>${w.run_role}</code> and its developers' SQL (SQL Workshop, code checks, the Advisor) as <code>${w.dev_role}</code>. Both reach only the roles below, and see only this workspace's applications, sessions and accounts in pgkiln's own tables. Developers who also belong to a shared workspace keep that workspace's rights there.</p>
162
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Database role</th><th>Used by</th><th></th></tr></thead><tbody>
163
+ ${roles.length
164
+ ? roles.map((r) => html`<tr><td data-label="Database role"><code>${r.role_name}</code>${r.exists ? '' : html` <span class="tag tag-error">missing</span>`}</td>
165
+ <td data-label="Used by">${r.apps ?? html`<span class="muted">no application</span>`}</td>
166
+ <td><form method="post" action="${BASE}/workspaces/${w.id}/roles">${csrf(s)}<input type="hidden" name="role" value="${r.role_name}">
167
+ <button class="btn btn-sm" name="action" value="remove">Remove</button></form></td></tr>`)
168
+ : html`<tr><td colspan="3" class="empty">No database roles: applications here can't run as a role of their own.</td></tr>`}</tbody></table></div>
169
+ <form method="post" action="${BASE}/workspaces/${w.id}/roles" class="u-mt1">${csrf(s)}
170
+ ${input('role', 'Add a database role', '', { help: 'An existing role (not a superuser, not one that creates roles or bypasses row-level security). Its rights become this workspace\'s; it can\'t belong to another workspace.' })}
171
+ <div class="buttons"><button class="btn" name="action" value="add">${icon('plus')} Add role</button></div></form>
172
+ <form method="post" action="${BASE}/workspaces/${w.id}/isolation" class="danger-zone">${csrf(s)}<input type="hidden" name="isolated" value="false">
173
+ <button class="btn btn-danger" data-confirm="Make ${w.name} shared again? Its applications then run on the shared runtime connection, and its developers' SQL as the owner.">Make shared</button></form>`
174
+ : html`<p class="u-mt0">Shared: the applications run on the shared runtime connection and the developers' SQL as the owner, like every shared workspace's.</p>
175
+ <p class="muted small">Isolating gives the workspace two database logins of its own and the database roles its applications use now. Application code then reaches only those roles and this workspace's applications; its developers' SQL Workshop runs on the workspace's login instead of the owner. New applications get a new role and schema of their own.</p>
176
+ ${problem ? html`<div class="alert alert-error" role="alert">To isolate a workspace, ${problem}</div>` : ''}
177
+ <form method="post" action="${BASE}/workspaces/${w.id}/isolation">${csrf(s)}<input type="hidden" name="isolated" value="true">
178
+ <div class="buttons"><button class="btn btn-hot"${problem ? raw(' disabled') : ''} data-confirm="Isolate ${w.name}?">${icon('lock')} Isolate this workspace</button></div></form>`;
179
+ return region('Isolation', body);
180
+ }
181
+
111
182
  export async function workspaceRoutes(app: FastifyInstance) {
112
183
  // switch the current workspace (any of the developer's own)
113
184
  app.post(`${BASE}/workspace`, async (req: Req, reply) => {
@@ -125,7 +196,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
125
196
  if (!s || !(await adminOnly(s, reply))) return;
126
197
  const rows = (
127
198
  await owner.query(
128
- `select w.id, w.name, w.description,
199
+ `select w.id, w.name, w.description, w.isolated,
129
200
  (select count(*) from meta.app a where meta.app_workspace(a.id) = w.id)::int as apps,
130
201
  (select string_agg(m.username, ', ' order by m.username) from meta.workspace_member m where m.workspace_id = w.id) as members
131
202
  from meta.workspace w order by w.id = 1 desc, lower(w.name)`,
@@ -133,12 +204,12 @@ export async function workspaceRoutes(app: FastifyInstance) {
133
204
  ).rows;
134
205
  const main = html`
135
206
  <div class="title-row"><h1>Workspaces</h1></div>
136
- <p class="muted u-mt0">A workspace groups applications and the developers who build them. Developers see and change the applications of their own workspaces; administrators see all of them. Workspaces are not a security boundary between developers who write SQL: the SQL Workshop and application code run with this installation's rights. Give tenants who must not see each other separate installations.</p>
207
+ <p class="muted u-mt0">A workspace groups applications and the developers who build them. Developers see and change the applications of their own workspaces; administrators see all of them. A shared workspace is not a security boundary between developers who write SQL: its SQL Workshop and application code run with this installation's rights. An <strong>isolated</strong> workspace is: its applications and SQL run on database logins of its own that reach only its database roles and its own applications.</p>
137
208
  <div class="columns wide-left">
138
209
  ${region('Workspaces', html`<div class="table-wrap"><table class="report report-reflow">
139
210
  <thead><tr><th>Workspace</th><th class="num">Applications</th><th>Developers</th></tr></thead>
140
211
  <tbody>${rows.map((w) => html`<tr>
141
- <td data-label="Workspace"><a href="${BASE}/workspaces/${w.id}">${w.name}</a>${w.description ? html` <span class="muted">${w.description}</span>` : ''}</td>
212
+ <td data-label="Workspace"><a href="${BASE}/workspaces/${w.id}">${w.name}</a>${w.isolated ? html` <span class="tag">${icon('lock')} isolated</span>` : ''}${w.description ? html` <span class="muted">${w.description}</span>` : ''}</td>
142
213
  <td class="num" data-label="Applications">${w.apps}</td>
143
214
  <td data-label="Developers">${w.members ?? html`<span class="muted">administrators only</span>`}</td></tr>`)}</tbody></table></div>`)}
144
215
  ${region('Add workspace', html`<form method="post" action="${BASE}/workspaces">${csrf(s)}
@@ -175,18 +246,26 @@ export async function workspaceRoutes(app: FastifyInstance) {
175
246
 
176
247
  app.get(`${BASE}/workspaces/:id(^\\d{1,9}$)`, async (req: Req, reply) => {
177
248
  const s = await developer(req, reply);
178
- if (!s || !(await adminOnly(s, reply))) return;
249
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
250
+ const full = await isAdmin(s.username);
179
251
  const w = await owner.one('select * from meta.workspace where id = $1', [req.params.id]);
180
252
  if (!w) return reply.code(404).send('Not found');
181
- const [devs, apps, others] = await Promise.all([
253
+ const [devs, apps, others, rolesQ] = await Promise.all([
182
254
  owner.query(
183
- `select d.username, d.is_admin, m.username is not null as member
255
+ `select d.username, d.is_admin, m.username is not null as member, coalesce(m.is_admin, false) as ws_admin
184
256
  from meta.developer d left join meta.workspace_member m on m.username = d.username and m.workspace_id = $1 order by d.username`,
185
257
  [w.id],
186
258
  ),
187
259
  owner.query(`select a.id, a.name, a.alias from meta.app a where meta.app_workspace(a.id) = $1 order by lower(a.name), a.id`, [w.id]),
188
260
  owner.query('select id, name from meta.workspace where id <> $1 order by id = 1 desc, lower(name)', [w.id]),
261
+ owner.query(
262
+ `select x.role_name, exists (select 1 from pg_roles r where r.rolname = x.role_name) as exists,
263
+ (select string_agg(a.alias, ', ' order by a.alias) from meta.app a where a.db_role = x.role_name) as apps
264
+ from meta.workspace_role x where x.workspace_id = $1 order by 1`,
265
+ [w.id],
266
+ ),
189
267
  ]);
268
+ const roles = rolesQ;
190
269
  const main = html`
191
270
  <div class="title-row"><h1>${w.name}</h1></div>
192
271
  <div class="columns wide-left">
@@ -194,7 +273,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
194
273
  ${input('name', 'Name', w.name, { required: true })}
195
274
  ${input('description', 'Description', w.description ?? '')}
196
275
  <div class="buttons"><button class="btn btn-hot">Save</button></div></form>
197
- ${w.id === 1
276
+ ${!full ? '' : w.id === 1
198
277
  ? html`<p class="muted small">The Default workspace can't be deleted: imported applications and applications made with the command line start here.</p>`
199
278
  : html`<form method="post" action="${BASE}/workspaces/${w.id}/delete" class="danger-zone">${csrf(s)}
200
279
  <button class="btn btn-danger" data-confirm="Delete workspace ${w.name}?"${apps.rows.length ? raw(' disabled') : ''}>Delete workspace</button>
@@ -204,9 +283,13 @@ export async function workspaceRoutes(app: FastifyInstance) {
204
283
  <fieldset class="field"><legend class="sr-only">Developers</legend><div class="radio-group">
205
284
  ${devs.rows.map((d) => html`<label class="check"><input type="checkbox" name="member" value="${d.username}"${d.member ? raw(' checked') : ''}> ${d.username}${d.is_admin ? html` <span class="muted">(administrator)</span>` : ''}</label>`)}
206
285
  </div></fieldset>
286
+ <fieldset class="field"><legend class="label">Workspace administrators</legend><div class="radio-group">
287
+ ${devs.rows.filter((d) => d.member && !d.is_admin).map((d) => html`<label class="check"><input type="checkbox" name="admin" value="${d.username}"${d.ws_admin ? raw(' checked') : ''}> ${d.username}</label>`)}
288
+ </div><small class="help">They manage this workspace's name and developers. Isolation, database roles, moving applications and deleting the workspace stay with administrators.</small></fieldset>
207
289
  <div class="buttons"><button class="btn">Save developers</button></div></form>`)}
208
290
  </div>
209
- ${region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
291
+ ${full ? isolationRegion(s, w, roles.rows) : region('Isolation', html`<p class="u-mt0">${w.isolated ? 'Isolated: its applications and SQL run on database logins of its own.' : 'Shared.'} Administrators change it.</p>`)}
292
+ ${!full ? '' : region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
210
293
  <thead><tr><th>Application</th><th>Move to</th></tr></thead>
211
294
  <tbody>${apps.rows.length
212
295
  ? apps.rows.map((a) => html`<tr><td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">${a.id} · /a/${a.alias}</span></td>
@@ -217,12 +300,12 @@ export async function workspaceRoutes(app: FastifyInstance) {
217
300
  <button class="btn">Move</button></form>`
218
301
  : html`<span class="muted">no other workspace</span>`}</td></tr>`)
219
302
  : html`<tr><td colspan="2" class="empty">No applications.</td></tr>`}</tbody></table></div>`)}`;
220
- return send(reply, s, shell(s, w.name, crumbs([[w.name]]), main));
303
+ return send(reply, s, shell(s, w.name, full ? crumbs([[w.name]]) : [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [w.name]], main));
221
304
  });
222
305
 
223
306
  app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)`, async (req: Req, reply) => {
224
307
  const s = await developer(req, reply);
225
- if (!s || !(await adminOnly(s, reply))) return;
308
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
226
309
  const name = String(req.body?.name ?? '').trim();
227
310
  try {
228
311
  if (!NAME.test(name)) throw new Error('The name is 1 to 60 characters.');
@@ -236,16 +319,19 @@ export async function workspaceRoutes(app: FastifyInstance) {
236
319
 
237
320
  app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/members`, async (req: Req, reply) => {
238
321
  const s = await developer(req, reply);
239
- if (!s || !(await adminOnly(s, reply))) return;
322
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
240
323
  if (!(await owner.one('select 1 as ok from meta.workspace where id = $1', [req.params.id]))) return reply.code(404).send('Not found');
241
324
  const posted = req.body?.member as unknown;
242
325
  const names = (Array.isArray(posted) ? posted : posted == null ? [] : [posted]).map(String).slice(0, 10000);
326
+ const postedAdmins = req.body?.admin as unknown;
327
+ const admins = (Array.isArray(postedAdmins) ? postedAdmins : postedAdmins == null ? [] : [postedAdmins]).map(String).slice(0, 10000);
243
328
  await owner.tx(async (c) => {
244
329
  await c.query('delete from meta.workspace_member where workspace_id = $1', [req.params.id]);
245
330
  // only existing developers (unknown names are ignored)
246
331
  await c.query(
247
- `insert into meta.workspace_member (workspace_id, username) select $1, d.username from meta.developer d where d.username = any($2::text[])`,
248
- [req.params.id, names],
332
+ `insert into meta.workspace_member (workspace_id, username, is_admin)
333
+ select $1, d.username, d.username = any($3::text[]) from meta.developer d where d.username = any($2::text[])`,
334
+ [req.params.id, names, admins],
249
335
  );
250
336
  });
251
337
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `developers of workspace ${req.params.id}: ${names.join(', ').slice(0, 1000)}` });
@@ -260,7 +346,28 @@ export async function workspaceRoutes(app: FastifyInstance) {
260
346
  const appId = /^\d{1,9}$/.test(req.body?.app ?? '') ? Number(req.body!.app) : null;
261
347
  const to = (await owner.one<{ id: number; name: string }>('select id, name from meta.workspace where id = $1', [/^\d{1,9}$/.test(req.body?.to ?? '') ? req.body!.to : null]));
262
348
  if (appId === null || !to || (await appWorkspace(appId)) !== from) return reply.code(404).send('Not found');
263
- await placeApp(owner, appId, to.id);
349
+ try {
350
+ await owner.tx(async (c) => {
351
+ // (090) the application's database role goes along: out of the old workspace's roles (when no other
352
+ // application there uses it), into the new one's when that one is isolated
353
+ const role = (await c.query<{ db_role: string | null }>('select db_role from meta.app where id = $1', [appId])).rows[0]?.db_role;
354
+ if (role) {
355
+ await c.query(
356
+ `delete from meta.workspace_role x where x.workspace_id = $1 and x.role_name = $2
357
+ and not exists (select 1 from meta.app a where a.db_role = $2 and a.id <> $3 and meta.app_workspace(a.id) = $1)`,
358
+ [from, role, appId],
359
+ );
360
+ if ((await c.query('select 1 from meta.workspace where id = $1 and isolated', [to.id])).rowCount)
361
+ await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict do nothing', [to.id, role]);
362
+ }
363
+ await placeApp(c, appId, to.id);
364
+ });
365
+ } catch (e) {
366
+ flash(s, `Not moved: ${roleError(e)}`, 'error');
367
+ return back(reply, s, `${BASE}/workspaces/${from}`);
368
+ } finally {
369
+ await workspacesChanged();
370
+ }
264
371
  await logActivity({ appId, username: s.username, event: 'workspace', ip: clientIp(req), detail: `moved from workspace ${from} to ${to.id}` });
265
372
  flash(s, `Application moved to ${to.name}.`);
266
373
  return back(reply, s, `${BASE}/workspaces/${from}`);
@@ -274,6 +381,10 @@ export async function workspaceRoutes(app: FastifyInstance) {
274
381
  flash(s, 'The Default workspace cannot be deleted.', 'error');
275
382
  return back(reply, s, `${BASE}/workspaces/${id}`);
276
383
  }
384
+ if (await owner.one('select 1 as ok from meta.workspace where id = $1 and isolated', [id])) {
385
+ flash(s, 'Make the workspace shared (not isolated) before deleting it.', 'error');
386
+ return back(reply, s, `${BASE}/workspaces/${id}`);
387
+ }
277
388
  if (await owner.one('select 1 as ok from meta.workspace_app where workspace_id = $1 limit 1', [id])) {
278
389
  flash(s, 'Move the applications of this workspace to another workspace first.', 'error');
279
390
  return back(reply, s, `${BASE}/workspaces/${id}`);
@@ -283,4 +394,53 @@ export async function workspaceRoutes(app: FastifyInstance) {
283
394
  flash(s, r ? `Workspace ${r.name} deleted.` : 'Not found.', r ? 'ok' : 'error');
284
395
  return back(reply, s, `${BASE}/workspaces`);
285
396
  });
397
+
398
+ // (090) isolate a workspace, or make it shared again
399
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/isolation`, async (req: Req, reply) => {
400
+ const s = await developer(req, reply);
401
+ if (!s || !(await adminOnly(s, reply))) return;
402
+ const id = Number(req.params.id);
403
+ const on = req.body?.isolated === 'true';
404
+ try {
405
+ const problem = on ? isolationProblem() : null;
406
+ if (problem) throw new Error(problem);
407
+ const r = await owner.query('update meta.workspace set isolated = $2 where id = $1', [id, on]);
408
+ if (!r.rowCount) return reply.code(404).send('Not found');
409
+ await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${id} ${on ? 'isolated' : 'shared'}` });
410
+ flash(s, on ? 'The workspace is isolated: its applications and SQL now run on its own database logins.' : 'The workspace is shared again: its applications run on the shared runtime connection.');
411
+ } catch (e) {
412
+ flash(s, `${on ? 'Not isolated' : 'Not changed'}: ${roleError(e)}`, 'error');
413
+ } finally {
414
+ await workspacesChanged();
415
+ }
416
+ return back(reply, s, `${BASE}/workspaces/${id}`);
417
+ });
418
+
419
+ // (090) the database roles of an isolated workspace
420
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/roles`, async (req: Req, reply) => {
421
+ const s = await developer(req, reply);
422
+ if (!s || !(await adminOnly(s, reply))) return;
423
+ const id = Number(req.params.id);
424
+ const role = String(req.body?.role ?? '').trim();
425
+ const remove = req.body?.action === 'remove';
426
+ try {
427
+ if (!ROLE.test(role)) throw new Error('A role name is a letter or _ followed by letters, digits, _ or $ (at most 63).');
428
+ if (!remove && !(await owner.one('select 1 as ok from pg_roles where rolname = $1', [role]))) throw new Error(`There is no database role ${role}.`);
429
+ if (remove) {
430
+ const used = await owner.one<{ aliases: string }>(
431
+ `select string_agg(alias, ', ') as aliases from meta.app a where a.db_role = $2 and meta.app_workspace(a.id) = $1 having count(*) > 0`,
432
+ [id, role],
433
+ );
434
+ if (used) throw new Error(`${used.aliases} run${used.aliases.includes(',') ? '' : 's'} as ${role}: give ${used.aliases.includes(',') ? 'them' : 'it'} another role first.`);
435
+ await owner.query('delete from meta.workspace_role where workspace_id = $1 and role_name = $2', [id, role]);
436
+ } else await owner.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2)', [id, role]);
437
+ await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${id}: ${remove ? 'removed' : 'added'} role ${role}` });
438
+ flash(s, remove ? `Role ${role} removed.` : `Role ${role} added.`);
439
+ } catch (e) {
440
+ flash(s, `Not saved: ${roleError(e)}`, 'error');
441
+ } finally {
442
+ await workspacesChanged();
443
+ }
444
+ return back(reply, s, `${BASE}/workspaces/${id}`);
445
+ });
286
446
  }
package/src/db.ts CHANGED
@@ -13,7 +13,7 @@ const inTx = new AsyncLocalStorage<ReadonlySet<string>>();
13
13
  /** Waiting for a free connection fails after this, instead of hanging the request forever. */
14
14
  const ACQUIRE_TIMEOUT_MS = Number(process.env.DB_ACQUIRE_TIMEOUT_MS ?? 30_000);
15
15
 
16
- function makePool(connectionString: string | undefined, name: string) {
16
+ export function makePool(connectionString: string | undefined, name: string, max = Number(process.env.DB_POOL_SIZE ?? 10)) {
17
17
  const opts = {
18
18
  connectionString,
19
19
  application_name: name,
@@ -21,7 +21,7 @@ function makePool(connectionString: string | undefined, name: string) {
21
21
  // a transaction left open (a bug, a hung client) is ended by the database and frees its connection
22
22
  idle_in_transaction_session_timeout: Number(process.env.DB_IDLE_IN_TRANSACTION_MS ?? 300_000),
23
23
  };
24
- const pool = new pg.Pool({ ...opts, max: Number(process.env.DB_POOL_SIZE ?? 10) });
24
+ const pool = new pg.Pool({ ...opts, max });
25
25
  // A query issued while this async chain already holds a connection of this pool
26
26
  // (in tx()) must not wait for a second one from the same pool: with every
27
27
  // connection held by requests doing that, none would ever be released. Those
@@ -72,11 +72,36 @@ if (!process.env.RUNTIME_DATABASE_URL)
72
72
 
73
73
  /** Owner connection: builder, SQL Workshop, migrations. */
74
74
  export const owner = makePool(ownerUrl, 'pgkiln-builder');
75
- /** Least-privilege connection (pgkiln_runtime) that runs applications. */
76
- export const runtime = makePool(process.env.RUNTIME_DATABASE_URL ?? ownerUrl, 'pgkiln-runtime');
75
+ export type Db = ReturnType<typeof makePool>;
76
+
77
+ export const runtimeUrl = process.env.RUNTIME_DATABASE_URL ?? ownerUrl;
78
+ /** The shared least-privilege connection (pgkiln_runtime): applications outside isolated workspaces. */
79
+ export const sharedRuntime = makePool(runtimeUrl, 'pgkiln-runtime');
80
+
81
+ /**
82
+ * The runtime connection of the current request or job: an isolated workspace's
83
+ * own login (src/workspace-db.ts sets it with runtimeScope.run) or the shared one.
84
+ */
85
+ export const runtimeScope = new AsyncLocalStorage<Db>();
86
+ const currentRuntime = () => runtimeScope.getStore() ?? sharedRuntime;
87
+
88
+ /** Least-privilege connection that runs applications: the current scope's (see runtimeScope). */
89
+ export const runtime: Db = {
90
+ get pool() {
91
+ return currentRuntime().pool;
92
+ },
93
+ query: (sql, params) => currentRuntime().query(sql, params),
94
+ one: (sql, params) => currentRuntime().one(sql, params),
95
+ tx: (fn) => currentRuntime().tx(fn),
96
+ end: () => sharedRuntime.end(),
97
+ };
98
+
99
+ const closers: (() => Promise<void>)[] = [];
100
+ /** Pools made elsewhere (the workspaces' own) close with the others. */
101
+ export const onClosePools = (fn: () => Promise<void>) => closers.push(fn);
77
102
 
78
103
  export async function closePools() {
79
- await Promise.all([owner.end(), runtime.end()]);
104
+ await Promise.all([owner.end(), sharedRuntime.end(), ...closers.map((f) => f())]);
80
105
  }
81
106
 
82
107
  export interface AppContext {
@@ -123,8 +148,13 @@ export async function appTx<T>(ctx: AppContext, fn: (c: Client) => Promise<T>):
123
148
 
124
149
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
125
150
 
151
+ let appRuntime: (appId: number) => Promise<Db> = async () => currentRuntime();
152
+ /** How appTx finds an application's runtime connection (src/workspace-db.ts: an isolated workspace's own). */
153
+ export const setAppRuntime = (fn: (appId: number) => Promise<Db>) => (appRuntime = fn);
154
+
126
155
  async function appTxInner<T>(ctx: AppContext, fn: (c: Client) => Promise<T>): Promise<T> {
127
- return runtime.tx(async (c) => {
156
+ const db = await appRuntime(ctx.appId);
157
+ return db.tx(async (c) => {
128
158
  await c.query(
129
159
  `select set_config('pgkiln.app_user', $1, true),
130
160
  set_config('pgkiln.session_id', $2, true),
package/src/ddl.ts CHANGED
@@ -69,11 +69,12 @@ const lit = (s: string) => `'${s.replace(/'/g, "''")}'`;
69
69
  const end = (s: string) => (s.trimEnd().endsWith(';') ? s.trimEnd() : `${s.trimEnd()};`);
70
70
 
71
71
  /** The schemas a developer can pick: not PostgreSQL's own, not pgkiln's metadata. */
72
- export async function userSchemas(c: { query: (sql: string) => Promise<{ rows: { name: string }[] }> }) {
72
+ export async function userSchemas(c: { query: (sql: string) => Promise<{ rows: { name: string }[] }> }, opts: { usable?: boolean } = {}) {
73
73
  const r = await c.query(
74
74
  `select nspname as name from pg_namespace
75
75
  where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta')
76
76
  and not exists (select 1 from pg_depend d where d.classid = 'pg_namespace'::regclass and d.objid = pg_namespace.oid and d.deptype = 'e')
77
+ ${opts.usable ? `and has_schema_privilege(oid, 'USAGE')` : ''}
77
78
  order by 1`,
78
79
  );
79
80
  return r.rows.map((x) => x.name);
package/src/debug.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // Item values are only written at level 9, and never those of password items.
10
10
  import type { FastifyRequest } from 'fastify';
11
11
  import { runtime } from './db.ts';
12
+ import { withAppDb } from './workspace-db.ts';
12
13
 
13
14
  export const DEBUG_LEVELS: [number, string][] = [
14
15
  [0, 'Off'],
@@ -92,10 +93,10 @@ export class DebugLog {
92
93
  if (this.saved) return;
93
94
  this.saved = true;
94
95
  if (this.dropped) this.entries.push({ ms: round(performance.now() - this.started), level: 1, component: 'debug', text: `${this.dropped} more entries left out (at most ${MAX_ENTRIES} per request)` });
95
- await runtime.query('select meta.debug_save($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)', [
96
+ await withAppDb(this.appId, () => runtime.query('select meta.debug_save($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)', [
96
97
  this.appId, this.pageNo, this.username, this.sessionId, this.method, this.path, status, this.level,
97
98
  this.startedAt.toISOString(), round(performance.now() - this.started), JSON.stringify(this.entries),
98
- ]);
99
+ ]));
99
100
  }
100
101
  }
101
102
 
package/src/migrate.ts CHANGED
@@ -103,6 +103,9 @@ export async function migrate(o: MigrateOptions): Promise<string[]> {
103
103
  else await apply('examples/hr', 'pgkiln_seed');
104
104
  }
105
105
  if (o.example) await apply(`examples/${o.example}`, 'pgkiln_seed');
106
+ // (090) application code in isolated workspaces gets what later migrations granted pgkiln_runtime
107
+ if (applied.length && (await client.query(`select to_regprocedure('meta.sync_app_code_grants()') is not null as ok`)).rows[0].ok)
108
+ await client.query('select meta.sync_app_code_grants()');
106
109
  } finally {
107
110
  if (applied.length || failure) await recordRun(client, o.root, { startedAt, fresh, applied, failure }).catch(() => {});
108
111
  await client.end();
@@ -3,6 +3,7 @@ import pg from 'pg';
3
3
  import type { FastifyRequest } from 'fastify';
4
4
  import { PageCss } from './css.ts';
5
5
  import { owner, runtime } from './db.ts';
6
+ import { withAppDb } from './workspace-db.ts';
6
7
  import { loadApp, loadPage } from './metadata.ts';
7
8
  import type { Session } from './session.ts';
8
9
  import { publicError, type PageContext } from './runtime/context.ts';
@@ -61,8 +62,12 @@ async function finish(id: string, state: 'completed' | 'failed', message: string
61
62
  );
62
63
  }
63
64
 
64
- /** Run one claimed job. */
65
- async function runJob(job: Job) {
65
+ /** Run one claimed job (on its application's runtime connection: an isolated workspace's own). */
66
+ function runJob(job: Job) {
67
+ return withAppDb(job.app_id, () => runJobIn(job));
68
+ }
69
+
70
+ async function runJobIn(job: Job) {
66
71
  const app = await loadApp(job.alias);
67
72
  const page = app ? await loadPage(app.id, job.page_no) : undefined;
68
73
  const chain = page?.processes.find((p) => p.id === job.process_id && p.type === 'chain');
package/src/restsync.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import pg from 'pg';
2
2
  import { nextRun, parseCron } from './automations.ts';
3
3
  import { owner, runtime, type Client } from './db.ts';
4
+ import { withAppDb } from './workspace-db.ts';
4
5
  import { fetchSource, sourceParamValues, toRows, type RestSource } from './websources.ts';
5
6
 
6
7
  // REST data source synchronisation (APEX: REST Source Synchronization):
@@ -148,7 +149,7 @@ export async function runSync(sourceId: number, trigger: 'manual' | 'schedule' |
148
149
  // background: no page items; &NAME. substitutions in parameter defaults are empty
149
150
  const { json } = await fetchSource({ ...s, cache_seconds: 0 }, sourceParamValues(s, undefined, () => ''));
150
151
  const { columns, rows, truncated } = toRows(json, s);
151
- const counts = await runtime.tx(async (c) => {
152
+ const counts = await withAppDb(s.app_id, () => runtime.tx(async (c) => {
152
153
  await c.query(
153
154
  `select set_config('pgkiln.app_user', $1, true), set_config('pgkiln.app_id', $2, true), set_config('pgkiln.session_id', '', true),
154
155
  set_config('statement_timeout', '300s', true)`,
@@ -157,7 +158,7 @@ export async function runSync(sourceId: number, trigger: 'manual' | 'schedule' |
157
158
  if (s.db_role) await c.query(`set local role ${ident(s.db_role)}`);
158
159
  // a cut-off response (maximum rows) must not delete the rows beyond the cut
159
160
  return writeRows(c, s, columns.map((x) => x.name), rows, !truncated);
160
- });
161
+ }));
161
162
  const note = truncated ? ` Only the first ${s.max_rows} rows were read (maximum rows)${s.sync_mode === 'merge' && s.sync_delete ? '; nothing was deleted' : ''}.` : '';
162
163
  result = { status: 'ok', log, rows: rows.length, ...counts, ...(note ? { message: note.trim() } : {}) };
163
164
  } catch (e) {
@@ -9,6 +9,7 @@ import { headerValue, HeaderAuthError, peerAddress, resolveHeaderAccount, truste
9
9
  import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
10
10
  import { applyBinds } from '../binds.ts';
11
11
  import { appTx, runtime, savepoint } from '../db.ts';
12
+ import { withAppDb } from '../workspace-db.ts';
12
13
  import { html } from '../html.ts';
13
14
  import { documentShell } from '../layout.ts';
14
15
  import { accountRoles, loadApp, loadPage, type App, type Page, type Region } from '../metadata.ts';
@@ -1274,7 +1275,9 @@ export async function runtimeRoutes(app: FastifyInstance) {
1274
1275
  });
1275
1276
 
1276
1277
  /** The end of a single sign-on (OpenID Connect or SAML): the app's access rules, then the session. */
1277
- const finishSso = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
1278
+ const finishSso = (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) =>
1279
+ withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
1280
+ const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
1278
1281
  const alias = (await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [result.appId]))?.alias;
1279
1282
  const loaded = alias ? await appWithLocale(req, alias) : undefined;
1280
1283
  if (!loaded || loaded.app.authentication !== 'app_users' || !loaded.app.sso_providers.includes(p.name)) return simplePage(reply, 403, english('login.sso_failed'), english('login.method_unavailable'));
package/src/sampledata.ts CHANGED
@@ -169,10 +169,10 @@ type Db = pg.ClientBase | pg.Pool;
169
169
  /** Schemas with at least one ordinary table, the allowed ones only. */
170
170
  export async function schemas(db: Db) {
171
171
  return (
172
- await db.query<{ name: string; tables: number }>(
173
- `select n.nspname as name, count(*)::int as tables from pg_namespace n join pg_class c on c.relnamespace = n.oid
172
+ await db.query<{ name: string; tables: number; usable: boolean }>(
173
+ `select n.nspname as name, count(*)::int as tables, has_schema_privilege(n.oid, 'USAGE') as usable from pg_namespace n join pg_class c on c.relnamespace = n.oid
174
174
  where c.relkind in ('r', 'p') and not c.relispartition and n.nspname !~ '^pg_' and n.nspname not in ('meta', 'information_schema')
175
- group by n.nspname order by n.nspname`,
175
+ group by n.oid, n.nspname order by n.nspname`,
176
176
  )
177
177
  ).rows;
178
178
  }
package/src/session.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { FastifyReply, FastifyRequest } from 'fastify';
2
- import { runtime } from './db.ts';
2
+ import { runtime, sharedRuntime } from './db.ts';
3
+ import { runtimeForApp } from './workspace-db.ts';
3
4
  import { hashToken, loginMaxFailuresPerIp, loginMaxFailuresPerUser, loginWindowMinutes, newToken } from './security.ts';
4
5
  import { instanceSetting } from './instance.ts';
5
6
 
@@ -96,7 +97,9 @@ export async function logActivity(entry: {
96
97
  detail?: string;
97
98
  }) {
98
99
  try {
99
- const r = await runtime.one<{ id: string }>(
100
+ // (090) on the connection of the entry's application (an isolated workspace's own); without one, the shared one
101
+ const db = entry.appId != null ? await runtimeForApp(entry.appId) : sharedRuntime;
102
+ const r = await db.one<{ id: string }>(
100
103
  `insert into meta.activity_log (app_id, page_no, username, event, ip, elapsed_ms, detail)
101
104
  values ($1, $2, $3, $4, $5, $6, $7) returning id`,
102
105
  [entry.appId ?? null, entry.pageNo ?? null, entry.username ?? null, entry.event, entry.ip ?? null, entry.elapsedMs ?? null, entry.detail?.slice(0, 2000) ?? null],
package/src/websources.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import pg from 'pg';
3
3
  import { owner, runtime } from './db.ts';
4
+ import { runtimeForApp } from './workspace-db.ts';
4
5
  import { decryptSecret, encryptSecret } from './secrets.ts';
5
6
  import { WebError, webRequest, type WebResponse } from './webclient.ts';
6
7
 
@@ -372,7 +373,8 @@ export const withRest = (rowsQuery: string, sql: string | null | undefined) =>
372
373
  // ---------------------------------------------------------------- loading
373
374
 
374
375
  export async function loadSource(appId: number, name: string) {
375
- const s = await runtime.one<RestSource>(
376
+ // the application's own connection (an isolated workspace's), also from a workflow or job (090)
377
+ const s = await (await runtimeForApp(appId)).one<RestSource>(
376
378
  `select id, app_id, name, url, method, credential, headers, params, body, row_selector, columns, cache_seconds, timeout_s, max_rows, key_columns, operations
377
379
  from meta.rest_source where app_id = $1 and name = $2`,
378
380
  [appId, name.toUpperCase()],