pgkiln 0.35.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.
- package/SECURITY.md +11 -9
- package/db/migrations/087_login_page.sql +175 -0
- package/db/migrations/088_region_templates.sql +18 -0
- package/db/migrations/089_page_groups.sql +330 -0
- package/db/migrations/090_workspace_isolation.sql +512 -0
- package/docs/apex-feature-parity.md +23 -21
- package/docs/guide/03-builder.md +85 -8
- package/docs/guide/04-pages-and-regions.md +42 -3
- package/docs/guide/08-security.md +46 -5
- package/docs/guide/11-from-apex.md +4 -1
- package/docs/guide/12-development.md +13 -3
- package/docs/guide/17-mobile.md +3 -1
- package/docs/guide/18-cli.md +2 -0
- package/docs/guide/20-ai-agents.md +2 -0
- package/examples/hr/hr_40_sample_data.sql +1 -1
- package/examples/hr/hr_54_region_templates.sql +67 -0
- package/examples/hr/hr_55_page_groups.sql +24 -0
- package/package.json +1 -1
- package/public/app.css +60 -3
- package/public/app.js +89 -3
- package/public/builder.css +11 -0
- package/public/builder.js +50 -0
- package/public/code-editor.css +3 -3
- package/public/sw.js +5 -3
- package/src/app.ts +20 -0
- package/src/appfiles.ts +1 -0
- package/src/automations.ts +3 -2
- package/src/builder/advisor.ts +25 -5
- package/src/builder/ai-builder.ts +27 -18
- package/src/builder/blueprints.ts +7 -7
- package/src/builder/code-editor.ts +3 -2
- package/src/builder/components.ts +15 -2
- package/src/builder/dataload.ts +24 -14
- package/src/builder/ddl-tools.ts +117 -0
- package/src/builder/designer.ts +29 -3
- package/src/builder/documents.ts +2 -1
- package/src/builder/forms.ts +6 -4
- package/src/builder/home.ts +6 -2
- package/src/builder/login-page.ts +82 -0
- package/src/builder/newapp.ts +21 -3
- package/src/builder/quicksql.ts +3 -2
- package/src/builder/region-help.ts +4 -2
- package/src/builder/region-settings.ts +19 -3
- package/src/builder/report-settings.ts +4 -2
- package/src/builder/routes.ts +18 -6
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +27 -16
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +18 -10
- package/src/builder/ui.ts +2 -2
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +175 -15
- package/src/cli/main.ts +45 -0
- package/src/cli/mcp.ts +42 -0
- package/src/cli/replace.ts +4 -0
- package/src/db.ts +36 -6
- package/src/ddl.ts +510 -0
- package/src/debug.ts +3 -2
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +3 -2
- package/src/metadata.ts +10 -4
- package/src/migrate.ts +3 -0
- package/src/process-jobs.ts +7 -2
- package/src/restsync.ts +3 -2
- package/src/runtime/devbar.ts +3 -1
- package/src/runtime/display-selector.ts +12 -4
- package/src/runtime/grid.ts +170 -26
- package/src/runtime/lists.ts +11 -1
- package/src/runtime/password-reset.ts +171 -0
- package/src/runtime/pwa.ts +7 -10
- package/src/runtime/regions.ts +33 -0
- package/src/runtime/render.ts +15 -0
- package/src/runtime/report-views.ts +4 -2
- package/src/runtime/report.ts +36 -22
- package/src/runtime/routes.ts +39 -33
- package/src/runtime/signin.ts +80 -0
- package/src/sampledata.ts +3 -3
- package/src/session.ts +5 -2
- package/src/static-version.ts +34 -0
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- package/src/workspace-db.ts +166 -0
package/src/builder/scripts.ts
CHANGED
|
@@ -7,12 +7,14 @@ import { clientIp, logActivity, type Session } from '../session.ts';
|
|
|
7
7
|
import { runScript, splitScript, type ScriptRun, type StatementResult } from '../sqlscript.ts';
|
|
8
8
|
import { clearCompletions } from './code-editor.ts';
|
|
9
9
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
10
|
+
import { currentWorkspace, workshopDb } from './workspaces.ts';
|
|
10
11
|
|
|
11
12
|
// SQL Workshop → SQL Scripts: saved scripts (create, edit, upload, download,
|
|
12
13
|
// delete), run with a result per statement (stop at the first error or go on,
|
|
13
14
|
// optionally in one transaction), and the run history. Scripts run on the
|
|
14
15
|
// owner connection, like SQL Commands, on a connection of their own that is
|
|
15
|
-
// closed afterwards (a script may SET ROLE or change settings).
|
|
16
|
+
// closed afterwards (a script may SET ROLE or change settings). Scripts and runs belong
|
|
17
|
+
// to the current workspace; an isolated workspace's run on its developer login (090).
|
|
16
18
|
|
|
17
19
|
/** Largest script (upload or editor), in MB. */
|
|
18
20
|
export const SCRIPT_MAX_MB = 5;
|
|
@@ -30,7 +32,7 @@ export interface RunRequest {
|
|
|
30
32
|
/** Run a script, record the run (history + activity log) and return the run's id. */
|
|
31
33
|
export async function runAndRecord(s: Session, ip: string, r: RunRequest): Promise<{ id: string; run: ScriptRun }> {
|
|
32
34
|
const statements = splitScript(r.content);
|
|
33
|
-
const c = await
|
|
35
|
+
const c = await (await workshopDb(s)).pool.connect();
|
|
34
36
|
let run: ScriptRun;
|
|
35
37
|
try {
|
|
36
38
|
run = await runScript(c, statements, { stopOnError: r.stopOnError, transaction: r.transaction });
|
|
@@ -40,12 +42,17 @@ export async function runAndRecord(s: Session, ip: string, r: RunRequest): Promi
|
|
|
40
42
|
}
|
|
41
43
|
clearCompletions(); // the script may have changed tables or grants
|
|
42
44
|
const saved = await owner.one<{ id: string }>(
|
|
43
|
-
`insert into meta.sql_script_run (script_id, script_name, run_by, elapsed_ms, stop_on_error, transactional, statements, succeeded, failed, rolled_back, results)
|
|
44
|
-
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) returning id`,
|
|
45
|
+
`insert into meta.sql_script_run (script_id, script_name, run_by, elapsed_ms, stop_on_error, transactional, statements, succeeded, failed, rolled_back, results, workspace_id)
|
|
46
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) returning id`,
|
|
45
47
|
[r.scriptId, r.name, s.username, run.ms, r.stopOnError, r.transaction, run.statements, run.succeeded, run.failed, run.rolledBack,
|
|
46
|
-
JSON.stringify(run.commitError ? [...run.results, { n: 0, line: 0, sql: 'COMMIT', status: 'error', error: run.commitError, ms: 0 }] : run.results)
|
|
48
|
+
JSON.stringify(run.commitError ? [...run.results, { n: 0, line: 0, sql: 'COMMIT', status: 'error', error: run.commitError, ms: 0 }] : run.results),
|
|
49
|
+
currentWorkspace(s)],
|
|
50
|
+
);
|
|
51
|
+
await owner.query(
|
|
52
|
+
`delete from meta.sql_script_run where workspace_id = $2
|
|
53
|
+
and id <= (select id from meta.sql_script_run where workspace_id = $2 order by id desc offset $1 limit 1)`,
|
|
54
|
+
[KEEP_RUNS, currentWorkspace(s)],
|
|
47
55
|
);
|
|
48
|
-
await owner.query(`delete from meta.sql_script_run where id <= (select id from meta.sql_script_run order by id desc offset $1 limit 1)`, [KEEP_RUNS]);
|
|
49
56
|
await logActivity({
|
|
50
57
|
username: s.username,
|
|
51
58
|
event: 'sql_script',
|
|
@@ -121,10 +128,12 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
121
128
|
r.started_at as last_run, r.failed as last_failed
|
|
122
129
|
from meta.sql_script s
|
|
123
130
|
left join lateral (select started_at, failed from meta.sql_script_run x where x.script_id = s.id order by x.id desc limit 1) r on true
|
|
131
|
+
where s.workspace_id = $1
|
|
124
132
|
order by lower(s.name)`,
|
|
133
|
+
[currentWorkspace(s)],
|
|
125
134
|
)
|
|
126
135
|
).rows;
|
|
127
|
-
const runs = (await owner.query('select * from meta.sql_script_run order by id desc limit 20')).rows;
|
|
136
|
+
const runs = (await owner.query('select * from meta.sql_script_run where workspace_id = $1 order by id desc limit 20', [currentWorkspace(s)])).rows;
|
|
128
137
|
const list = scripts.length
|
|
129
138
|
? html`<div class="table-wrap"><table class="report"><thead><tr><th>Script</th><th>Updated</th><th class="num">Size</th><th>Last run</th><th>File</th></tr></thead><tbody>
|
|
130
139
|
${scripts.map((x) => html`<tr><td><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description}</span>` : ''}</td>
|
|
@@ -193,7 +202,7 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
193
202
|
app.get(`${BASE}/sql/scripts/:id`, async (req: Req, reply) => {
|
|
194
203
|
const s = await developer(req, reply);
|
|
195
204
|
if (!s) return;
|
|
196
|
-
const row = /^\d+$/.test(req.params.id) ? await owner.one('select * from meta.sql_script where id = $1', [req.params.id]) : undefined;
|
|
205
|
+
const row = /^\d+$/.test(req.params.id) ? await owner.one('select * from meta.sql_script where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]) : undefined;
|
|
197
206
|
if (!row) return reply.code(404).send('Not found');
|
|
198
207
|
return editor(req, reply, s, row);
|
|
199
208
|
});
|
|
@@ -220,15 +229,15 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
220
229
|
try {
|
|
221
230
|
if (id) {
|
|
222
231
|
const r = await owner.one(
|
|
223
|
-
'update meta.sql_script set name = $2, description = nullif($3, \'\'), content = $4, updated_by = $5, updated_at = now() where id = $1 returning id',
|
|
224
|
-
[id, name, b.description ?? '', content, s.username],
|
|
232
|
+
'update meta.sql_script set name = $2, description = nullif($3, \'\'), content = $4, updated_by = $5, updated_at = now() where id = $1 and workspace_id = $6 returning id',
|
|
233
|
+
[id, name, b.description ?? '', content, s.username, currentWorkspace(s)],
|
|
225
234
|
);
|
|
226
235
|
if (!r) return reply.code(404).send('Not found');
|
|
227
236
|
scriptId = r.id;
|
|
228
237
|
} else {
|
|
229
238
|
const r = await owner.one(
|
|
230
|
-
`insert into meta.sql_script (name, description, content, created_by, updated_by) values ($1, nullif($2, ''), $3, $4, $4) returning id`,
|
|
231
|
-
[name, b.description ?? '', content, s.username],
|
|
239
|
+
`insert into meta.sql_script (name, description, content, created_by, updated_by, workspace_id) values ($1, nullif($2, ''), $3, $4, $4, $5) returning id`,
|
|
240
|
+
[name, b.description ?? '', content, s.username, currentWorkspace(s)],
|
|
232
241
|
);
|
|
233
242
|
scriptId = r!.id;
|
|
234
243
|
}
|
|
@@ -260,6 +269,8 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
260
269
|
if (!s) return;
|
|
261
270
|
if (!/^\d+$/.test(req.params.id)) return reply.code(404).send('Not found');
|
|
262
271
|
const r = await owner.tx(async (c) => {
|
|
272
|
+
const own = (await c.query('select 1 from meta.sql_script where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)])).rowCount;
|
|
273
|
+
if (!own) return undefined;
|
|
263
274
|
await c.query('delete from meta.sql_script_run where script_id = $1', [req.params.id]);
|
|
264
275
|
return (await c.query('delete from meta.sql_script where id = $1 returning name', [req.params.id])).rows[0];
|
|
265
276
|
});
|
|
@@ -270,7 +281,7 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
270
281
|
app.get(`${BASE}/sql/scripts/:id/download`, async (req: Req, reply) => {
|
|
271
282
|
const s = await developer(req, reply);
|
|
272
283
|
if (!s) return;
|
|
273
|
-
const row = /^\d+$/.test(req.params.id) ? await owner.one('select name, content from meta.sql_script where id = $1', [req.params.id]) : undefined;
|
|
284
|
+
const row = /^\d+$/.test(req.params.id) ? await owner.one('select name, content from meta.sql_script where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]) : undefined;
|
|
274
285
|
if (!row) return reply.code(404).send('Not found');
|
|
275
286
|
return reply
|
|
276
287
|
.header('content-type', 'application/sql; charset=utf-8')
|
|
@@ -302,8 +313,8 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
302
313
|
if (content.includes('\0')) return fail('The file is not a text file.');
|
|
303
314
|
const base = file.filename.replace(/^.*[\\/]/, '').replace(/\.sql$/i, '').slice(0, 180) || 'script';
|
|
304
315
|
let name = base;
|
|
305
|
-
for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1', [name]); n++) name = `${base} (${n})`;
|
|
306
|
-
const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by) values ($1, $2, $3, $3) returning id`, [name, content, s.username]);
|
|
316
|
+
for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1 and workspace_id = $2', [name, currentWorkspace(s)]); n++) name = `${base} (${n})`;
|
|
317
|
+
const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by, workspace_id) values ($1, $2, $3, $3, $4) returning id`, [name, content, s.username, currentWorkspace(s)]);
|
|
307
318
|
flash(s, `Script “${name}” uploaded.`);
|
|
308
319
|
return back(reply, s, `${BASE}/sql/scripts/${r!.id}`);
|
|
309
320
|
});
|
|
@@ -312,7 +323,7 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
312
323
|
app.get(`${BASE}/sql/scripts/runs/:run`, async (req: Req, reply) => {
|
|
313
324
|
const s = await developer(req, reply);
|
|
314
325
|
if (!s) return;
|
|
315
|
-
const run = /^\d+$/.test(req.params.run) ? await owner.one('select * from meta.sql_script_run where id = $1', [req.params.run]) : undefined;
|
|
326
|
+
const run = /^\d+$/.test(req.params.run) ? await owner.one('select * from meta.sql_script_run where id = $1 and workspace_id = $2', [req.params.run, currentWorkspace(s)]) : undefined;
|
|
316
327
|
if (!run) return reply.code(404).send('Not found');
|
|
317
328
|
const exists = run.script_id ? await owner.one('select id from meta.sql_script where id = $1', [run.script_id]) : undefined;
|
|
318
329
|
const options = `${run.stop_on_error ? 'stop on error' : 'continue on error'}${run.transactional ? ', one transaction' : ''}`;
|
package/src/builder/shared.ts
CHANGED
|
@@ -33,7 +33,7 @@ function listExtras(appId: number, list: any, entries: any[]): Raw {
|
|
|
33
33
|
|
|
34
34
|
export async function sharedRoutes(app: FastifyInstance) {
|
|
35
35
|
// ---------------------------------------------------------------- shared components
|
|
36
|
-
const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'lov', 'app_item', 'app_process', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
|
|
36
|
+
const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'page_group', 'lov', 'app_item', 'app_process', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
|
|
37
37
|
|
|
38
38
|
const ORDER: Record<string, string> = {
|
|
39
39
|
search_config: 'seq, name', dynamic_translation: 'language, source',
|
package/src/builder/sql.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
2
|
import pg from 'pg';
|
|
3
|
-
import { owner } from '../db.ts';
|
|
4
3
|
import { html, raw, type Raw } from '../html.ts';
|
|
5
4
|
import { icon } from '../icons.ts';
|
|
6
5
|
import { BASE, csrf, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
@@ -10,8 +9,11 @@ import { scriptRoutes } from './scripts.ts';
|
|
|
10
9
|
import { quickSqlRoutes } from './quicksql.ts';
|
|
11
10
|
import { queryBuilderRoutes } from './querybuilder.ts';
|
|
12
11
|
import { unloadRoutes } from './unload.ts';
|
|
12
|
+
import { ddlToolRoutes } from './ddl-tools.ts';
|
|
13
|
+
import { workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
13
14
|
|
|
14
|
-
// SQL Workshop: SQL commands and the object browser (owner connection
|
|
15
|
+
// SQL Workshop: SQL commands and the object browser (owner connection; an isolated
|
|
16
|
+
// workspace's developer login for its developers, see workshopDb).
|
|
15
17
|
|
|
16
18
|
export async function sqlRoutes(app: FastifyInstance) {
|
|
17
19
|
// SQL Scripts, Quick SQL and the query builder (sprint 31)
|
|
@@ -20,6 +22,8 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
20
22
|
await app.register(queryBuilderRoutes);
|
|
21
23
|
// Unload Data (sprint 32)
|
|
22
24
|
await app.register(unloadRoutes);
|
|
25
|
+
// Generate DDL and Schema Comparison (sprint 56)
|
|
26
|
+
await app.register(ddlToolRoutes);
|
|
23
27
|
// ---------------------------------------------------------------- SQL workshop
|
|
24
28
|
const resultTable = (res: pg.QueryResult<any[]>, limit = 500) => {
|
|
25
29
|
const rows = (res.rows ?? []).slice(0, limit);
|
|
@@ -32,12 +36,13 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
32
36
|
if (!s) return;
|
|
33
37
|
const sql = req.body?.sql ?? '';
|
|
34
38
|
let result: Raw | '' = '';
|
|
39
|
+
const db = await workshopDb(s);
|
|
35
40
|
if (req.method === 'POST' && sql.trim()) {
|
|
36
41
|
const started = performance.now();
|
|
37
42
|
try {
|
|
38
43
|
// a connection of its own, closed afterwards: the SQL may SET ROLE or change settings,
|
|
39
44
|
// which must not leak into the builder's pooled owner connections
|
|
40
|
-
const c = await
|
|
45
|
+
const c = await db.pool.connect();
|
|
41
46
|
let out;
|
|
42
47
|
try {
|
|
43
48
|
out = await c.query({ text: sql, rowMode: 'array' });
|
|
@@ -56,7 +61,7 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
56
61
|
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), elapsedMs: Math.round(performance.now() - started), detail: sql.length > 2000 ? `${sql.slice(0, 2000)}…` : sql });
|
|
57
62
|
}
|
|
58
63
|
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('sql')}
|
|
59
|
-
<p class="muted">Runs as
|
|
64
|
+
<p class="muted">Runs as ${workshopRunsAs(db)} (not as an application role). Multiple statements are allowed; results of the last one are shown.</p>
|
|
60
65
|
<form method="post">${csrf(s)}
|
|
61
66
|
<textarea name="sql" class="code sql-editor" rows="12" spellcheck="false" aria-label="SQL" data-code="plpgsql">${sql || 'select current_user, version();'}</textarea>
|
|
62
67
|
<div class="buttons"><button class="btn btn-hot">${icon('play')} Run (Ctrl+Enter)</button></div>
|
|
@@ -70,17 +75,20 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
70
75
|
app.get(`${BASE}/sql/objects`, async (req: Req, reply) => {
|
|
71
76
|
const s = await developer(req, reply);
|
|
72
77
|
if (!s) return;
|
|
78
|
+
const db = await workshopDb(s);
|
|
73
79
|
const objects = (
|
|
74
|
-
await
|
|
80
|
+
await db.query(
|
|
75
81
|
`select n.nspname as schema, c.relname as name, c.oid::regclass::text as qname,
|
|
76
82
|
case c.relkind when 'r' then 'table' when 'p' then 'table' when 'v' then 'view' when 'm' then 'view' end as kind,
|
|
77
83
|
c.relrowsecurity as rls
|
|
78
84
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
79
85
|
where c.relkind in ('r', 'p', 'v', 'm') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema')
|
|
86
|
+
and has_schema_privilege(n.oid, 'USAGE')
|
|
80
87
|
union all
|
|
81
88
|
select n.nspname, p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', p.oid::regprocedure::text, 'function', false
|
|
82
89
|
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
83
90
|
where n.nspname !~ '^pg_' and n.nspname not in ('information_schema') and p.prokind in ('f', 'p')
|
|
91
|
+
and has_schema_privilege(n.oid, 'USAGE')
|
|
84
92
|
order by 1, 4 desc, 2`,
|
|
85
93
|
)
|
|
86
94
|
).rows;
|
|
@@ -89,24 +97,24 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
89
97
|
let detail: Raw = html`<p class="muted">Select a table, view or function.</p>`;
|
|
90
98
|
if (selected) {
|
|
91
99
|
if (selected.kind === 'function') {
|
|
92
|
-
const src = await
|
|
100
|
+
const src = await db.one('select pg_get_functiondef($1::regprocedure) as def', [selected.qname]);
|
|
93
101
|
detail = region(selected.qname, html`<pre class="source">${src.def}</pre>`);
|
|
94
102
|
} else {
|
|
95
103
|
const [cols, policies, grants, data] = await Promise.all([
|
|
96
|
-
|
|
104
|
+
db.query(
|
|
97
105
|
`select a.attname as column, format_type(a.atttypid, a.atttypmod) as type, not a.attnotnull as nullable,
|
|
98
106
|
pg_get_expr(d.adbin, d.adrelid) as "default"
|
|
99
107
|
from pg_attribute a left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
|
|
100
108
|
where a.attrelid = $1::regclass and a.attnum > 0 and not a.attisdropped order by a.attnum`,
|
|
101
109
|
[selected.qname],
|
|
102
110
|
),
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
db.query('select policyname as policy, cmd, roles::text, qual as using, with_check from pg_policies where schemaname = $1 and tablename = $2', [selected.schema, selected.name]),
|
|
112
|
+
db.query(
|
|
105
113
|
`select grantee, string_agg(privilege_type, ', ' order by privilege_type) as privileges
|
|
106
114
|
from information_schema.role_table_grants where table_schema = $1 and table_name = $2 group by grantee order by grantee`,
|
|
107
115
|
[selected.schema, selected.name],
|
|
108
116
|
),
|
|
109
|
-
|
|
117
|
+
db.pool.query({ text: `select * from ${selected.qname} limit 25`, rowMode: 'array' }).catch((e) => e as Error),
|
|
110
118
|
]);
|
|
111
119
|
const asArray = (r: pg.QueryResult) => ({ ...r, rows: r.rows.map((x) => Object.values(x)) }) as unknown as pg.QueryResult<any[]>;
|
|
112
120
|
detail = html`${region(`${selected.kind === 'view' ? 'View' : 'Table'} ${selected.qname}`, resultTable(asArray(cols)),
|
package/src/builder/ui.ts
CHANGED
|
@@ -209,13 +209,13 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
|
|
|
209
209
|
};
|
|
210
210
|
|
|
211
211
|
/** Tabs of the SQL Workshop. */
|
|
212
|
-
export type WorkshopTab = 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai';
|
|
212
|
+
export type WorkshopTab = 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare';
|
|
213
213
|
export const workshopTabs = (active: WorkshopTab) => {
|
|
214
214
|
const tab = (key: WorkshopTab, href: string, ic: string, label: string) =>
|
|
215
215
|
html`<a class="ide-tab" href="${BASE}${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
|
|
216
216
|
return html`<nav class="ide-tabs u-mb1" aria-label="SQL Workshop">
|
|
217
217
|
${tab('sql', '/sql', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('quicksql', '/sql/quick', 'bolt', 'Quick SQL')}
|
|
218
|
-
${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ai', '/sql/ai', 'bolt', 'AI')}</nav>`;
|
|
218
|
+
${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ddl', '/sql/ddl', 'file', 'DDL')}${tab('compare', '/sql/compare', 'layers', 'Compare')}${tab('ai', '/sql/ai', 'bolt', 'AI')}</nav>`;
|
|
219
219
|
};
|
|
220
220
|
|
|
221
221
|
/** Number of app tile colours (.app-color-0 … -7 in app.css). */
|
package/src/builder/unload.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type UnloadOptions,
|
|
22
22
|
} from '../unload.ts';
|
|
23
23
|
import { BASE, csrf, developer, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
24
|
+
import { workshopDb } from './workspaces.ts';
|
|
24
25
|
|
|
25
26
|
// SQL Workshop → Unload Data (APEX: Data Workshop → Unload Data): a table or
|
|
26
27
|
// view (chosen columns, an optional WHERE and ORDER BY) or a query, to CSV,
|
|
@@ -125,7 +126,7 @@ export async function unloadRoutes(app: FastifyInstance) {
|
|
|
125
126
|
<a class="ide-tab" href="${BASE}/sql/unload?source=query"${active === 'query' ? raw(' aria-current="page"') : ''}>${icon('code')}<span>Query</span></a></nav>`;
|
|
126
127
|
|
|
127
128
|
const intro = html`<p class="muted">Download the rows of a table, a view or a query as CSV, JSON, Excel or XML (up to ${DOWNLOAD_MAX_ROWS.toLocaleString('en')} rows).
|
|
128
|
-
Runs as the builder's owner connection in a read-only transaction. To load a file into a table, use <a href="${BASE}/sql/load">Load Data</a>.</p>`;
|
|
129
|
+
Runs as the builder's owner connection (an isolated workspace's developer login for its developers) in a read-only transaction. To load a file into a table, use <a href="${BASE}/sql/load">Load Data</a>.</p>`;
|
|
129
130
|
|
|
130
131
|
const form = async (s: Session, b: Body, error?: string) => {
|
|
131
132
|
const alert = error ? html`<div class="alert alert-error" role="alert">${error}</div>` : '';
|
|
@@ -212,7 +213,7 @@ export async function unloadRoutes(app: FastifyInstance) {
|
|
|
212
213
|
if (typeof opts === 'string') return refuse(opts);
|
|
213
214
|
|
|
214
215
|
const started = performance.now();
|
|
215
|
-
const c = await
|
|
216
|
+
const c = await (await workshopDb(s)).pool.connect();
|
|
216
217
|
let opened;
|
|
217
218
|
try {
|
|
218
219
|
await c.query('begin transaction read only');
|
|
@@ -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.
|
|
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
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
248
|
-
|
|
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
|
-
|
|
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
|
}
|