@mason-data/cli 0.7.0 → 0.9.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/README.md +101 -73
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +368 -25
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +1617 -245
- package/dist/src/api/postgres/translate.js +127 -10
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +47 -25
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +170 -11
- package/dist/src/cli/commands.js +804 -209
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +20 -11
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +491 -130
- package/dist/src/cli/doctor.js +131 -38
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +175 -36
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +162 -65
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +13 -12
- package/dist/src/cli/main.js +11 -5
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +31 -22
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +14 -4
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +19 -14
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +138 -46
- package/dist/src/cli/project.js +142 -4
- package/dist/src/cli/readiness.js +76 -6
- package/dist/src/cli/registry.js +65 -38
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +585 -0
- package/dist/src/cli/run-server.js +637 -48
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +67 -18
- package/dist/src/cli/serve.js +204 -46
- package/dist/src/cli/share-run.js +329 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +75 -17
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +306 -47
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +27 -24
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +124 -17
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +573 -65
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +29 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +165 -0
- package/dist/src/duck/executor-worker.js +20 -11
- package/dist/src/duck/executor.js +47 -33
- package/dist/src/duck/explain.js +60 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +461 -63
- package/dist/src/duck/gaps.js +48 -21
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +127 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +15 -13
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +369 -47
- package/dist/src/duck/publication.js +20 -6
- package/dist/src/duck/requests.js +269 -93
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4584 -580
- package/dist/src/duck/row-filter.js +655 -0
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +439 -177
- package/dist/src/duck/stream.js +19 -10
- package/dist/src/duck/sync.js +1411 -368
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +69 -12
- package/dist/src/duck/usage.js +326 -0
- package/dist/src/duck/users.js +69 -15
- package/dist/src/duck/verified.js +161 -1
- package/dist/src/duck/views.js +5 -5
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +77 -0
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +390 -49
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +282 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +331 -409
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +611 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +439 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +219 -0
- package/dist/src/web/shared.js +584 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +12 -4
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
-
import { addedTables, chooseColumns, entryName, entryProblems, findEntry, parseSourceText, sourceFailure, sourceFileNames, sourcesDir, unaddableReason, withoutTable, withTable } from "./sources.js";
|
|
16
|
+
import { addedTables, chooseColumns, effectiveSync, entryName, entryProblems, findEntry, parseSourceText, sourceFailure, sourceFileNames, sourcesDir, unaddableReason, withEntrySync, withoutTable, withTable } from "./sources.js";
|
|
17
|
+
import { BIG_TABLE_ROWS, COSTLY_POLL_ROWS, COSTLY_POLL_SECONDS, syncText } from "../lib/table-sync.js";
|
|
18
|
+
import { projectWriteRefusal } from "./project.js";
|
|
17
19
|
/** A refusal the command shows as it is; `failure` is how the web page says it (the CLI's Failure: why, fix, next), by
|
|
18
20
|
* default the message alone under the status's code. */
|
|
19
21
|
export class Refusal extends Error {
|
|
@@ -24,11 +26,41 @@ export class Refusal extends Error {
|
|
|
24
26
|
/** The FAILURE_CODES code (src/cli/command.ts) of a refusal by its HTTP status. */
|
|
25
27
|
const codeOf = (status) => status === 400 ? "usage" : status === 404 ? "not_found" : status >= 500 ? "failed" : "refused";
|
|
26
28
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* it: src/cli/sources.ts readSource).
|
|
29
|
+
* A SCAN'S RATE WHEN NOTHING OF THE TABLE IS MEASURED, rows a second on one backend: the test server's production
|
|
30
|
+
* replica scanned a 22 M-row table in 12 s at every poll (docs/DESIGN.md "The poll") — an assumption, said as one.
|
|
30
31
|
*/
|
|
31
|
-
|
|
32
|
+
const ASSUMED_SCAN_ROWS_PER_SECOND = 1_800_000;
|
|
33
|
+
/**
|
|
34
|
+
* WHAT A POLL OF A BIG TABLE WOULD COST THE DATABASE, estimated before it runs (issue 33: "a poll never takes more than
|
|
35
|
+
* a fifth of the source's time" bounds the share, not the backend's seconds — a 200 M-row table set to poll 5m is
|
|
36
|
+
* still a standing load): seconds a poll, from the best basis there is — its last poll, measured; else its copy's read
|
|
37
|
+
* rate, a reader's (an upper bound: a poll sends only the changed rows); else the catalog's row estimate at an assumed
|
|
38
|
+
* scan rate — and the basis said. Null for a table under `COSTLY_POLL_ROWS`, or set to poll every minute or slower.
|
|
39
|
+
*/
|
|
40
|
+
export function pollCost(t, sync, m) {
|
|
41
|
+
if (sync.mode !== "poll" || sync.seconds >= COSTLY_POLL_SECONDS || t.rows === null || t.rows < COSTLY_POLL_ROWS)
|
|
42
|
+
return null;
|
|
43
|
+
const rows = t.rows.toLocaleString("en-US");
|
|
44
|
+
const [seconds, basis] = m?.lastPollMs != null ? [m.lastPollMs / 1000, "its last poll, measured"]
|
|
45
|
+
: m?.copyRowsPerSecond ? [t.rows / m.copyRowsPerSecond, `its first sync's read rate, ${Math.round(m.copyRowsPerSecond).toLocaleString("en-US")} rows a second a reader (a poll sends only the rows that changed, so it is at most that)`]
|
|
46
|
+
: [t.rows / ASSUMED_SCAN_ROWS_PER_SECOND, `the catalog's ${rows} rows at an assumed ${ASSUMED_SCAN_ROWS_PER_SECOND.toLocaleString("en-US")} rows a second of scan (nothing of it measured yet)`];
|
|
47
|
+
const every = Math.max(sync.seconds, 4 * seconds);
|
|
48
|
+
const s = (x) => x < 90 ? `${Math.round(x)} s` : x < 5400 ? `${Math.round(x / 60)} min` : `${(x / 3600).toFixed(1)} h`;
|
|
49
|
+
return `${entryName(t.source)} has about ${rows} rows, and each poll scans it whole on your database: about ${s(seconds)} a poll, from ${basis}. ` +
|
|
50
|
+
`sync: ${syncText(sync)} is stretched to a poll every ${s(every)} at most (a poll waits four times its own time), one backend of your database ` +
|
|
51
|
+
`busy ${Math.round((seconds / every) * 100)} % of the time — a longer interval (--sync "poll 1h"), or --sync replication (the change log), costs it less`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The project's sources file as it is on disk NOW, its text and what it says — or why it cannot be edited: its files are
|
|
55
|
+
* read-only here (src/cli/project.ts `projectWriteRefusal`, the one authority, asked FIRST: nothing is read or checked
|
|
56
|
+
* against the database for an edit that cannot be written), no folder, not exactly one file, a file with mistakes of its
|
|
57
|
+
* own, a URL that cannot be resolved (`read`, the file read as the run reads it: src/cli/sources.ts readSource). An
|
|
58
|
+
* `EditTarget` is made here alone, so every edit `addEntry` and `removeEntries` write has asked.
|
|
59
|
+
*/
|
|
60
|
+
export function sourceToEdit(folder, root, read, env = process.env) {
|
|
61
|
+
const readOnly = projectWriteRefusal(root, env);
|
|
62
|
+
if (readOnly !== null)
|
|
63
|
+
throw new Refusal(409, readOnly.message, readOnly);
|
|
32
64
|
if (!folder)
|
|
33
65
|
throw new Refusal(409, "choose the project's folder first");
|
|
34
66
|
const names = sourceFileNames(folder);
|
|
@@ -49,7 +81,7 @@ export function sourceToEdit(folder, root, read) {
|
|
|
49
81
|
/** The file's other entries against the database: an edit is never written over a file the run would not take. */
|
|
50
82
|
function othersHold(at, db, without, then) {
|
|
51
83
|
const others = { ...at.parsed, tables: at.parsed.tables.filter((e) => !without.includes(e.table)) };
|
|
52
|
-
const held = addedTables(others, db.tables, db.database, at.root
|
|
84
|
+
const held = addedTables(others, db.tables, db.database, at.root);
|
|
53
85
|
if (!Array.isArray(held))
|
|
54
86
|
throw new Refusal(409, `${held.message}${held.why ? `\n${held.why}` : ""} — fix it first, then ${then}`);
|
|
55
87
|
}
|
|
@@ -58,35 +90,61 @@ function othersHold(at, db, without, then) {
|
|
|
58
90
|
* table's own (`withTable`): every other byte of the file as it was. `columns` are the ones asked (`["*"]`: every one);
|
|
59
91
|
* `key` the columns a table with no primary key is copied by. The entry and the file's other entries must hold against
|
|
60
92
|
* the database first: an edit is never written over a file the next read would refuse.
|
|
93
|
+
*
|
|
94
|
+
* ITS SYNC (`o.sync`, --sync): written on the entry as its own `sync:`; else an entry's own is kept; else a NEW entry
|
|
95
|
+
* follows the file's default — unless the table is over `BIG_TABLE_ROWS` and that default polls, when the entry is
|
|
96
|
+
* written `sync: off` and said (`bigOff`): a poll scans its table whole — or the default is `replication` and the table
|
|
97
|
+
* cannot be (a `--key` table, REPLICA IDENTITY NOTHING), when it is written `sync: poll 5m` and said why (`notReplicated`;
|
|
98
|
+
* an explicit `--sync replication` of such a table is refused, at its entry). `o.keep`: only the sync is asked of a table
|
|
99
|
+
* already added (`mason add orders --sync "poll 30s"`), so its columns and key stay as they are, and only its `sync:`
|
|
100
|
+
* line is written (`withEntrySync`: its comments too).
|
|
61
101
|
*/
|
|
62
|
-
export function addEntry(at, db, table, columns = ["*"], key = null) {
|
|
102
|
+
export function addEntry(at, db, table, columns = ["*"], key = null, o = {}) {
|
|
63
103
|
const t = db.tables.find((x) => x.source === table);
|
|
64
104
|
if (!t)
|
|
65
105
|
throw new Refusal(404, `${table} is not a table of ${db.database} that this user can read`);
|
|
66
106
|
if (key !== null && t.key.length)
|
|
67
|
-
throw new Refusal(400, `${table} has a primary key (${t.key.join(", ")}), which Mason
|
|
107
|
+
throw new Refusal(400, `${table} has a primary key (${t.key.join(", ")}), which Mason keys it by: leave --key out`);
|
|
68
108
|
if (key === null && !t.key.length)
|
|
69
109
|
throw new Refusal(409, `${table} cannot be added: ${unaddableReason(t)}`);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const chosen = chooseColumns(t, columns, key);
|
|
110
|
+
const was = at.parsed.tables.find((e) => e.table === table);
|
|
111
|
+
const keep = o.keep === true && was !== undefined;
|
|
112
|
+
const chosen = keep ? { columns: was.columns, exclude: was.exclude, addedKey: [], skipped: [] } : chooseColumns(t, columns, key);
|
|
73
113
|
if ("problem" in chosen)
|
|
74
114
|
throw new Refusal(400, chosen.problem);
|
|
75
115
|
othersHold(at, db, [table], `add ${entryName(table)}`);
|
|
76
|
-
|
|
77
|
-
const
|
|
116
|
+
// its own sync: asked, else the one its entry has, else none (the file's) — but a new big table under a polling default
|
|
117
|
+
const fileSync = effectiveSync(at.parsed, undefined).sync;
|
|
118
|
+
const bigOff = o.sync === undefined && was === undefined && fileSync.mode === "poll" && t.rows !== null && t.rows > BIG_TABLE_ROWS;
|
|
119
|
+
// a table logical replication cannot keep, under a file whose default is replication: polled, said (never refused for
|
|
120
|
+
// the default it did not choose; its own --sync replication is refused at its entry, below)
|
|
121
|
+
const keyed = keep ? was.key !== null : key !== null;
|
|
122
|
+
const unreplicable = keyed ? "it has no primary key, so logical replication cannot apply its changes by its key:"
|
|
123
|
+
: t.replicaIdentity === "n" ? "it has REPLICA IDENTITY NOTHING, and a publication carrying it would make your app's own UPDATEs and DELETEs on it fail" : null;
|
|
124
|
+
const notReplicated = o.sync === undefined && (was?.sync ?? null) === null && fileSync.mode === "replication" ? unreplicable : null;
|
|
125
|
+
// --sync replication asked of one: refused with the COMMAND to run (the person is at it), its own flags kept — the
|
|
126
|
+
// file's words are for an entry written by hand (src/cli/sources.ts entryProblems)
|
|
127
|
+
if (o.sync?.mode === "replication" && unreplicable !== null) {
|
|
128
|
+
const others = columns.filter((c) => c !== "*");
|
|
129
|
+
const flags = keep ? "" : `${columns.includes("*") ? (others.length ? ` --include ${others.join(",")}` : "") : ` --columns ${others.join(",")}`}${key !== null ? ` --key ${key.join(",")}` : ""}`;
|
|
130
|
+
throw new Refusal(400, `${table} cannot be kept current by logical replication: ${unreplicable} — mason add ${entryName(table)}${flags} --sync "poll 5m" polls it instead`);
|
|
131
|
+
}
|
|
132
|
+
const sync = o.sync ?? was?.sync ?? (bigOff ? { mode: "off" } : notReplicated !== null ? { mode: "poll", every: "5m", seconds: 300 } : null);
|
|
133
|
+
const entry = { name: was?.name ?? entryName(table), table, columns: chosen.columns, exclude: chosen.exclude, key: keep ? was.key : key === null ? null : [...key], sync };
|
|
78
134
|
// the entry itself, as the run will read it: a --key that does not tell the rows apart is refused here, not at the copy
|
|
79
|
-
const own = entryProblems(t, entry, db.tables, db.database,
|
|
135
|
+
const own = entryProblems(t, entry, db.tables, db.database, effectiveSync(at.parsed, entry).sync);
|
|
80
136
|
if (own.length)
|
|
81
137
|
throw new Refusal(400, own.join("; "));
|
|
82
|
-
const same = was !== undefined && JSON.stringify([was.columns, was.exclude, was.key]) === JSON.stringify([entry.columns, entry.exclude, entry.key]);
|
|
138
|
+
const same = was !== undefined && JSON.stringify([was.columns, was.exclude, was.key, was.sync]) === JSON.stringify([entry.columns, entry.exclude, entry.key, entry.sync]);
|
|
83
139
|
if (!same) {
|
|
84
|
-
const r = withTable(at.text, entry);
|
|
140
|
+
const r = keep ? withEntrySync(at.text, was, sync) : withTable(at.text, entry);
|
|
85
141
|
if ("problem" in r)
|
|
86
142
|
throw new Refusal(409, `${path.relative(at.root, at.file)} cannot be edited: ${r.problem}`);
|
|
87
143
|
fs.writeFileSync(at.file, r.text);
|
|
88
144
|
}
|
|
89
|
-
|
|
145
|
+
const eff = effectiveSync(at.parsed, entry);
|
|
146
|
+
return { table, file: at.file, entry: entry.name, columns: entry.columns, exclude: entry.exclude, key: entry.key, addedKey: chosen.addedKey, skippedSecrets: chosen.skipped, already: same,
|
|
147
|
+
sync: { value: syncText(eff.sync), from: eff.from }, bigOff, notReplicated, pollCost: pollCost(t, eff.sync, db.measured?.(table) ?? null) };
|
|
90
148
|
}
|
|
91
149
|
/**
|
|
92
150
|
* REMOVE TABLES from the sources file: each one's entry cut (`withoutTable`), every other byte of the file as it was. A
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHETHER THE ROLE MASON CONNECTS AS COULD WRITE TO THE SOURCE (issue 524), for `mason doctor`. Mason only reads the
|
|
3
|
+
* database it syncs (CLAUDE.md "The source database is production"), so the role it connects as need not be able to
|
|
4
|
+
* write either: one that can lets a Mason bug, or a stolen URL, change production. Said as WARNINGS, never a refusal:
|
|
5
|
+
* a start goes on, and the way out is the statements that make a role that only reads.
|
|
6
|
+
*
|
|
7
|
+
* ONE catalog read (`roleFactsSql`), no write ever attempted, and every privilege decided by Postgres itself:
|
|
8
|
+
* `has_table_privilege`, `has_any_column_privilege` (a column's INSERT, UPDATE or REFERENCES is a write too),
|
|
9
|
+
* `has_schema_privilege` and `has_function_privilege`, asked for the role, for every role whose privileges it holds or
|
|
10
|
+
* may SET ROLE to (`pg_has_role … 'USAGE'` or `'SET'` from Postgres 16; `'MEMBER'` before, when any membership could
|
|
11
|
+
* SET ROLE), and for PUBLIC, which every role holds; never an ACL read by hand.
|
|
12
|
+
*
|
|
13
|
+
* THE CLASS (PR 572's review, two rounds): every way Postgres lets a role change a synced table's rows WITHOUT a
|
|
14
|
+
* privilege on that table, each read from its catalog. The "only reads" line is true by construction because each is
|
|
15
|
+
* asked, and over-approximated where the catalog cannot say more (a warning too many, never one too few):
|
|
16
|
+
* - DECLARATIVE ROUTES, followed transitively (`reach`): a descendant by `pg_inherits` (a partition, or an INHERITS
|
|
17
|
+
* child, whose rows ARE the synced table's: every privilege); an INHERITS ancestor (an UPDATE, DELETE or TRUNCATE
|
|
18
|
+
* on it reaches its children's rows, Postgres asking only the parent's privilege); a relation whose rules depend on
|
|
19
|
+
* a route (`pg_rewrite` through `pg_depend`: a view at any depth, or another table's rule), for the INSERT, UPDATE
|
|
20
|
+
* and DELETE `pg_relation_is_updatable` says it takes; and a table a route's foreign key references with an ON
|
|
21
|
+
* DELETE or ON UPDATE action that writes (`pg_constraint` `confdeltype` / `confupdtype` CASCADE, SET NULL or SET
|
|
22
|
+
* DEFAULT), whose action runs as the referencing table's owner. TRUNCATE … CASCADE is no route: Postgres asks
|
|
23
|
+
* TRUNCATE on every table it cascades to (measured on 16).
|
|
24
|
+
* - CODE THAT RUNS AS ANOTHER ROLE: a SECURITY DEFINER function whose owner may write a route (a superuser, or one
|
|
25
|
+
* holding a route's privilege), not the role's own, that it reaches as Postgres decides (measured on 16, PR 572's
|
|
26
|
+
* third review): by EXECUTE, wherever it is called from (a view, a policy, a default, a CHECK, an index, a cast, an
|
|
27
|
+
* operator); by a trigger on a relation it may write; and with no EXECUTE asked, by an event trigger (any DDL, even
|
|
28
|
+
* one refused), an aggregate it may call (its support functions), a type (its I/O), an operator's estimators, an
|
|
29
|
+
* operator class's, a range's or a conversion's function. Volatility bounds nothing (a STABLE definer calling a
|
|
30
|
+
* VOLATILE writer writes). What such a function writes is in its body, which the catalog does not read: it is its own
|
|
31
|
+
* finding (`role_may_run_definer_code`), LISTED for a person to review, and the role is never said to "only read"
|
|
32
|
+
* while one is reached. Code of invoker's rights runs with the role's own privileges, which the rest asks.
|
|
33
|
+
* - ANOTHER IDENTITY BY CONNECTION: a foreign table it may write, or a user mapping on a server it may use (postgres_fdw,
|
|
34
|
+
* dblink): what it sends runs as the mapping's user, maybe on this very database.
|
|
35
|
+
* - BECOMING ANOTHER ROLE: ADMIN OPTION on a role (it may grant itself that role), CREATEROLE before Postgres 16 (any
|
|
36
|
+
* role but a superuser), and pg_execute_server_program, pg_write_server_files or pg_read_server_files (the server's
|
|
37
|
+
* programs and files as its operating-system user).
|
|
38
|
+
* Not routes: COPY … FROM needs INSERT, which is asked; a large object is not a table's rows; a publication only reads;
|
|
39
|
+
* a subscription writes as its owner, with that owner's privileges, and a table it fills is written from another
|
|
40
|
+
* database, which this database cannot see.
|
|
41
|
+
*
|
|
42
|
+
* The statement runs under `search_path = pg_catalog` (`SET LOCAL`, ending with it, as VIEWS_SQL does), so no function,
|
|
43
|
+
* operator or table of the role's own schemas can stand in for the catalog's.
|
|
44
|
+
*/
|
|
45
|
+
import { pgLiteral } from "../cdc/pg-wire.js";
|
|
46
|
+
import { shownUrl } from "./preflight.js";
|
|
47
|
+
/** The privileges on a table that write or change it (SELECT is the one Mason needs), in their bit order (`mask`). */
|
|
48
|
+
export const WRITE_PRIVILEGES = ["INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER"];
|
|
49
|
+
/** Each warning's code, stable for an agent: one per thing the role should not have (`role_unknown`: could not tell). */
|
|
50
|
+
export const ROLE_CODES = ["role_superuser", "role_bypassrls", "role_owns_table", "role_writes_table", "role_creates_in_schema",
|
|
51
|
+
"role_may_run_definer_code", "role_reaches_foreign_server", "role_may_become_another", "role_unknown"];
|
|
52
|
+
/** The name the way out gives the role that only reads, or the first of `mason_reader2`, … not taken. */
|
|
53
|
+
export const READER_ROLE = "mason_reader";
|
|
54
|
+
/** Whether `role` holds privilege `p` on relation `rel` (SQL expressions): a column's INSERT, UPDATE or REFERENCES counts */
|
|
55
|
+
const holds = (role, rel, p) => `CASE WHEN ${p} IN ('INSERT', 'UPDATE', 'REFERENCES') THEN has_any_column_privilege(${role}, ${rel}, ${p}) ELSE has_table_privilege(${role}, ${rel}, ${p}) END`;
|
|
56
|
+
/** The predefined roles that reach the server's programs or files (a superuser's, in effect) */
|
|
57
|
+
export const SERVER_ROLES = ["pg_execute_server_program", "pg_write_server_files", "pg_read_server_files"];
|
|
58
|
+
/**
|
|
59
|
+
* Everything the checks read, in ONE statement under the catalog's search_path, for the tables `tables` names (a JSON
|
|
60
|
+
* list of {schema, table}). A superuser holds every privilege: what else it holds is not asked.
|
|
61
|
+
*/
|
|
62
|
+
export function roleFactsSql(tables) {
|
|
63
|
+
const want = pgLiteral(JSON.stringify(tables.map((t) => ({ schema: t.schema, table: t.table }))));
|
|
64
|
+
const writes = WRITE_PRIVILEGES.map((p) => `'${p}'`).join(", ");
|
|
65
|
+
const all = (1 << WRITE_PRIVILEGES.length) - 1, ins = 1, upd = 2, del = 4, trunc = 8;
|
|
66
|
+
return `SET LOCAL search_path = pg_catalog; WITH RECURSIVE me AS (SELECT oid, rolname, rolsuper, rolcreaterole FROM pg_roles WHERE rolname = current_user),
|
|
67
|
+
-- the roles whose privileges it holds or that it may switch to (Postgres 16 split the two, before it any membership was both)
|
|
68
|
+
r AS (SELECT p.oid, p.rolname, p.rolsuper, p.rolbypassrls, p.oid = me.oid AS self FROM pg_roles p, me
|
|
69
|
+
WHERE p.oid = me.oid OR (NOT me.rolsuper AND CASE WHEN current_setting('server_version_num')::int >= 160000
|
|
70
|
+
THEN pg_has_role(me.oid, p.oid, 'USAGE') OR pg_has_role(me.oid, p.oid, 'SET') ELSE pg_has_role(me.oid, p.oid, 'MEMBER') END)),
|
|
71
|
+
su AS (SELECT EXISTS (SELECT FROM r WHERE rolsuper) AS any),
|
|
72
|
+
want AS (SELECT w.schema, w."table" FROM json_to_recordset(${want}::json) AS w(schema text, "table" text)),
|
|
73
|
+
rel AS (SELECT w.schema, w."table", c.oid FROM want w JOIN pg_namespace n ON n.nspname = w.schema
|
|
74
|
+
JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = w."table" AND c.relkind IN ('r', 'p')),
|
|
75
|
+
-- how a write on one relation reaches another's rows. need: the modes it leaves from (S a synced table, R its rows'
|
|
76
|
+
-- relation, A an ancestor, F a referenced table, V a view or rule), gives: the mode it reaches, mask: the privileges
|
|
77
|
+
-- (bits in WRITE_PRIVILEGES' order) that reach, a view's or rule's read from pg_relation_is_updatable below
|
|
78
|
+
edge AS (SELECT inhparent AS src, inhrelid AS dst, 'SR' AS need, 'R' AS gives, ${all} AS mask FROM pg_inherits
|
|
79
|
+
UNION ALL SELECT inhrelid, inhparent, 'SRAF', 'A', ${upd | del | trunc} FROM pg_inherits
|
|
80
|
+
UNION ALL SELECT d.refobjid, rw.ev_class, 'SRAFV', 'V', 0 FROM pg_depend d JOIN pg_rewrite rw ON rw.oid = d.objid
|
|
81
|
+
WHERE d.classid = 'pg_rewrite'::regclass AND d.refclassid = 'pg_class'::regclass AND rw.ev_class <> d.refobjid
|
|
82
|
+
UNION ALL SELECT conrelid, confrelid, 'SRF', 'F', CASE WHEN confdeltype IN ('c', 'n', 'd') THEN ${del} ELSE 0 END | CASE WHEN confupdtype IN ('c', 'n', 'd') THEN ${upd} ELSE 0 END
|
|
83
|
+
FROM pg_constraint WHERE contype = 'f' AND (confdeltype IN ('c', 'n', 'd') OR confupdtype IN ('c', 'n', 'd'))),
|
|
84
|
+
-- each synced table and every relation that reaches its rows, at any depth: UNION stops at what it has seen
|
|
85
|
+
reach AS (SELECT schema || '.' || "table" AS entry, oid, 'S' AS mode, ${all} AS mask FROM rel
|
|
86
|
+
UNION SELECT x.entry, e.dst, e.gives, e.mask FROM reach x JOIN edge e ON e.src = x.oid AND position(x.mode IN e.need) > 0),
|
|
87
|
+
route AS (SELECT x.entry, x.oid, min(position(x.mode IN 'SRAFV')) AS rank,
|
|
88
|
+
bit_or(CASE WHEN x.mode <> 'V' THEN x.mask ELSE CASE WHEN k.u & 8 <> 0 THEN ${ins} ELSE 0 END | CASE WHEN k.u & 4 <> 0 THEN ${upd} ELSE 0 END | CASE WHEN k.u & 16 <> 0 THEN ${del} ELSE 0 END END) AS mask
|
|
89
|
+
FROM reach x CROSS JOIN LATERAL (SELECT CASE WHEN x.mode = 'V' THEN pg_relation_is_updatable(x.oid, true) ELSE 0 END AS u) k GROUP BY x.entry, x.oid),
|
|
90
|
+
-- each privilege that reaches a synced table's rows through a route
|
|
91
|
+
asks AS (SELECT x.entry, x.oid, u.p, u.o FROM route x CROSS JOIN unnest(ARRAY[${writes}]) WITH ORDINALITY AS u(p, o) WHERE x.mask & (1 << (u.o::int - 1)) <> 0),
|
|
92
|
+
who AS (SELECT rolname AS role, CASE WHEN self THEN 0 ELSE 1 END AS ord FROM r UNION ALL SELECT 'public', 2),
|
|
93
|
+
-- SECURITY DEFINER code (not the role's own) whose owner may write a synced table's rows: each owner asked once
|
|
94
|
+
sd AS (SELECT f.oid, f.proowner, f.prorettype NOT IN ('trigger'::regtype, 'event_trigger'::regtype) AS direct FROM pg_proc f, su
|
|
95
|
+
WHERE f.prosecdef AND NOT su.any AND f.proowner NOT IN (SELECT oid FROM r)),
|
|
96
|
+
writer AS (SELECT o.oid, o.rolname, o.rolsuper FROM pg_roles o WHERE o.oid IN (SELECT proowner FROM sd)
|
|
97
|
+
AND (o.rolsuper OR EXISTS (SELECT FROM asks a WHERE ${holds("o.rolname", "a.oid", "a.p")}))),
|
|
98
|
+
definer AS (SELECT sd.oid, sd.direct, w.rolname AS owner, w.rolsuper AS owner_super FROM sd JOIN writer w ON w.oid = sd.proowner),
|
|
99
|
+
-- the relations it may write whose enabled trigger calls one (pg_trigger read once, joined: never once per function)
|
|
100
|
+
dtrig AS (SELECT DISTINCT t.tgfoid, tn.nspname || '.' || tc.relname AS rel FROM pg_trigger t JOIN definer d ON d.oid = t.tgfoid
|
|
101
|
+
JOIN pg_class tc ON tc.oid = t.tgrelid JOIN pg_namespace tn ON tn.oid = tc.relnamespace
|
|
102
|
+
WHERE NOT t.tgisinternal AND t.tgenabled <> 'D'
|
|
103
|
+
AND EXISTS (SELECT FROM who w, unnest(ARRAY['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE']) AS u(p) WHERE ${holds("w.role::name", "t.tgrelid", "u.p")})),
|
|
104
|
+
-- what Postgres calls without asking the caller's EXECUTE: an event trigger's function, an aggregate's support
|
|
105
|
+
-- functions (when it may call the aggregate), a type's input, output, receive, send, typmod and analyze functions, an
|
|
106
|
+
-- operator's estimators, an operator class's support functions, a range's, a conversion's
|
|
107
|
+
unchecked AS (SELECT evtfoid AS fn FROM pg_event_trigger WHERE evtenabled <> 'D'
|
|
108
|
+
UNION SELECT a.fn FROM (SELECT aggfnoid, unnest(ARRAY[aggtransfn::oid, aggfinalfn::oid, aggcombinefn::oid, aggserialfn::oid, aggdeserialfn::oid,
|
|
109
|
+
aggmtransfn::oid, aggminvtransfn::oid, aggmfinalfn::oid]) AS fn FROM pg_aggregate) a
|
|
110
|
+
WHERE a.fn IN (SELECT oid FROM definer) AND EXISTS (SELECT FROM who w WHERE has_function_privilege(w.role::name, a.aggfnoid, 'EXECUTE'))
|
|
111
|
+
UNION SELECT unnest(ARRAY[typinput::oid, typoutput::oid, typreceive::oid, typsend::oid, typmodin::oid, typmodout::oid, typanalyze::oid]) FROM pg_type
|
|
112
|
+
UNION SELECT unnest(ARRAY[oprrest::oid, oprjoin::oid]) FROM pg_operator
|
|
113
|
+
UNION SELECT amproc::oid FROM pg_amproc
|
|
114
|
+
UNION SELECT unnest(ARRAY[rngcanonical::oid, rngsubdiff::oid]) FROM pg_range
|
|
115
|
+
UNION SELECT conproc::oid FROM pg_conversion)
|
|
116
|
+
SELECT json_build_object(
|
|
117
|
+
'user', me.rolname, 'user_sql', quote_ident(me.rolname), 'database_sql', quote_ident(current_database()),
|
|
118
|
+
'roles', (SELECT json_agg(json_build_object('name', rolname, 'self', self, 'superuser', rolsuper, 'bypassrls', rolbypassrls) ORDER BY NOT self, rolname) FROM r),
|
|
119
|
+
'relations', (SELECT coalesce(json_agg(json_build_object('name', n.nspname || '.' || c.relname, 'sql', quote_ident(n.nspname) || '.' || quote_ident(c.relname),
|
|
120
|
+
'kind', CASE x.rank WHEN 1 THEN 'table' WHEN 2 THEN CASE WHEN c.relispartition THEN 'partition' ELSE 'child' END WHEN 3 THEN 'parent' WHEN 4 THEN 'referenced'
|
|
121
|
+
ELSE CASE WHEN c.relkind = 'v' THEN 'view' ELSE 'rule' END END,
|
|
122
|
+
'entry', x.entry, 'schema_sql', quote_ident(n.nspname), 'owner', pg_get_userbyid(c.relowner),
|
|
123
|
+
'owned', x.rank <= 2 AND c.relowner IN (SELECT oid FROM r), 'row_security', c.relrowsecurity,
|
|
124
|
+
'privileges', CASE WHEN su.any THEN '[]'::json ELSE (SELECT coalesce(json_agg(json_build_object('role', CASE WHEN g.ord = 2 THEN 'PUBLIC' ELSE g.role END, 'privileges', g.privs) ORDER BY g.ord, g.role), '[]'::json)
|
|
125
|
+
FROM (SELECT w.role, w.ord, (SELECT coalesce(array_agg(a.p ORDER BY a.o), '{}') FROM asks a WHERE a.entry = x.entry AND a.oid = x.oid AND ${holds("w.role::name", "a.oid", "a.p")}) AS privs
|
|
126
|
+
FROM who w) g WHERE cardinality(g.privs) > 0) END) ORDER BY x.rank, x.entry, n.nspname, c.relname), '[]'::json)
|
|
127
|
+
FROM route x JOIN pg_class c ON c.oid = x.oid JOIN pg_namespace n ON n.oid = c.relnamespace),
|
|
128
|
+
'schemas', (SELECT coalesce(json_agg(json_build_object('name', n.nspname, 'sql', quote_ident(n.nspname), 'owner', pg_get_userbyid(n.nspowner),
|
|
129
|
+
'owned', n.nspowner IN (SELECT oid FROM r),
|
|
130
|
+
'create', CASE WHEN su.any THEN '[]'::json ELSE (SELECT coalesce(json_agg(rr.rolname ORDER BY NOT rr.self, rr.rolname), '[]'::json) FROM r rr WHERE has_schema_privilege(rr.rolname, n.oid, 'CREATE')) END,
|
|
131
|
+
'public_create', has_schema_privilege('public'::name, n.oid, 'CREATE')) ORDER BY n.nspname), '[]'::json)
|
|
132
|
+
FROM pg_namespace n WHERE n.oid IN (SELECT c.relnamespace FROM route x JOIN pg_class c ON c.oid = x.oid WHERE x.rank <= 2)),
|
|
133
|
+
'definers', (SELECT coalesce(json_agg(d ORDER BY d.name), '[]'::json) FROM (
|
|
134
|
+
SELECT quote_ident(n.nspname) || '.' || quote_ident(f.proname) || '(' || pg_get_function_identity_arguments(f.oid) || ')' AS name, x.owner, x.owner_super,
|
|
135
|
+
-- reached through a view, a policy, a default, a CHECK, an index, a cast or an operator only by EXECUTE, a
|
|
136
|
+
-- trigger's function only by its trigger
|
|
137
|
+
x.direct AND EXISTS (SELECT FROM who w WHERE has_function_privilege(w.role::name, x.oid, 'EXECUTE')) AS callable,
|
|
138
|
+
x.direct AND has_function_privilege('public'::name, x.oid, 'EXECUTE') AS public_callable,
|
|
139
|
+
(SELECT coalesce(json_agg(t.rel ORDER BY t.rel), '[]'::json) FROM dtrig t WHERE t.tgfoid = x.oid) AS triggers,
|
|
140
|
+
EXISTS (SELECT FROM pg_event_trigger e WHERE e.evtfoid = x.oid AND e.evtenabled <> 'D') AS event_trigger,
|
|
141
|
+
x.oid IN (SELECT fn FROM unchecked) AS unchecked
|
|
142
|
+
FROM definer x JOIN pg_proc f ON f.oid = x.oid JOIN pg_namespace n ON n.oid = f.pronamespace) d
|
|
143
|
+
WHERE d.callable OR json_array_length(d.triggers) > 0 OR d.unchecked),
|
|
144
|
+
'foreign_tables', CASE WHEN su.any THEN '[]'::json ELSE (SELECT coalesce(json_agg(n.nspname || '.' || c.relname ORDER BY n.nspname, c.relname), '[]'::json)
|
|
145
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'f'
|
|
146
|
+
AND EXISTS (SELECT FROM who w, unnest(ARRAY['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE']) AS u(p) WHERE ${holds("w.role::name", "c.oid", "u.p")})) END,
|
|
147
|
+
'mapped_servers', CASE WHEN su.any THEN '[]'::json ELSE (SELECT coalesce(json_agg(DISTINCT m.srvname), '[]'::json) FROM pg_user_mappings m
|
|
148
|
+
WHERE (m.umuser = 0 OR m.umuser IN (SELECT oid FROM r)) AND EXISTS (SELECT FROM who w WHERE has_server_privilege(w.role::name, m.srvid, 'USAGE'))) END,
|
|
149
|
+
'admin_of', CASE WHEN su.any THEN '[]'::json ELSE (SELECT coalesce(json_agg(p.rolname ORDER BY p.rolname), '[]'::json) FROM pg_roles p
|
|
150
|
+
WHERE p.oid <> me.oid AND pg_has_role(me.oid, p.oid, 'MEMBER WITH ADMIN OPTION')) END,
|
|
151
|
+
'create_role', NOT su.any AND me.rolcreaterole AND current_setting('server_version_num')::int < 160000,
|
|
152
|
+
'server_roles', (SELECT coalesce(json_agg(rolname ORDER BY rolname), '[]'::json) FROM r WHERE NOT su.any AND rolname IN (${SERVER_ROLES.map((x) => `'${x}'`).join(", ")})),
|
|
153
|
+
'missing', (SELECT coalesce(json_agg(w.schema || '.' || w."table"), '[]'::json) FROM want w WHERE NOT EXISTS (SELECT FROM rel WHERE rel.schema = w.schema AND rel."table" = w."table")),
|
|
154
|
+
'taken', (SELECT coalesce(json_agg(rolname ORDER BY rolname), '[]'::json) FROM pg_roles WHERE rolname LIKE '${READER_ROLE.replace(/_/g, "\\_")}%'),
|
|
155
|
+
'rds_replication', EXISTS (SELECT FROM pg_roles WHERE rolname = 'rds_replication')
|
|
156
|
+
)::text FROM me, su`;
|
|
157
|
+
}
|
|
158
|
+
/** The role's facts, read in the one statement; a role with no row of pg_roles (it was dropped while connected) throws. */
|
|
159
|
+
export async function readRoleFacts(pg, tables) {
|
|
160
|
+
const row = JSON.parse((await pg.query(roleFactsSql(tables))).rows[0]?.[0] ?? "null");
|
|
161
|
+
if (row === null)
|
|
162
|
+
throw new Error("pg_roles has no row for the user connected");
|
|
163
|
+
return {
|
|
164
|
+
user: row.user, userSql: row.user_sql, databaseSql: row.database_sql,
|
|
165
|
+
roles: row.roles,
|
|
166
|
+
relations: row.relations.map((x) => ({ name: x.name, sql: x.sql, kind: x.kind, entry: x.entry, schemaSql: x.schema_sql, owner: x.owner, owned: x.owned,
|
|
167
|
+
rowSecurity: x.row_security, privileges: x.privileges })),
|
|
168
|
+
schemas: row.schemas.map((s) => ({ name: s.name, sql: s.sql, owner: s.owner, owned: s.owned, create: s.create, publicCreate: s.public_create })),
|
|
169
|
+
definers: row.definers.map((d) => ({ name: d.name, owner: d.owner, ownerSuperuser: d.owner_super, callable: d.callable, publicCallable: d.public_callable,
|
|
170
|
+
triggers: d.triggers, eventTrigger: d.event_trigger, unchecked: d.unchecked && !d.event_trigger })),
|
|
171
|
+
foreignTables: row.foreign_tables, mappedServers: row.mapped_servers, adminOf: row.admin_of, createRole: row.create_role, serverRoles: row.server_roles,
|
|
172
|
+
missing: row.missing, taken: row.taken, rdsReplication: row.rds_replication,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const list = (xs) => xs.length <= 1 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`;
|
|
176
|
+
/** "through writers", "granted to PUBLIC": how a role came to hold something it holds by another */
|
|
177
|
+
function route(others) {
|
|
178
|
+
const roles = others.filter((r) => r !== "PUBLIC");
|
|
179
|
+
const parts = [...(roles.length ? [`through ${list(roles)}, which it is a member of`] : []), ...(others.includes("PUBLIC") ? ["granted to PUBLIC, which every role is"] : [])];
|
|
180
|
+
return parts.length ? ` (${parts.join("; ")})` : "";
|
|
181
|
+
}
|
|
182
|
+
/** " (a partition of public.events)": how a relation that is not a synced table reaches one */
|
|
183
|
+
function via(x) {
|
|
184
|
+
const said = { table: "", partition: ` (a partition of ${x.entry})`, child: ` (a child of ${x.entry}, by INHERITS: its rows are ${x.entry}'s)`,
|
|
185
|
+
parent: ` (an INHERITS parent of ${x.entry}: an UPDATE, DELETE or TRUNCATE of it reaches ${x.entry}'s rows)`,
|
|
186
|
+
referenced: ` (a foreign key's ON DELETE or ON UPDATE action carries its changes to ${x.entry}, as its owner)`,
|
|
187
|
+
view: ` (a view that writes ${x.entry})`, rule: ` (its rules write ${x.entry})` };
|
|
188
|
+
return said[x.kind];
|
|
189
|
+
}
|
|
190
|
+
const WHY = "Mason only reads, so a role that may write lets a Mason bug or a stolen URL change your database";
|
|
191
|
+
/** The checks of the role, from its facts: one warning per code that applies, else one line saying it only reads. */
|
|
192
|
+
export function roleChecks(f, ask) {
|
|
193
|
+
const fix = readOnlyFix(f, ask);
|
|
194
|
+
const warn = (code, detail) => ({ name: "role", ok: true, warn: true, code, detail: `${detail}. ${WHY}: sync as a role that only reads`, fix });
|
|
195
|
+
const out = [];
|
|
196
|
+
const supers = f.roles.filter((r) => r.superuser);
|
|
197
|
+
if (supers.length) {
|
|
198
|
+
// a superuser may do everything: what else it holds follows from it, and is not said again
|
|
199
|
+
const self = supers.some((r) => r.self);
|
|
200
|
+
out.push(warn("role_superuser", self ? `${f.user} is a superuser: it may write to, alter or drop anything on this database`
|
|
201
|
+
: `${f.user} is a member of ${list(supers.map((r) => r.name))}, a superuser: it may SET ROLE to it and write to, alter or drop anything on this database`));
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
const bypass = f.roles.filter((r) => r.bypassrls);
|
|
205
|
+
if (bypass.length) {
|
|
206
|
+
out.push(warn("role_bypassrls", `${f.user} has BYPASSRLS${route(bypass.filter((r) => !r.self).map((r) => r.name))}: row-level security does not apply to it, on any table`));
|
|
207
|
+
}
|
|
208
|
+
const owned = f.relations.filter((x) => x.owned);
|
|
209
|
+
if (owned.length) {
|
|
210
|
+
const others = [...new Set(owned.map((x) => x.owner).filter((o) => o !== f.user))];
|
|
211
|
+
out.push(warn("role_owns_table", `${f.user} owns ${list(owned.map((x) => `${x.name}${via(x)}`))}${route(others)}: ` +
|
|
212
|
+
"an owner may write to, alter or drop a table whatever is granted"));
|
|
213
|
+
}
|
|
214
|
+
// an owner holds every privilege on its table: the owner's line said it
|
|
215
|
+
const writable = f.relations.filter((x) => !x.owned && x.privileges.length > 0);
|
|
216
|
+
if (writable.length) {
|
|
217
|
+
const held = writable.map((x) => {
|
|
218
|
+
const privs = WRITE_PRIVILEGES.filter((p) => x.privileges.some((g) => g.privileges.includes(p)));
|
|
219
|
+
const others = x.privileges.filter((g) => g.role !== f.user).map((g) => g.role);
|
|
220
|
+
return `${privs.join(", ")} on ${x.name}${via(x)}${route(others)}`;
|
|
221
|
+
});
|
|
222
|
+
out.push(warn("role_writes_table", `${f.user} may ${held.join("; ")}`));
|
|
223
|
+
}
|
|
224
|
+
const creates = f.schemas.filter((s) => s.create.length > 0 || s.publicCreate);
|
|
225
|
+
if (creates.length) {
|
|
226
|
+
const held = creates.map((s) => `CREATE in schema ${s.name}${s.owned ? ` (${s.owner === f.user ? "it owns the schema" : `${s.owner}, which owns the schema`}: it may drop what is in it too)` : route([...s.create.filter((r) => r !== f.user), ...(s.publicCreate ? ["PUBLIC"] : [])])}`);
|
|
227
|
+
out.push(warn("role_creates_in_schema", `${f.user} may ${held.join("; ")}: it may make tables and functions beside the synced ones`));
|
|
228
|
+
}
|
|
229
|
+
if (f.foreignTables.length || f.mappedServers.length) {
|
|
230
|
+
const held = [...(f.foreignTables.length ? [`may write the foreign table(s) ${list(f.foreignTables)}`] : []),
|
|
231
|
+
...(f.mappedServers.length ? [`has a user mapping (its own or PUBLIC's) on ${list(f.mappedServers)}, which it may use`] : [])];
|
|
232
|
+
out.push(warn("role_reaches_foreign_server", `${f.user} ${held.join(", and ")}: what it sends there runs as the mapping's user, and the server may be this very database`));
|
|
233
|
+
}
|
|
234
|
+
if (f.adminOf.length || f.createRole || f.serverRoles.length) {
|
|
235
|
+
const held = [...(f.adminOf.length ? [`has ADMIN OPTION on ${list(f.adminOf)}: it may grant itself that role and its privileges`] : []),
|
|
236
|
+
...(f.createRole ? ["has CREATEROLE, which before Postgres 16 lets it grant itself any role but a superuser"] : []),
|
|
237
|
+
...(f.serverRoles.length ? [`holds ${list(f.serverRoles)}: the server's programs or files, as its operating-system user`] : [])];
|
|
238
|
+
out.push(warn("role_may_become_another", `${f.user} ${held.join("; ")}`));
|
|
239
|
+
}
|
|
240
|
+
// SECURITY DEFINER code: what it writes is in its body, which the catalog does not say, so it is LISTED for a person
|
|
241
|
+
// to review, never "fixed" by a statement that changes another role's privileges, and never taken for "only reads"
|
|
242
|
+
const definers = definerLine(f);
|
|
243
|
+
if (out.length)
|
|
244
|
+
return definers === null ? out : [...out, { name: "role", ok: true, warn: true, code: "role_may_run_definer_code", detail: `${f.user} ${definers}` }];
|
|
245
|
+
const unread = f.missing.length ? ` (not asked of ${list(f.missing)}, which the database does not have: mason start says so)` : "";
|
|
246
|
+
const synced = f.relations.filter((x) => x.kind === "table").length;
|
|
247
|
+
const itself = "not a superuser, no BYPASSRLS, no ADMIN OPTION, no server roles, no foreign server" + (synced ? `, and it neither owns nor may write to the ${synced} synced table(s) — ` +
|
|
248
|
+
"nor to their partitions, inheritance children or parents, the tables their foreign keys act on, or the views and rules that write them — " +
|
|
249
|
+
"nor CREATE in their schemas" : ", and no table is added yet (the tables are asked once one is)");
|
|
250
|
+
if (definers !== null)
|
|
251
|
+
return [{ name: "role", ok: true, warn: true, code: "role_may_run_definer_code", detail: `${f.user} may not write the synced tables itself: ${itself}. But it ${definers}${unread}` }];
|
|
252
|
+
return [{ name: "role", ok: true, detail: `${f.user} only reads: ${itself}, and it reaches no SECURITY DEFINER code whose owner may write${unread}` }];
|
|
253
|
+
}
|
|
254
|
+
/** "may run 2 SECURITY DEFINER function(s) of roles that may write the synced tables: …; what they write is not known", or null */
|
|
255
|
+
function definerLine(f) {
|
|
256
|
+
if (!f.definers.length)
|
|
257
|
+
return null;
|
|
258
|
+
const each = f.definers.map((d) => {
|
|
259
|
+
const how = [...(d.callable ? [d.publicCallable ? "every role may call it (PUBLIC's EXECUTE)" : "it may call it"] : []),
|
|
260
|
+
...(d.triggers.length ? [`a trigger on ${list(d.triggers)}, which it may write, calls it`] : []),
|
|
261
|
+
...(d.eventTrigger ? ["an event trigger calls it on any DDL, even one refused, never asking EXECUTE"] : []),
|
|
262
|
+
...(d.unchecked ? ["Postgres calls it for an aggregate, a type, an operator or the like, never asking EXECUTE"] : [])];
|
|
263
|
+
return `${d.name} runs as ${d.owner}${d.ownerSuperuser ? ", a superuser" : ", which may write the synced tables"}: ${how.join("; ")}`;
|
|
264
|
+
});
|
|
265
|
+
return `may run ${f.definers.length} SECURITY DEFINER function(s) of roles that may write the synced tables — ${each.join("; ")}. ` +
|
|
266
|
+
"What they write is not known (it is in their bodies, which the catalog does not read): review each, whether it writes a synced table and who may reach it";
|
|
267
|
+
}
|
|
268
|
+
/** The line a check is when the role could not be asked: said, never taken for "only reads". */
|
|
269
|
+
export function roleUnknown(why) {
|
|
270
|
+
return { name: "role", ok: null, code: "role_unknown", detail: `could not tell whether the user Mason connects as may write to the database: ${why}` };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The URL `url` with `role` as its user and a placeholder for its password, nothing else of it changed: built from what
|
|
274
|
+
* `shownUrl` shows (src/cli/preflight.ts, issue 541), so no part the URL is not read with is ever printed; a value it
|
|
275
|
+
* does not show is said in words.
|
|
276
|
+
*/
|
|
277
|
+
export function readerUrl(url, role) {
|
|
278
|
+
const shown = shownUrl(url);
|
|
279
|
+
if (shown === null)
|
|
280
|
+
return `the database's URL with ${role} and its password as the user and password`;
|
|
281
|
+
const u = new URL(shown);
|
|
282
|
+
return `${u.protocol}//${role}:<its password>@${u.host}${u.pathname}${u.search}`;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* THE WAY OUT, as the statements the database's owner runs, then the line of the project to change: a role that may
|
|
286
|
+
* LOGIN and SELECT the synced tables and nothing else — REPLICATION where a table streams, and the publication made by
|
|
287
|
+
* the owner when Mason would make its own (CREATE PUBLICATION … FOR TABLE needs the tables' owner); a policy for it where
|
|
288
|
+
* a table has row-level security (the new role would read no row of it). It NEVER prints a statement that changes any
|
|
289
|
+
* role's privileges but the new one's (PR 572's review: a REVOKE … FROM PUBLIC of a policy's function, run as printed,
|
|
290
|
+
* broke the application's own reads): what PUBLIC holds, and the SECURITY DEFINER code every role reaches, the new role
|
|
291
|
+
* holds and reaches too, and is SAID in a comment, for the database's owner to decide.
|
|
292
|
+
*/
|
|
293
|
+
export function readOnlyFix(f, ask) {
|
|
294
|
+
let role = READER_ROLE;
|
|
295
|
+
for (let n = 2; f.taken.includes(role); n++)
|
|
296
|
+
role = `${READER_ROLE}${n}`;
|
|
297
|
+
const tables = f.relations.filter((x) => x.kind === "table");
|
|
298
|
+
const schemas = [...new Set(tables.map((x) => x.schemaSql))];
|
|
299
|
+
const replication = ask.replicated.length > 0;
|
|
300
|
+
const replicating = replication && !f.rdsReplication;
|
|
301
|
+
const lines = [`-- as the database's owner (on the primary):`,
|
|
302
|
+
`CREATE ROLE ${role} LOGIN${replicating ? " REPLICATION" : ""} PASSWORD '<a new password>';`];
|
|
303
|
+
if (replication && f.rdsReplication)
|
|
304
|
+
lines.push(`GRANT rds_replication TO ${role};`);
|
|
305
|
+
lines.push(`GRANT CONNECT ON DATABASE ${f.databaseSql} TO ${role};`);
|
|
306
|
+
if (tables.length) {
|
|
307
|
+
lines.push(`GRANT USAGE ON SCHEMA ${schemas.join(", ")} TO ${role};`, `GRANT SELECT ON ${tables.map((x) => x.sql).join(", ")} TO ${role};`);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
lines.push(`-- and GRANT USAGE ON SCHEMA <schema>, SELECT ON <table> TO ${role}, for each table you add`);
|
|
311
|
+
}
|
|
312
|
+
for (const x of tables.filter((t) => t.rowSecurity))
|
|
313
|
+
lines.push(`CREATE POLICY ${role}_reads ON ${x.sql} FOR SELECT TO ${role} USING (true); -- row-level security: else it reads no row`);
|
|
314
|
+
// PUBLIC's privileges are every role's, the new one's too: said for the database's owner to decide, never revoked here
|
|
315
|
+
const publicCreate = f.schemas.filter((s) => s.publicCreate);
|
|
316
|
+
if (publicCreate.length)
|
|
317
|
+
lines.push(`-- PUBLIC may CREATE in schema ${list(publicCreate.map((s) => s.name))} (the default before Postgres 15), so the new role may too: whether PUBLIC should is the owner's to decide, for every role`);
|
|
318
|
+
for (const x of f.relations) {
|
|
319
|
+
const pub = x.privileges.find((g) => g.role === "PUBLIC");
|
|
320
|
+
if (pub)
|
|
321
|
+
lines.push(`-- PUBLIC holds ${pub.privileges.join(", ")} on ${x.name}, so the new role does too: whether PUBLIC should is the owner's to decide, for every role`);
|
|
322
|
+
}
|
|
323
|
+
// what Mason's own would carry (src/duck/sync.ts ensurePublication): the replicated tables, every change
|
|
324
|
+
const published = tables.filter((x) => ask.replicated.includes(x.entry));
|
|
325
|
+
const ownPublication = ask.publication === null && published.length > 0;
|
|
326
|
+
if (ownPublication) {
|
|
327
|
+
lines.push(`CREATE PUBLICATION ${role} FOR TABLE ${published.map((x) => x.sql).join(", ")} WITH (publish = 'insert, update, delete, truncate'); -- Mason's own needs the tables' owner`);
|
|
328
|
+
}
|
|
329
|
+
// what every role reaches, the new one too, is SAID, never changed: a statement that changed PUBLIC's would change
|
|
330
|
+
// every role's, the application's included (PR 572's review: a REVOKE of a policy's function broke the app's reads)
|
|
331
|
+
const everyone = f.definers.filter((d) => d.publicCallable || d.eventTrigger || d.unchecked);
|
|
332
|
+
if (everyone.length)
|
|
333
|
+
lines.push(`-- the new role may still run ${list(everyone.map((d) => d.name))}, as every role may (SECURITY DEFINER): mason doctor lists them to review`);
|
|
334
|
+
if (f.mappedServers.length)
|
|
335
|
+
lines.push(`-- a user mapping FOR PUBLIC on ${list(f.mappedServers)} would serve it too: map the roles that need it by name`);
|
|
336
|
+
lines.push("-- and GRANT SELECT ON each view Mason should serve: it serves the views the role it connects as may read");
|
|
337
|
+
// the app's own DATABASE_URL stays the app's: Mason's gets a variable of its own
|
|
338
|
+
const variable = ask.urlVariable === null || ask.urlVariable === "DATABASE_URL" ? "MASON_DATABASE_URL" : ask.urlVariable;
|
|
339
|
+
const own = variable !== ask.urlVariable;
|
|
340
|
+
lines.push(`then: set ${variable}=${readerUrl(ask.url, role)} ${own ? "in .env" : `where it is set now (${ask.urlFrom})`}` +
|
|
341
|
+
`${own ? ` and write url: \${${variable}} in ${ask.file}` : ""}${ownPublication ? `${own ? "," : " and"} publication: ${role} at its top` : ""}` +
|
|
342
|
+
"; mason doctor checks it again, and a running Mason reads it at its next start (mason stop, then mason start)");
|
|
343
|
+
return lines.join("\n");
|
|
344
|
+
}
|