@mason-data/cli 0.6.2 → 0.8.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 +147 -69
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +258 -14
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1290 -223
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +55 -8
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +5 -3
- package/dist/src/cli/commands.js +843 -272
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +158 -0
- package/dist/src/cli/dev.js +458 -125
- package/dist/src/cli/doctor.js +25 -12
- package/dist/src/cli/guides.js +103 -31
- package/dist/src/cli/init.js +159 -58
- package/dist/src/cli/machine.js +18 -12
- package/dist/src/cli/main.js +5 -5
- package/dist/src/cli/mcp.js +735 -0
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +24 -14
- package/dist/src/cli/project.js +48 -7
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +57 -35
- 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 +584 -0
- package/dist/src/cli/run-server.js +498 -133
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +147 -40
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +168 -0
- package/dist/src/cli/sources.js +290 -39
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +240 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +25 -11
- package/dist/src/duck/executor.js +107 -53
- package/dist/src/duck/explain.js +158 -29
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +73 -19
- package/dist/src/duck/gaps.js +25 -11
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +211 -44
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +120 -30
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +1193 -73
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +271 -159
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +907 -275
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +9 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +706 -55
- package/dist/src/web/app.js +330 -258
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +84 -15
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -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 +10 -3
package/dist/src/cli/user.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `mason user add | list | remove` — NAMED, REVOCABLE LOGINS FOR THE POSTGRES API, WITH NO EXPIRY
|
|
2
|
+
* `mason user add | list | disable | enable | remove` — NAMED, REVOCABLE LOGINS FOR THE POSTGRES API, WITH NO EXPIRY
|
|
3
3
|
* (docs/DESIGN.md "On a server", item 1 of the agreed plan; src/duck/users.ts holds the rules). A BI
|
|
4
4
|
* tool's datasource stores ONE password: a user's lasts until it is removed.
|
|
5
5
|
*
|
|
@@ -8,53 +8,301 @@
|
|
|
8
8
|
* the file's lock, and the run's listener reads the change at its next connection or statement. The password is said
|
|
9
9
|
* ONCE, by `add`, with the URL to paste (one URL builder, pg-url.ts `connectionUrl`) when the run is up and recorded
|
|
10
10
|
* where it listens. The web page's users (`mason ui`) are the same functions (`addUser`, `listUsers`, `removeUser`),
|
|
11
|
-
* called by the run's command server
|
|
11
|
+
* called by the run's command server; so are `disableUser` and `enableUser`.
|
|
12
12
|
*/
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { failed, jsonText, parseCommand, reportText, withEnd } from "./command.js";
|
|
15
15
|
import { reach } from "./state.js";
|
|
16
|
+
import { callRun, RunUnreachable } from "./control.js";
|
|
16
17
|
import { connectionUrl, listenerTips, postgresLink, psqlLine } from "./pg-url.js";
|
|
17
|
-
import { changeUsers, keyFingerprint, newUserId, readUsers, underKey, userPassword, USER_NAME_RE, USER_NAME_RULE, USERS_FILE } from "../duck/users.js";
|
|
18
|
+
import { changeUsers, isActive, isDisabled, keyFingerprint, newUserId, readUsers, underKey, userPassword, USER_NAME_RE, USER_NAME_RULE, USERS_FILE } from "../duck/users.js";
|
|
18
19
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
19
20
|
import { out as styleOut } from "./style.js";
|
|
21
|
+
import { NO_ROLES } from "../duck/grants.js";
|
|
22
|
+
import { exampleRole, missingAttributesText, noRoleWayOut, problemText, rolesProblemText, readRoles, ROLE_NAME_RE, ROLE_NAME_RULE, ROLES_FILE, roleProblems, rolesFailure, rolesFile, userRole, usersMissingAttributes } from "./roles.js";
|
|
23
|
+
import { ATTRIBUTE_NAME_RE, ATTRIBUTE_NAME_RULE, whereAttributes, whereParts } from "../duck/row-filter.js";
|
|
24
|
+
import { readSource, shownPath } from "./sources.js";
|
|
25
|
+
import { readState } from "./commands.js";
|
|
26
|
+
import { projectDatabase } from "./project.js";
|
|
20
27
|
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
21
28
|
export const USER = {
|
|
22
29
|
needs: "project",
|
|
23
30
|
name: "user",
|
|
24
31
|
summary: "the users the Postgres API takes: a name and a password that does not expire, for a BI tool or an app",
|
|
25
|
-
usage: `usage: mason user add <name> [--json]
|
|
32
|
+
usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--json]
|
|
26
33
|
mason user list [--json]
|
|
34
|
+
mason user disable <name> [--json]
|
|
35
|
+
mason user enable <name> [--json]
|
|
27
36
|
mason user remove <name> [--json]
|
|
28
37
|
|
|
29
38
|
A user of this project's Postgres API: a name and a password, as a BI tool's datasource form asks for them
|
|
30
39
|
(Metabase, Grafana, Superset, Power BI) or an app's DATABASE_URL carries them. Its password does not expire:
|
|
31
40
|
it connects until mason user remove, which refuses its next connection at once and
|
|
32
41
|
ends the sessions it holds at their next statement, without a restart. The password is printed once, by add.
|
|
33
|
-
The user name a tool sends must be the user's own.
|
|
42
|
+
The user name a tool sends must be the user's own. A user disabled is kept with its password: its logins are
|
|
43
|
+
refused (as Postgres refuses a role without LOGIN) and its sessions end at their next statement, until enable.
|
|
34
44
|
|
|
35
45
|
The list is .mason/users.json (names and ids, no password), beside the running Mason, which reads it again by itself.
|
|
46
|
+
A removed user stays in it, marked removed, so the list shows who was removed and when; its name can be added again.
|
|
36
47
|
Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.
|
|
37
48
|
|
|
49
|
+
--role <role> limits what the user reads to a role of mason/roles.yml (beside the sources file): the tables, and
|
|
50
|
+
the columns of each, it may read. A statement over anything else is refused as Postgres refuses it (42501,
|
|
51
|
+
permission denied for table …), information_schema and mason mcp list the role only what it may read, and the
|
|
52
|
+
role is read with the user at every statement, so an edit of the file applies at the next one. A user added
|
|
53
|
+
without --role reads as the file's default: role; under a file with no default: it reads nothing; with no
|
|
54
|
+
roles file at all, every synced table. mason init writes the file:
|
|
55
|
+
|
|
56
|
+
default: everything the role of a user added without --role
|
|
57
|
+
roles:
|
|
58
|
+
everything:
|
|
59
|
+
tables: all every synced table and column, those added later included
|
|
60
|
+
agent: the role's name (${ROLE_NAME_RULE})
|
|
61
|
+
tables:
|
|
62
|
+
orders: {} every column of a synced table
|
|
63
|
+
customers:
|
|
64
|
+
columns: [id, country] these columns only
|
|
65
|
+
billing.invoices:
|
|
66
|
+
exclude: [notes] every column but these
|
|
67
|
+
tenant:
|
|
68
|
+
attributes: [tenant] what each of its users carries: --set tenant=42
|
|
69
|
+
tables:
|
|
70
|
+
orders: { where: "tenant_id = :tenant" } the rows it reads: a Postgres condition
|
|
71
|
+
order_items: { through: orders } the rows whose order it reads
|
|
72
|
+
region:
|
|
73
|
+
attributes: { regions: list } several values: --set regions=eu --set regions=us
|
|
74
|
+
tables:
|
|
75
|
+
customers: { where: "region = ANY(:regions)" }
|
|
76
|
+
|
|
77
|
+
--set <attribute>=<value> gives the user its value of an attribute its role declares (repeat --set for each
|
|
78
|
+
attribute; a list attribute takes one --set per value; an empty list cannot be written). A role's where:
|
|
79
|
+
reads it as :attribute, bound as a literal of the type Postgres infers for it there (tenant_id = :tenant: the
|
|
80
|
+
column's), so every read of the table — through a view, a join, a subquery, a prepared statement — reads only
|
|
81
|
+
its rows, as Postgres's row security reads them. A user without a value its role reads is refused at every
|
|
82
|
+
statement, never answered with no rows: add it again with the --set. Postgres refuses a where: it cannot read
|
|
83
|
+
when the running Mason reads the file, at its key.
|
|
84
|
+
|
|
38
85
|
mason user add metabase a user for Metabase: its password and the URL to paste
|
|
86
|
+
mason user add agent --role agent a user held to the role agent
|
|
87
|
+
mason user add acme --role tenant --set tenant=42 the rows of tenant 42
|
|
88
|
+
mason user add emea --role region --set regions=eu --set regions=uk
|
|
39
89
|
mason user list
|
|
90
|
+
mason user disable metabase metabase connects no more, until mason user enable metabase
|
|
91
|
+
mason user enable metabase metabase connects again, with the same password
|
|
40
92
|
mason user remove metabase metabase connects no more
|
|
41
93
|
docker compose exec mason mason user add grafana beside mason serve, in its data folder
|
|
42
94
|
|
|
43
95
|
<name> ${USER_NAME_RULE}
|
|
44
|
-
--json as data: add { user (the name), id, addedAt, password, database,
|
|
45
|
-
|
|
96
|
+
--json as data: add { user (the name), id, addedAt, password, database, role (the role it reads as: its own
|
|
97
|
+
or the file's default; absent when none), attributes? ({ name: value, or [values] for a list }), roleFrom (file: its own --role; default: the file's default;
|
|
98
|
+
none), readsNothing (true: no role under a file with no default), and once Mason runs url, psql (the
|
|
99
|
+
psql line for url), host, port, sslmode }; list { users: [{ name, id, addedAt, removedAt (null for a user
|
|
100
|
+
not removed), disabledAt (null for a user not disabled), live (connects now), role?, attributes?,
|
|
101
|
+
missingAttributes? (the attributes its role's filters read that it lacks: refused until added again), roleFrom, readsNothing?,
|
|
102
|
+
roleKnown? (the roles file names its own role now), lastUsedAt (its newest statement, null when none within
|
|
103
|
+
requestLogDays), url (to paste, without the password; null for a removed user or when Mason is not running) }],
|
|
104
|
+
lastUsedNote (why lastUsedAt is unknown, else null), urlNote (why url is null, else null), requestLogDays
|
|
105
|
+
(null when unknown) }; disable { disabled: { name, id, addedAt, disabledAt }, already (it was disabled
|
|
106
|
+
before) }; enable { enabled: { name, id, addedAt }, already (it was not disabled) }; remove { removed: { name,
|
|
107
|
+
id, addedAt, removedAt } }; next
|
|
46
108
|
`,
|
|
47
|
-
options: { json: { type: "boolean", default: false } },
|
|
48
|
-
json: ["user", "id", "addedAt", "password", "url", "host", "port", "database", "sslmode", "users", "removed", "next"],
|
|
109
|
+
options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true } },
|
|
110
|
+
json: ["user", "id", "addedAt", "password", "url", "psql", "host", "port", "database", "sslmode", "role", "attributes", "roleFrom", "readsNothing", "users", "disabled", "enabled", "already", "removed", "lastUsedNote", "urlNote", "requestLogDays", "next"],
|
|
49
111
|
};
|
|
50
112
|
/** The users of a project and why one is not live: added under an API key since rotated. */
|
|
51
113
|
const liveOf = (u, apiKey) => underKey(u, apiKey);
|
|
114
|
+
export const NOT_RUNNING_LAST_USED = "unknown: Mason is not running, and the request log is only read by a running Mason (mason start)";
|
|
115
|
+
export const NOT_RUNNING_URL = "unknown: Mason is not running, so where its Postgres API listens is not known (mason start, then mason status)";
|
|
116
|
+
/** A time as `mason user list` stamps it: `2026-10-27 09:10`, UTC. */
|
|
117
|
+
const stamp = (iso) => iso.slice(0, 16).replace("T", " ");
|
|
118
|
+
/**
|
|
119
|
+
* WHEN A USER WAS LAST USED, as `mason user list` and the web page's Users page (src/web/pages/users.js, held equal by
|
|
120
|
+
* tests/web-page.test.ts) say it: its time, "unknown" when the note says why it cannot be known, "never" when it was
|
|
121
|
+
* added within the days the request log keeps (so the log holds its whole life: a user just added was "not used in the
|
|
122
|
+
* last 7 days", which reads as if it once was), else not used within those days. `now` is the clock, in ms.
|
|
123
|
+
*/
|
|
124
|
+
export function lastUsedText(lastUsedAt, lastUsedNote, requestLogDays, addedAt = null, now = Date.now()) {
|
|
125
|
+
if (lastUsedAt !== null)
|
|
126
|
+
return `${stamp(lastUsedAt)} UTC`;
|
|
127
|
+
if (lastUsedNote !== null)
|
|
128
|
+
return "unknown";
|
|
129
|
+
if (requestLogDays !== null && addedAt !== null && now - Date.parse(addedAt) < requestLogDays * 86_400_000)
|
|
130
|
+
return "never";
|
|
131
|
+
return `not used in the last ${requestLogDays ?? "?"} days`;
|
|
132
|
+
}
|
|
133
|
+
// ------------------------------------------------------------------ the attributes a user is added with
|
|
134
|
+
/** `--set name=value` read: the name and the value (everything after the first `=`, a comma included); or the problem */
|
|
135
|
+
export function setOf(arg) {
|
|
136
|
+
const eq = arg.indexOf("=");
|
|
137
|
+
if (eq < 0)
|
|
138
|
+
return `--set ${arg}: is <attribute>=<value>, like --set tenant=42`;
|
|
139
|
+
const name = arg.slice(0, eq);
|
|
140
|
+
if (!ATTRIBUTE_NAME_RE.test(name))
|
|
141
|
+
return `--set ${arg}: ${JSON.stringify(name)} is not an attribute's name: ${ATTRIBUTE_NAME_RULE}`;
|
|
142
|
+
// through /api/users' JSON: a shell cannot pass one
|
|
143
|
+
if (arg.includes("\0"))
|
|
144
|
+
return `--set ${name}: a value cannot hold a NUL byte — it would end every statement the value is bound into`;
|
|
145
|
+
return { name, value: arg.slice(eq + 1) };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* THE ATTRIBUTE VALUES A USER IS ADDED WITH, held to its role (`role`: its own, or the file's default) as the roles are
|
|
149
|
+
* now (`live`): every attribute the role declares set — a missing one refused with the where: that reads it and the exact
|
|
150
|
+
* `--set` to add —, none it does not declare (the closest it does), a scalar once, a list once per value and never
|
|
151
|
+
* empty. Checked BEFORE the user is written, by `mason user add` and the run's `/api/users` alike (one grammar: the
|
|
152
|
+
* `--set` strings). A scalar is a string, a list an array of strings, in the order given.
|
|
153
|
+
*/
|
|
154
|
+
export function attributesToAdd(name, role, sets, live) {
|
|
155
|
+
const g = role === undefined ? undefined : live.grants.get(role);
|
|
156
|
+
const declared = g?.attributes ?? new Map();
|
|
157
|
+
const usage = (message, fix) => ({ refused: { message, code: "usage", ...(fix !== undefined ? { fix } : {}) } });
|
|
158
|
+
const out = {};
|
|
159
|
+
for (const arg of sets) {
|
|
160
|
+
const s = setOf(arg);
|
|
161
|
+
if (typeof s === "string")
|
|
162
|
+
return usage(s);
|
|
163
|
+
const shape = declared.get(s.name);
|
|
164
|
+
if (shape === undefined) {
|
|
165
|
+
if (role === undefined)
|
|
166
|
+
return usage(`--set ${s.name}: the user has no role, so it carries no attribute`, `mason user add ${name} --role <a role that declares ${s.name}> --set ${arg}`);
|
|
167
|
+
const names = [...declared.keys()];
|
|
168
|
+
return usage(`--set ${s.name}: the role ${role} has no attribute ${s.name}${didYouMean(s.name, names)}`, names.length ? `its attributes: ${names.join(", ")}` : `the role ${role} declares no attributes (attributes: [${s.name}] beside its tables: in ${live.file ?? `mason/${ROLES_FILE}`})`);
|
|
169
|
+
}
|
|
170
|
+
if (shape === "list") {
|
|
171
|
+
if (s.value === "")
|
|
172
|
+
return usage(`--set ${s.name}=: ${s.name} is a list, and a list's value is never empty — an empty list is not written (Mason refuses a user without its values rather than answer no rows)`);
|
|
173
|
+
out[s.name] = [...(out[s.name] ?? []), s.value];
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
if (out[s.name] !== undefined) {
|
|
177
|
+
const lists = [...declared].filter(([, k]) => k === "list").map(([n]) => n);
|
|
178
|
+
return usage(`--set ${s.name} twice: ${s.name} is a scalar of the role ${role}, one value${didYouMean(s.name, lists)}`, `one --set ${s.name}=<value>; several values are a list attribute (attributes: { ${s.name}: list })`);
|
|
179
|
+
}
|
|
180
|
+
out[s.name] = s.value;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const missing = [...declared.keys()].filter((a) => out[a] === undefined);
|
|
184
|
+
if (missing.length > 0) {
|
|
185
|
+
const reads = (a) => {
|
|
186
|
+
const f = [...(g?.filters?.values() ?? [])].find((x) => x.where !== undefined && whereAttributes(x.where.parts).includes(a));
|
|
187
|
+
return f === undefined ? "" : ` (${f.at}.where: ${f.where.text})`;
|
|
188
|
+
};
|
|
189
|
+
const sets2 = missing.map((a) => declared.get(a) === "list" ? `--set ${a}=<value> --set ${a}=<another>` : `--set ${a}=<value>`).join(" ");
|
|
190
|
+
return { refused: { message: `the role ${role} needs ${missing.map((a) => `--set ${a}=…`).join(" ")}: its users carry ${missing.length === 1 ? "the attribute" : "the attributes"} ${missing.map((a) => `${a}${reads(a)}`).join(", ")}`,
|
|
191
|
+
code: "usage", why: "a user without a value its role's rows are filtered by would be refused at every statement (Mason never answers it with no rows)",
|
|
192
|
+
fix: `mason user add ${name}${role === undefined ? "" : ` --role ${role}`} ${[...sets.map((x) => `--set ${x}`), sets2].join(" ")}` } };
|
|
193
|
+
}
|
|
194
|
+
return { values: out };
|
|
195
|
+
}
|
|
196
|
+
// ------------------------------------------------------------------ the role a user is added with
|
|
197
|
+
/**
|
|
198
|
+
* WHY `role` IS NOT ONE A USER CAN BE ADDED WITH, against the roles as they are now (`live`: the listener's while the
|
|
199
|
+
* run is up, the file's otherwise): a name of another shape, a file that cannot be read as it is, no roles file at
|
|
200
|
+
* all (the lines to add), a role the file does not name (the closest it does). Null: the role is in the file. Checked
|
|
201
|
+
* BEFORE the user is written, by `mason user add` and the run's `/api/users` alike, so a user never carries a role
|
|
202
|
+
* that reads nothing by mistake — one whose role is removed LATER is refused at its next statement (fail closed).
|
|
203
|
+
*/
|
|
204
|
+
export function roleToAdd(role, live) {
|
|
205
|
+
if (!ROLE_NAME_RE.test(role))
|
|
206
|
+
return { message: `${JSON.stringify(role)} is not a role name: ${ROLE_NAME_RULE}`, code: "usage" };
|
|
207
|
+
const file = live.file ?? `mason/${ROLES_FILE}`;
|
|
208
|
+
if (live.problem !== null) {
|
|
209
|
+
return { message: `${file} cannot be read as it is now, so no user can be added with a role: ${live.problem}`, code: "bad_roles",
|
|
210
|
+
why: "a user's role is checked against the file before the user is written, and every statement of a user with a role is refused while the file is wrong",
|
|
211
|
+
fix: `repair ${file}; mason user add ${"<name>"} --role ${role} then`, next: [["mason user list", "the users, and whose role the file lacks"]] };
|
|
212
|
+
}
|
|
213
|
+
const names = [...live.grants.keys()];
|
|
214
|
+
if (names.length === 0) {
|
|
215
|
+
return { message: `there is no role ${role}: this project has no roles file`, code: "not_found",
|
|
216
|
+
why: `a role is a key of ${file}, beside the sources file, naming the tables (and the columns of each) its users may read`,
|
|
217
|
+
fix: `write ${file}:
|
|
218
|
+
roles:
|
|
219
|
+
${role}:
|
|
220
|
+
tables:
|
|
221
|
+
<a synced table>: {}
|
|
222
|
+
then mason user add <name> --role ${role}`,
|
|
223
|
+
next: [["mason tables", "the tables the sources file syncs: what a role may name"]] };
|
|
224
|
+
}
|
|
225
|
+
if (!live.grants.has(role)) {
|
|
226
|
+
return { message: `there is no role ${role} in ${file}${didYouMean(role, names)}`, code: "not_found",
|
|
227
|
+
why: `the roles: ${names.join(", ")}`,
|
|
228
|
+
fix: `add it to ${file}:
|
|
229
|
+
roles:
|
|
230
|
+
${role}:
|
|
231
|
+
tables:
|
|
232
|
+
<a synced table>: {}
|
|
233
|
+
or mason user add <name> --role ${names[0]}`,
|
|
234
|
+
next: [["mason user list", "the users and their roles"]] };
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* THE ROLES AS A COMMAND BESIDE THE RUN SEES THEM: the listener's own while the run is up (`/api/state`'s roles: what a
|
|
240
|
+
* session is held to this moment), else the file read here and its tables held to the sources file's entries (their
|
|
241
|
+
* columns are the database's, which only the run knows: a column the copy lacks is refused by mason start). One
|
|
242
|
+
* object of src/duck/grants.ts's shape, whose grants here carry the names only.
|
|
243
|
+
*/
|
|
244
|
+
export async function rolesBeside(project, run) {
|
|
245
|
+
if (run !== null) {
|
|
246
|
+
let r;
|
|
247
|
+
// a run that does not answer its state (hung, or an older run without /api/state's roles): the file is read here
|
|
248
|
+
// instead, as with Mason stopped — the user is still refused a role the file lacks, which is what this guards
|
|
249
|
+
try {
|
|
250
|
+
r = (await readState(run)).roles;
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
if (!(e instanceof RunUnreachable))
|
|
254
|
+
throw e;
|
|
255
|
+
r = undefined;
|
|
256
|
+
}
|
|
257
|
+
if (r !== undefined)
|
|
258
|
+
return { file: r.file, grants: new Map(r.roles.map((n) => [n, { name: n, file: r.file ?? "", tables: new Map(), all: r.all?.includes(n) === true, ...rowsOfState(r, n) }])), problem: r.problem, default: r.default ?? null };
|
|
259
|
+
}
|
|
260
|
+
const read = readRoles(project.folder, project.root);
|
|
261
|
+
if (read === null)
|
|
262
|
+
return NO_ROLES;
|
|
263
|
+
const file = shownPath(project.root, rolesFile(project.folder));
|
|
264
|
+
if ("message" in read)
|
|
265
|
+
return { file, grants: new Map(), problem: problemText(read), default: null };
|
|
266
|
+
const src = readSource(project.folder, project.root);
|
|
267
|
+
// the sources file unreadable here: the role's tables are held to it by mason start; the names are checked
|
|
268
|
+
const copied = "message" in src ? null : src.tables.map((e) => ({ source: e.table, columns: e.columns === null ? null : e.columns.map((name) => ({ name })) }));
|
|
269
|
+
const problems = copied === null ? [] : roleProblems(read, copied);
|
|
270
|
+
if (problems.length)
|
|
271
|
+
return { file, grants: new Map(), problem: problemText(rolesFailure(read.file, project.root, problems)), default: read.default };
|
|
272
|
+
return { file, grants: new Map(read.roles.map((r) => {
|
|
273
|
+
const filters = new Map(r.tables === "all" ? [] : r.tables.flatMap((t) => t.where === undefined ? [] : [[t.table, { table: t.table, at: `roles.${r.name}.tables.${t.name}`, where: t.where }]]));
|
|
274
|
+
return [r.name, { name: r.name, file, tables: new Map(), all: r.tables === "all", ...(r.attributes !== undefined ? { attributes: r.attributes } : {}), ...(filters.size > 0 ? { filters } : {}) }];
|
|
275
|
+
})), problem: null, default: read.default };
|
|
276
|
+
}
|
|
277
|
+
/** a role's attributes and its where:s as `/api/state` says them, as the grants' fields `attributesToAdd` reads */
|
|
278
|
+
function rowsOfState(r, role) {
|
|
279
|
+
const attrs = r.attributes?.[role];
|
|
280
|
+
const filters = new Map();
|
|
281
|
+
for (const f of r.wheres?.[role] ?? []) {
|
|
282
|
+
const parts = whereParts(f.where);
|
|
283
|
+
if (!("problem" in parts))
|
|
284
|
+
filters.set(f.table, { table: f.table, at: f.at, where: { text: f.where, parts } });
|
|
285
|
+
}
|
|
286
|
+
return { ...(attrs !== undefined && Object.keys(attrs).length > 0 ? { attributes: new Map(Object.entries(attrs)) } : {}), ...(filters.size > 0 ? { filters } : {}) };
|
|
287
|
+
}
|
|
288
|
+
/** a user's attributes as `mason user add` and `mason user list` say them: `tenant=42, regions=[eu, uk]` */
|
|
289
|
+
export const attributesText = (a) => Object.entries(a).map(([k, v]) => `${k}=${typeof v === "string" ? v : `[${v.join(", ")}]`}`).join(", ");
|
|
52
290
|
/** What to run after an add: with Mason up, the list and how to end it; with Mason stopped, mason start first. */
|
|
53
291
|
export const addedNext = (name, up) => up
|
|
54
292
|
? [["mason user list", "the users"], [`mason user remove ${name}`, "it connects no more"]]
|
|
55
293
|
: [["mason start", "start Mason: then mason status prints where the Postgres API listens"], ["mason user list", "the users"]];
|
|
56
294
|
/** What to run after a remove. */
|
|
57
|
-
export const REMOVED_NEXT = [["mason user list", "the users
|
|
295
|
+
export const REMOVED_NEXT = [["mason user list", "the users, removed ones included"], ["mason user add <name>", "a new user, with a new password"]];
|
|
296
|
+
/** What to run after a disable, and after an enable. */
|
|
297
|
+
export const disabledNext = (name) => [[`mason user enable ${name}`, "it connects again, with the same password"], ["mason user list", "the users"]];
|
|
298
|
+
export const enabledNext = (name) => [["mason user list", "the users"], [`mason user disable ${name}`, "it connects no more, until enabled"]];
|
|
299
|
+
/**
|
|
300
|
+
* WHAT A DISABLE DOES, in the words `mason user disable` prints and the web page's Disable asks with (src/web/pages/users.js
|
|
301
|
+
* `disableEffect`, held equal by tests/web-page.test.ts).
|
|
302
|
+
*/
|
|
303
|
+
export const disableEffect = (name) => `It connects no more until mason user enable ${name}, with the same password, and the sessions it holds end at their next statement.`;
|
|
304
|
+
/** A disabled user's state, as `mason user list` and the web page's Users page say it (held equal by the tests). */
|
|
305
|
+
export const disabledText = (disabledAt) => `Disabled ${stamp(disabledAt)} UTC`;
|
|
58
306
|
/** Why `name` is not one `mason user <verb>` takes (a usage failure), or null. */
|
|
59
307
|
export function userNameProblem(verb, name) {
|
|
60
308
|
if (name === undefined)
|
|
@@ -65,13 +313,43 @@ export function userNameProblem(verb, name) {
|
|
|
65
313
|
}
|
|
66
314
|
const cantChange = (file, e) => ({ message: `the list of users could not be changed: ${e.message}`, code: "failed",
|
|
67
315
|
fix: `${file} is the list (mason user writes it): repair it, or move it aside, which removes every user` });
|
|
68
|
-
/**
|
|
69
|
-
|
|
316
|
+
/**
|
|
317
|
+
* The users `file` lists, removed ones too, each with whether it connects under `apiKey` and — `link`, where the run's
|
|
318
|
+
* Postgres API listens, when it is known — the URL to paste without the password, and with a role whether `roles` names it now; or why the file cannot be read.
|
|
319
|
+
* `lastUsedAt` is null here: `withLastUsed` fills it from the request log, which only the run has.
|
|
320
|
+
*/
|
|
321
|
+
export function listUsers(file, apiKey, link, roles = NO_ROLES) {
|
|
70
322
|
const r = readUsers(file);
|
|
71
323
|
if ("problem" in r)
|
|
72
324
|
return { message: `the list of users cannot be read: ${r.problem}`, code: "bad_config", why: "no user connects while it cannot be read",
|
|
73
325
|
fix: `repair ${file}, or move it aside, which removes every user` };
|
|
74
|
-
|
|
326
|
+
// roleKnown only where the file can be read: under a problem the listener refuses every user held to it for the FILE
|
|
327
|
+
return { users: r.users.map((u) => ({ name: u.name, id: u.id, addedAt: u.addedAt, removedAt: u.removedAt ?? null, disabledAt: u.disabledAt ?? null,
|
|
328
|
+
live: isActive(u) && !isDisabled(u) && liveOf(u, apiKey),
|
|
329
|
+
lastUsedAt: null, url: link !== null && isActive(u) ? connectionUrl(link, u.name, null) : null,
|
|
330
|
+
...roleFields(u, roles), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}),
|
|
331
|
+
...((m) => m === undefined ? {} : { missingAttributes: m.missing })(usersMissingAttributes([u], roles)[0]),
|
|
332
|
+
...(u.role !== undefined && roles.problem === null ? { roleKnown: roles.grants.has(u.role) } : {}) })) };
|
|
333
|
+
}
|
|
334
|
+
/** a user's role as the JSON says it: the role it reads as, where from, and whether it reads nothing (src/cli/roles.ts userRole) */
|
|
335
|
+
function roleFields(u, roles) {
|
|
336
|
+
const r = userRole(u, roles);
|
|
337
|
+
return { ...(r.role !== undefined ? { role: r.role } : {}), roleFrom: r.roleFrom, ...(r.readsNothing ? { readsNothing: true } : {}) };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The rows with their last use from the request log (`lastUsed`, src/duck/requests.ts: within the log's retention, in
|
|
341
|
+
* the window each user was a user in, so a name added again is not credited with the earlier user's statements).
|
|
342
|
+
* A log that cannot be read is SAID in the note, never a guess.
|
|
343
|
+
*/
|
|
344
|
+
export async function withLastUsed(rows, requests, log) {
|
|
345
|
+
try {
|
|
346
|
+
const used = await requests.lastUsed(rows.map((u) => ({ name: u.name, since: u.addedAt, until: u.removedAt })));
|
|
347
|
+
return { users: rows.map((u, i) => ({ ...u, lastUsedAt: used[i] ?? null })), lastUsedNote: null, requestLogDays: requests.days };
|
|
348
|
+
}
|
|
349
|
+
catch (e) {
|
|
350
|
+
log(`mason user list: the request log could not be read for the users' last use: ${e.message}`);
|
|
351
|
+
return { users: [...rows], lastUsedNote: `unknown: the request log could not be read (${e.message})`, requestLogDays: requests.days };
|
|
352
|
+
}
|
|
75
353
|
}
|
|
76
354
|
/**
|
|
77
355
|
* ADD A USER to `file` under its lock: a new id, its password (said here, once, and kept nowhere), and — `link`, where
|
|
@@ -87,13 +365,15 @@ export function addUser(file, apiKey, name, o) {
|
|
|
87
365
|
let r;
|
|
88
366
|
try {
|
|
89
367
|
r = changeUsers(file, (users) => {
|
|
90
|
-
|
|
91
|
-
|
|
368
|
+
// a removed user's name is free; so is a live one's added under a key since rotated (it connects no more), said
|
|
369
|
+
const was = users.find((u) => u.name === name && isActive(u));
|
|
92
370
|
if (was !== undefined && was.key === fingerprint)
|
|
93
371
|
return { refused: "taken" };
|
|
94
372
|
replaced = was !== undefined;
|
|
95
|
-
|
|
96
|
-
|
|
373
|
+
const now = new Date().toISOString();
|
|
374
|
+
added = { name, id: newUserId(), addedAt: now, key: fingerprint, ...(o.role !== undefined ? { role: o.role } : {}),
|
|
375
|
+
...(o.attributes !== undefined && Object.keys(o.attributes).length > 0 ? { attributes: o.attributes } : {}) };
|
|
376
|
+
return [...users.map((u) => u === was ? { ...u, removedAt: now } : u), added];
|
|
97
377
|
});
|
|
98
378
|
}
|
|
99
379
|
catch (e) {
|
|
@@ -109,7 +389,8 @@ export function addUser(file, apiKey, name, o) {
|
|
|
109
389
|
const password = userPassword(apiKey, u.name, u.id);
|
|
110
390
|
const { link } = o;
|
|
111
391
|
return { user: u.name, id: u.id, addedAt: u.addedAt, password, database: link?.database ?? o.database,
|
|
112
|
-
...(link !== null ? { url: connectionUrl(link, u.name, password), host: link.host, port: link.port, sslmode: link.sslmode ?? "disable" } : {}),
|
|
392
|
+
...(link !== null ? { url: connectionUrl(link, u.name, password), psql: psqlLine(connectionUrl(link, u.name, password)), host: link.host, port: link.port, sslmode: link.sslmode ?? "disable" } : {}),
|
|
393
|
+
...roleFields(u, o.roles ?? NO_ROLES), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}), replaced };
|
|
113
394
|
}
|
|
114
395
|
/** REMOVE A USER from `file` under its lock: it connects no more. A name the file lacks is refused with the closest it has. */
|
|
115
396
|
export function removeUser(file, name) {
|
|
@@ -117,24 +398,91 @@ export function removeUser(file, name) {
|
|
|
117
398
|
if (usage !== null)
|
|
118
399
|
return { message: usage, code: "usage" };
|
|
119
400
|
let removed;
|
|
401
|
+
let removedAt = "";
|
|
120
402
|
let live = [];
|
|
121
403
|
let r;
|
|
122
404
|
try {
|
|
123
405
|
r = changeUsers(file, (users) => {
|
|
124
|
-
removed = users.find((u) => u.name === name);
|
|
125
|
-
live = users.map((u) => u.name);
|
|
126
|
-
|
|
406
|
+
removed = users.find((u) => u.name === name && isActive(u));
|
|
407
|
+
live = users.filter(isActive).map((u) => u.name);
|
|
408
|
+
// the entry stays, marked: the list shows who was removed and when; the listener reads only the live ones
|
|
409
|
+
removedAt = new Date().toISOString();
|
|
410
|
+
return removed === undefined ? { refused: "unknown" } : users.map((u) => u === removed ? { ...u, removedAt } : u);
|
|
127
411
|
});
|
|
128
412
|
}
|
|
129
413
|
catch (e) {
|
|
130
414
|
return cantChange(file, e);
|
|
131
415
|
}
|
|
132
|
-
if (!Array.isArray(r))
|
|
133
|
-
return
|
|
134
|
-
why: live.length ? `the users: ${live.join(", ")}` : "this project has no users", next: [["mason user list", "the users"]] };
|
|
135
|
-
}
|
|
416
|
+
if (!Array.isArray(r))
|
|
417
|
+
return noSuchUser(name, live);
|
|
136
418
|
const gone = removed;
|
|
137
|
-
return { removed: { name: gone.name, id: gone.id, addedAt: gone.addedAt } };
|
|
419
|
+
return { removed: { name: gone.name, id: gone.id, addedAt: gone.addedAt, removedAt } };
|
|
420
|
+
}
|
|
421
|
+
/** No user `name` among those not removed: the closest name they have, and who they are. */
|
|
422
|
+
const noSuchUser = (name, live) => ({ message: `there is no user ${JSON.stringify(name)}${didYouMean(name, live)}`, code: "not_found",
|
|
423
|
+
why: live.length ? `the users: ${live.join(", ")}` : "this project has no users", next: [["mason user list", "the users"]] });
|
|
424
|
+
/**
|
|
425
|
+
* DISABLE A USER in `file` under its lock: kept, its id (so its password) unchanged, marked `disabledAt`; the listener
|
|
426
|
+
* refuses its logins and ends its sessions at their next statement. One already disabled is said so (`already`), its
|
|
427
|
+
* first time kept, nothing written: a no-op is no failure (as `mason add` of a table already added).
|
|
428
|
+
*/
|
|
429
|
+
export function disableUser(file, name) {
|
|
430
|
+
const usage = userNameProblem("disable", name);
|
|
431
|
+
if (usage !== null)
|
|
432
|
+
return { message: usage, code: "usage" };
|
|
433
|
+
let found;
|
|
434
|
+
let disabledAt = "";
|
|
435
|
+
let live = [];
|
|
436
|
+
let r;
|
|
437
|
+
try {
|
|
438
|
+
r = changeUsers(file, (users) => {
|
|
439
|
+
found = users.find((u) => u.name === name && isActive(u));
|
|
440
|
+
live = users.filter(isActive).map((u) => u.name);
|
|
441
|
+
if (found === undefined)
|
|
442
|
+
return { refused: "unknown" };
|
|
443
|
+
if (isDisabled(found)) {
|
|
444
|
+
disabledAt = found.disabledAt;
|
|
445
|
+
return { refused: "already" };
|
|
446
|
+
}
|
|
447
|
+
disabledAt = new Date().toISOString();
|
|
448
|
+
return users.map((u) => u === found ? { ...u, disabledAt } : u);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
return cantChange(file, e);
|
|
453
|
+
}
|
|
454
|
+
if (found === undefined)
|
|
455
|
+
return noSuchUser(name, live);
|
|
456
|
+
const u = found;
|
|
457
|
+
return { disabled: { name: u.name, id: u.id, addedAt: u.addedAt, disabledAt }, already: !Array.isArray(r) };
|
|
458
|
+
}
|
|
459
|
+
/** ENABLE A DISABLED USER in `file` under its lock: it connects again, with the same password. One not disabled is said so. */
|
|
460
|
+
export function enableUser(file, name) {
|
|
461
|
+
const usage = userNameProblem("enable", name);
|
|
462
|
+
if (usage !== null)
|
|
463
|
+
return { message: usage, code: "usage" };
|
|
464
|
+
let found;
|
|
465
|
+
let live = [];
|
|
466
|
+
let r;
|
|
467
|
+
try {
|
|
468
|
+
r = changeUsers(file, (users) => {
|
|
469
|
+
found = users.find((u) => u.name === name && isActive(u));
|
|
470
|
+
live = users.filter(isActive).map((u) => u.name);
|
|
471
|
+
if (found === undefined)
|
|
472
|
+
return { refused: "unknown" };
|
|
473
|
+
if (!isDisabled(found))
|
|
474
|
+
return { refused: "already" };
|
|
475
|
+
return users.map((u) => { if (u !== found)
|
|
476
|
+
return u; const { disabledAt: _, ...rest } = u; return rest; });
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
return cantChange(file, e);
|
|
481
|
+
}
|
|
482
|
+
if (found === undefined)
|
|
483
|
+
return noSuchUser(name, live);
|
|
484
|
+
const u = found;
|
|
485
|
+
return { enabled: { name: u.name, id: u.id, addedAt: u.addedAt }, already: !Array.isArray(r) };
|
|
138
486
|
}
|
|
139
487
|
// ------------------------------------------------------------------ the command
|
|
140
488
|
export async function user(argv) {
|
|
@@ -144,12 +492,24 @@ export async function user(argv) {
|
|
|
144
492
|
const json = f.values.json === true;
|
|
145
493
|
const usage = (message) => failed("user", { message, code: "usage", usage: USER.usage }, { json });
|
|
146
494
|
const [verb, name, ...rest] = f.positionals;
|
|
495
|
+
const VERBS = ["add", "list", "disable", "enable", "remove"];
|
|
147
496
|
if (verb === undefined)
|
|
148
|
-
return usage("add, list or remove: mason user add <name> makes a user");
|
|
149
|
-
if (!
|
|
150
|
-
return usage(`there is no mason user ${verb}: add, list or remove${didYouMean(verb,
|
|
497
|
+
return usage("add, list, disable, enable or remove: mason user add <name> makes a user");
|
|
498
|
+
if (!VERBS.includes(verb))
|
|
499
|
+
return usage(`there is no mason user ${verb}: add, list, disable, enable or remove${didYouMean(verb, VERBS)}`);
|
|
151
500
|
if (verb === "list" ? name !== undefined : rest.length)
|
|
152
501
|
return usage(`mason user ${verb} takes ${verb === "list" ? "no name" : "one name"}: ${[name, ...rest].filter(Boolean).join(" ")} is too many`);
|
|
502
|
+
const role = typeof f.values.role === "string" ? f.values.role : undefined;
|
|
503
|
+
if (role !== undefined && verb !== "add")
|
|
504
|
+
return usage(`--role goes with mason user add: mason user add <name> --role ${role}`);
|
|
505
|
+
const sets = Array.isArray(f.values.set) ? f.values.set.filter((x) => typeof x === "string") : [];
|
|
506
|
+
if (sets.length > 0 && verb !== "add")
|
|
507
|
+
return usage(`--set goes with mason user add: mason user add <name> --role <role> --set ${sets[0]}`);
|
|
508
|
+
for (const arg of sets) {
|
|
509
|
+
const s = setOf(arg);
|
|
510
|
+
if (typeof s === "string")
|
|
511
|
+
return usage(s);
|
|
512
|
+
}
|
|
153
513
|
if (verb !== "list") {
|
|
154
514
|
const problem = userNameProblem(verb, name);
|
|
155
515
|
if (problem !== null)
|
|
@@ -162,24 +522,100 @@ export async function user(argv) {
|
|
|
162
522
|
const file = path.join(project.dir, USERS_FILE);
|
|
163
523
|
const apiKey = project.config.apiKey;
|
|
164
524
|
if (verb === "list") {
|
|
165
|
-
const
|
|
525
|
+
const up = w.state === "up" ? w.run : null;
|
|
526
|
+
const roles = await rolesBeside(project, up);
|
|
527
|
+
const r = listUsers(file, apiKey, up !== null ? postgresLink(up.postgresUrl) : null, roles);
|
|
166
528
|
if ("message" in r)
|
|
167
529
|
return failed("user", r, { json });
|
|
168
|
-
|
|
530
|
+
// the last use is the run's (its request log is in the copy's file, which only the run opens): asked of its command
|
|
531
|
+
// server, which lists the same file. A run that cannot say is SAID, never guessed.
|
|
532
|
+
let users = r.users;
|
|
533
|
+
let lastUsedNote = NOT_RUNNING_LAST_USED;
|
|
534
|
+
let requestLogDays = null;
|
|
535
|
+
if (up !== null) {
|
|
536
|
+
try {
|
|
537
|
+
const asked = await callRun(up, "GET", "/api/users?lastUsed=1");
|
|
538
|
+
if (asked.status === 200 && Array.isArray(asked.body.users)) {
|
|
539
|
+
const used = new Map(asked.body.users.map((u) => [u.id, u.lastUsedAt]));
|
|
540
|
+
users = users.map((u) => ({ ...u, lastUsedAt: used.get(u.id) ?? null }));
|
|
541
|
+
lastUsedNote = typeof asked.body.lastUsedNote === "string" ? asked.body.lastUsedNote : null;
|
|
542
|
+
requestLogDays = typeof asked.body.requestLogDays === "number" ? asked.body.requestLogDays : null;
|
|
543
|
+
}
|
|
544
|
+
else
|
|
545
|
+
lastUsedNote = `unknown: Mason answered ${asked.status}${typeof asked.body.error === "string" ? `: ${asked.body.error}` : ""}`;
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
if (!(e instanceof RunUnreachable))
|
|
549
|
+
throw e;
|
|
550
|
+
lastUsedNote = `unknown: ${e.message}`;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const urlNote = up !== null ? null : NOT_RUNNING_URL;
|
|
554
|
+
const next = [["mason user add <name>", "a user for a tool: its password and URL, printed once"],
|
|
555
|
+
...(users.some((u) => u.removedAt === null && u.disabledAt !== null) ? [["mason user enable <name>", "a disabled user connects again, with the same password"]] : []),
|
|
556
|
+
...(users.some((u) => u.removedAt === null) ? [["mason user remove <name>", "that user connects no more"]] : [])];
|
|
169
557
|
if (json) {
|
|
170
|
-
out(jsonText(USER, { users
|
|
558
|
+
out(jsonText(USER, { users, lastUsedNote, urlNote, requestLogDays }, next));
|
|
171
559
|
return 0;
|
|
172
560
|
}
|
|
173
561
|
const s = styleOut();
|
|
174
|
-
if (!
|
|
562
|
+
if (!users.length) {
|
|
175
563
|
out(withEnd(`${s.bold(`No users yet for ${project.config.name}.`)}`, { next }));
|
|
176
564
|
return 0;
|
|
177
565
|
}
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
566
|
+
const active = users.filter((u) => u.removedAt === null);
|
|
567
|
+
const gone = users.filter((u) => u.removedAt !== null);
|
|
568
|
+
const width = Math.max(...users.map((u) => u.name.length));
|
|
569
|
+
const rolesFileShown = roles.file ?? `mason/${ROLES_FILE}`;
|
|
570
|
+
// the last use in the words the web page's Users page says it too (lastUsedText): "last used" before a time or "unknown"
|
|
571
|
+
const used = (u) => { const t = lastUsedText(u.lastUsedAt, lastUsedNote, requestLogDays, u.addedAt); return t === "never" ? "never used" : t.startsWith("not used") ? t : `last used ${t}`; };
|
|
572
|
+
const lines = active.map((u) => ` ${s.cyan(u.name.padEnd(width))} ${u.role !== undefined ? `role ${u.role}${u.roleFrom === "default" ? " (default)" : ""}${u.attributes !== undefined ? ` (${attributesText(u.attributes)})` : ""} ` : ""}${s.dim(`id ${u.id} · added ${stamp(u.addedAt)} UTC · ${used(u)}`)}` +
|
|
573
|
+
(u.disabledAt !== null ? ` ${s.yellow(`${disabledText(u.disabledAt)}: does not connect until mason user enable ${u.name}`)}`
|
|
574
|
+
: u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`) +
|
|
575
|
+
(u.roleKnown === false ? ` ${s.yellow(`its role ${u.role} is not in the roles file: its statements are refused`)}` : "") +
|
|
576
|
+
(u.readsNothing === true ? ` ${s.yellow(`has no role, and ${rolesFileShown} names no default: its statements are refused`)}` : "") +
|
|
577
|
+
(u.missingAttributes !== undefined ? ` ${s.yellow(`has no value for ${u.missingAttributes.join(", ")}: its statements are refused`)}` : ""));
|
|
578
|
+
const disabled = active.filter((u) => u.disabledAt !== null);
|
|
579
|
+
const removedLines = gone.map((u) => ` ${s.dim(`${u.name.padEnd(width)} id ${u.id} · added ${stamp(u.addedAt)} UTC · removed ${stamp(u.removedAt)} UTC`)}`);
|
|
580
|
+
const rotated = active.filter((u) => !u.live && u.disabledAt === null);
|
|
581
|
+
const orphan = active.find((u) => u.roleKnown === false);
|
|
582
|
+
const roleless = active.find((u) => u.readsNothing === true);
|
|
583
|
+
const unset = active.find((u) => u.missingAttributes !== undefined);
|
|
584
|
+
out(withEnd(`${s.bold(`${active.length} user${active.length === 1 ? "" : "s"} of ${project.config.name}${disabled.length ? `, ${disabled.length} disabled` : ""}${gone.length ? `, ${gone.length} removed` : ""}`)}\n${[...lines, ...removedLines].join("\n")}`, { tips: [...(rotated.length ? [`A user added under another API key does not connect: mason user remove ${rotated[0].name}, then mason user add ${rotated[0].name}.`] : []),
|
|
585
|
+
...(orphan ? [`A role the roles file (mason/${ROLES_FILE}) does not name reads nothing: add ${orphan.role} to it, or mason user remove ${orphan.name}, then mason user add ${orphan.name} --role <role>.`] : []),
|
|
586
|
+
...(unset ? [`A user without a value its role's rows are filtered by is refused at every statement: ${missingAttributesText({ name: unset.name, role: unset.role ?? "", missing: unset.missingAttributes })}.`] : []),
|
|
587
|
+
...(roleless ? [`A user with no role reads nothing while ${rolesFileShown} names no default: ${noRoleWayOut(roleless.name, rolesFileShown, exampleRole(roles))}.`] : []),
|
|
588
|
+
...(roles.problem !== null && active.length ? [rolesProblemText(rolesFileShown, roles.problem)] : []),
|
|
589
|
+
...(lastUsedNote !== null ? [`Last used: ${lastUsedNote}.`] : [])], next }));
|
|
590
|
+
return 0;
|
|
591
|
+
}
|
|
592
|
+
if (verb === "disable") {
|
|
593
|
+
const r = disableUser(file, name);
|
|
594
|
+
if ("message" in r)
|
|
595
|
+
return failed("user", r, { json });
|
|
596
|
+
const next = disabledNext(r.disabled.name);
|
|
597
|
+
if (json) {
|
|
598
|
+
out(jsonText(USER, r, next));
|
|
599
|
+
return 0;
|
|
600
|
+
}
|
|
601
|
+
const d = r.disabled;
|
|
602
|
+
out(reportText({ headline: r.already ? `The user ${d.name} is disabled already: nothing changed` : `The user ${d.name} disabled: it connects no more, until enabled`,
|
|
603
|
+
details: [["user", d.name], ["disabled", d.disabledAt], ...(r.already ? [] : [["sessions", w.state === "up" ? "those it holds end at their next statement" : "none: Mason is not running"]])],
|
|
604
|
+
tips: [disableEffect(d.name)], next }));
|
|
605
|
+
return 0;
|
|
606
|
+
}
|
|
607
|
+
if (verb === "enable") {
|
|
608
|
+
const r = enableUser(file, name);
|
|
609
|
+
if ("message" in r)
|
|
610
|
+
return failed("user", r, { json });
|
|
611
|
+
const next = enabledNext(r.enabled.name);
|
|
612
|
+
if (json) {
|
|
613
|
+
out(jsonText(USER, r, next));
|
|
614
|
+
return 0;
|
|
615
|
+
}
|
|
616
|
+
const e = r.enabled;
|
|
617
|
+
out(reportText({ headline: r.already ? `The user ${e.name} is not disabled: nothing changed, it connects` : `The user ${e.name} enabled: it connects again, with the same password`,
|
|
618
|
+
details: [["user", e.name], ["added", e.addedAt]], next }));
|
|
183
619
|
return 0;
|
|
184
620
|
}
|
|
185
621
|
if (verb === "remove") {
|
|
@@ -200,7 +636,22 @@ export async function user(argv) {
|
|
|
200
636
|
// add
|
|
201
637
|
const run = w.state === "up" ? w.run : null;
|
|
202
638
|
const link = run ? postgresLink(run.postgresUrl) : null;
|
|
203
|
-
|
|
639
|
+
// the role must be one the roles file names NOW (the listener's reading while the run is up): refused before writing
|
|
640
|
+
const roles = await rolesBeside(project, run);
|
|
641
|
+
if (role !== undefined) {
|
|
642
|
+
const problem = roleToAdd(role, roles);
|
|
643
|
+
if (problem !== null)
|
|
644
|
+
return failed("user", problem, { json });
|
|
645
|
+
}
|
|
646
|
+
// the name it connects with: the run's, or with Mason stopped what the project says it will be
|
|
647
|
+
const database = link?.database ?? projectDatabase(project);
|
|
648
|
+
if (typeof database !== "string")
|
|
649
|
+
return failed("user", database, { json });
|
|
650
|
+
// the attributes the role declares, every one, and none other: refused before writing, as the role is
|
|
651
|
+
const attributes = attributesToAdd(name, role ?? userRole({}, roles).role, sets, roles);
|
|
652
|
+
if ("refused" in attributes)
|
|
653
|
+
return failed("user", attributes.refused, { json });
|
|
654
|
+
const r = addUser(file, apiKey, name, { link, database, roles, ...(role !== undefined ? { role } : {}), attributes: attributes.values });
|
|
204
655
|
if ("message" in r)
|
|
205
656
|
return failed("user", r, { json });
|
|
206
657
|
const { replaced, ...added } = r;
|
|
@@ -212,12 +663,17 @@ export async function user(argv) {
|
|
|
212
663
|
out(reportText({
|
|
213
664
|
headline: `The user ${r.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}: its password is printed this once`,
|
|
214
665
|
details: [
|
|
215
|
-
...(r.url !== undefined ? [["url", r.url], ["psql",
|
|
666
|
+
...(r.url !== undefined && r.psql !== undefined ? [["url", r.url], ["psql", r.psql]] : []),
|
|
216
667
|
["user", r.user], ["password", r.password], ["database", r.database],
|
|
668
|
+
...(r.attributes !== undefined ? [["attributes", attributesText(r.attributes)]] : []),
|
|
669
|
+
...(r.roleFrom === "file" ? [["role", `${r.role} (mason/${ROLES_FILE} names what it reads)`]]
|
|
670
|
+
: r.roleFrom === "default" ? [["role", `${r.role} (the file's default: ${roles.file ?? `mason/${ROLES_FILE}`})`]]
|
|
671
|
+
: r.readsNothing === true ? [["role", `none, and ${roles.file ?? `mason/${ROLES_FILE}`} names no default: it reads nothing until one is written`]] : []),
|
|
217
672
|
...(link !== null ? [["host", link.host], ["port", String(link.port)], ...(link.sslmode ? [["ssl mode", link.sslmode]] : [])] : []),
|
|
218
673
|
],
|
|
219
674
|
tips: [
|
|
220
675
|
"Paste it into the tool now: Mason keeps no copy of the password. Lost, remove the user and add it again.",
|
|
676
|
+
...(r.readsNothing === true ? [`Its statements are refused until it has a role: ${noRoleWayOut(r.user, roles.file ?? `mason/${ROLES_FILE}`, exampleRole(roles))}.`] : []),
|
|
221
677
|
...(link === null ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
|
|
222
678
|
...listenerTips(run?.postgresListener ?? null),
|
|
223
679
|
"It never expires: mason user remove ends it, at once and for every session it holds.",
|