@mason-data/cli 0.8.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 +13 -4
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- 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 +505 -99
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +169 -11
- package/dist/src/cli/commands.js +207 -51
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +116 -33
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +97 -6
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +47 -11
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +1 -1
- package/dist/src/cli/main.js +8 -2
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +2 -2
- 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 +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +99 -0
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +12 -4
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +154 -7
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +63 -7
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +3 -3
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +30 -20
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +115 -11
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +126 -74
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- 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 +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -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 +9 -7
- 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 +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +5 -0
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +24 -9
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason user add | list | disable | enable | remove … --replica <org>/<name>` — THE USERS OF A REPLICA ON A HOSTED MASON
|
|
3
|
+
* SERVICE (docs/HOSTED-API.md "The users of a replica"). Each subcommand is ONE request, the one the run's own command
|
|
4
|
+
* server takes for it (src/cli/run-server.ts `/api/users`), sent to the service under `/v1/replicas/{org}/{name}/users`:
|
|
5
|
+
*
|
|
6
|
+
* list GET …/users?lastUsed=1 mason user list --json's shape, beside its next
|
|
7
|
+
* add POST …/users { name, role?, set? } mason user add --json's shape (the run is up: its url)
|
|
8
|
+
* disable POST …/users/{user}/disable { disabled, already }
|
|
9
|
+
* enable POST …/users/{user}/enable { enabled, already }
|
|
10
|
+
* remove DELETE …/users/{user} { removed }
|
|
11
|
+
*
|
|
12
|
+
* `--role` and `--set` go as they were given: Mason on the replica checks them with the functions it checks them with
|
|
13
|
+
* here (src/cli/user.ts `roleToAdd`, `attributesToAdd`). What only this command line says (a verb, a user's name, a
|
|
14
|
+
* role's name, a `--set`'s form) is checked first, by the same functions, and refused as a wrong line before a byte is
|
|
15
|
+
* sent. Nothing of a project here is read or written: the users are the replica's.
|
|
16
|
+
*
|
|
17
|
+
* THE ANSWER IS HELD TO THE RUN'S OWN ANSWER SHAPE, then printed by the renderers the local command prints with
|
|
18
|
+
* (src/cli/user.ts `listText`, `addedReport`, …), in serviceStyle: the same answer prints the same lines. A SERVICE'S
|
|
19
|
+
* VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts). Every value the
|
|
20
|
+
* service sends that this command prints, and how it is held:
|
|
21
|
+
*
|
|
22
|
+
* value where it is printed what holds it
|
|
23
|
+
* a failure's message, why, fix stderr's failure, --json error `serviceFailure`: one line each (`serviceText`), masked
|
|
24
|
+
* a failure's code inside why: "(the service said …)" `^[a-z][a-z0-9_]*$`, 64 chars; the code is `codeOfStatus`'s
|
|
25
|
+
* a failure's next nowhere: never read the step is `failureNext`'s, by the status
|
|
26
|
+
* a user's name every row, headline and step it names `USER_NAME_RE`; add, disable, enable, remove: the name asked
|
|
27
|
+
* an id list's rows, --json 16 hex digits
|
|
28
|
+
* addedAt, removedAt, disabledAt, rows, list's lines, --json `isServiceTime`
|
|
29
|
+
* lastUsedAt
|
|
30
|
+
* the password add's url, psql and password rows, --json `mason_<its id>_<32 hex>` (src/duck/users.ts), add's alone;
|
|
31
|
+
* anything shaped as one elsewhere refused (`carriesNoSecret`)
|
|
32
|
+
* or, in a failure's words, masked (`serviceText`)
|
|
33
|
+
* database, host, port, sslmode add's rows, --json the database name's rule, a host's letters, 1-65535, a closed set
|
|
34
|
+
* url, psql (add) add's rows, --json rebuilt here (`connectionUrl`, `psqlLine`): the service's must equal them
|
|
35
|
+
* url (list) --json `postgres://<its name>@<host>:<port>/<database>`, no password
|
|
36
|
+
* role, roleFrom, readsNothing, rows, list's lines, --json `ROLE_NAME_RE`; closed sets; booleans; each consistent with the others
|
|
37
|
+
* roleKnown, live
|
|
38
|
+
* attributes, missingAttributes rows, list's lines, --json `ATTRIBUTE_NAME_RE` names; each value one line (`serviceText`), bounded
|
|
39
|
+
* lastUsedNote, urlNote list's tip "Last used: …", --json one line (`serviceText`), bounded
|
|
40
|
+
* requestLogDays list's lines, --json a whole number, 1 to 36500
|
|
41
|
+
* already the headline chosen, --json a boolean
|
|
42
|
+
* an HTTP status a failure's why a number
|
|
43
|
+
*
|
|
44
|
+
* BEFORE ANY OF THESE, every string of the answer, its keys included, is put to the exact test (src/cli/hosted.ts
|
|
45
|
+
* `carriesNoSecret`, `carriesSecret` on each: an echo of the held token, `ECHO_RUN` of its body in a row in one string; a
|
|
46
|
+
* token's or a password's shape — add's own password, url and psql for the token alone): an answer that carries one
|
|
47
|
+
* anywhere is refused whole, so no grammar is ever the only thing between a secret and the output, and an honest answer
|
|
48
|
+
* is refused with a probability under 1 in a million whatever its size. A failure's code, printed inside why, is put to
|
|
49
|
+
* the same test (`failureShape`). The free text (the notes, a refusal's words) is masked too, by #591's quarter rule.
|
|
50
|
+
*
|
|
51
|
+
* An answer any of which does not hold is outside the contract: said by the request and its status, never its body.
|
|
52
|
+
* What to run next is the CLI's own (`onReplica`: the local command's steps, each `mason user …` naming the replica).
|
|
53
|
+
*/
|
|
54
|
+
import { failed, jsonText, serviceStyle } from "./command.js";
|
|
55
|
+
import { call, carriesNoSecret, isFailure, isServiceTime, serviceFailure, serviceNext, serviceOf, serviceText, unexpected } from "./hosted.js";
|
|
56
|
+
import { replicaText, tokenFor } from "./replica.js";
|
|
57
|
+
import { holdOutput } from "./output-gate.js";
|
|
58
|
+
import { err as styleErr, out as styleOut } from "./style.js";
|
|
59
|
+
import { connectionUrl, psqlLine } from "./pg-url.js";
|
|
60
|
+
import { USER_NAME_RE } from "../duck/users.js";
|
|
61
|
+
import { ROLE_NAME_RE } from "./roles.js";
|
|
62
|
+
import { ATTRIBUTE_NAME_RE } from "../duck/row-filter.js";
|
|
63
|
+
import { databaseNameProblem } from "../lib/database-name.js";
|
|
64
|
+
import { NO_ROLES } from "../duck/grants.js";
|
|
65
|
+
import { addedNext, addedReport, disabledNext, disabledReport, enabledNext, enabledReport, listNext, listText, REMOVED_NEXT, removedReport, USER } from "./user.js";
|
|
66
|
+
/** The bound of a list's answer (a replica's users, a row each), and of the number of users in it; every other answer
|
|
67
|
+
* is `call`'s own 64 KiB. */
|
|
68
|
+
export const MAX_LIST_BYTES = 1024 * 1024;
|
|
69
|
+
export const MAX_USERS = 2000;
|
|
70
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
71
|
+
const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
72
|
+
const ID = /^[0-9a-f]{16}$/;
|
|
73
|
+
const HOST = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,62}))*\.?|[0-9A-Fa-f:.]{2,45})$/;
|
|
74
|
+
const ROLE_FROM = ["file", "default", "none"];
|
|
75
|
+
/** The local command's next steps, as a replica's: each `mason user …` names the replica, so it runs against it. */
|
|
76
|
+
export const onReplica = (steps, r) => steps.map(([command, ...what]) => [command.startsWith("mason user ") ? `${command} --replica ${replicaText(r)}` : command, ...what]);
|
|
77
|
+
/** What to run after the service refused a request about a replica's users: the CLI's own, by the status (never the
|
|
78
|
+
* service's `next`): sign in again for a 401 or 403, else the replica's users. */
|
|
79
|
+
export const failureNext = (status, r) => status === 401 || status === 403 ? serviceNext(status) : [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]];
|
|
80
|
+
/** The request a subcommand is: the run's own `/api/users` request, under the replica's path. */
|
|
81
|
+
export function requestOf(r, o) {
|
|
82
|
+
const base = `/v1/replicas/${r.org}/${r.name}/users`;
|
|
83
|
+
// a user's name is USER_NAME_RE's (checked before this): letters, digits, `_`, `.` and `-`, safe in a path as it is
|
|
84
|
+
switch (o.verb) {
|
|
85
|
+
case "list": return { method: "GET", path: `${base}?lastUsed=1` };
|
|
86
|
+
case "add": return { method: "POST", path: base, json: { name: o.name, ...(o.role !== undefined ? { role: o.role } : {}), ...(o.sets.length ? { set: [...o.sets] } : {}) } };
|
|
87
|
+
case "disable":
|
|
88
|
+
case "enable": return { method: "POST", path: `${base}/${o.name}/${o.verb}` };
|
|
89
|
+
case "remove": return { method: "DELETE", path: `${base}/${o.name}` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// ------------------------------------------------------------------ the answers, held to the run's own shapes
|
|
93
|
+
/** A string the service sends, as one line: null when it is not a string. */
|
|
94
|
+
const line = (v, max, secrets) => typeof v === "string" ? serviceText(v, max, secrets) : null;
|
|
95
|
+
/** A user's role fields, each consistent with the others (src/cli/roles.ts userRole): a role for `file` and `default`,
|
|
96
|
+
* none for `none`, `readsNothing` only where there is none. */
|
|
97
|
+
function roleOf(u) {
|
|
98
|
+
if (typeof u.roleFrom !== "string" || !ROLE_FROM.includes(u.roleFrom))
|
|
99
|
+
return null;
|
|
100
|
+
const roleFrom = u.roleFrom;
|
|
101
|
+
if (u.role !== undefined && (typeof u.role !== "string" || !ROLE_NAME_RE.test(u.role)))
|
|
102
|
+
return null;
|
|
103
|
+
if ((roleFrom === "none") !== (u.role === undefined))
|
|
104
|
+
return null;
|
|
105
|
+
if (u.readsNothing !== undefined && (u.readsNothing !== true || roleFrom !== "none"))
|
|
106
|
+
return null;
|
|
107
|
+
return { ...(typeof u.role === "string" ? { role: u.role } : {}), roleFrom, ...(u.readsNothing === true ? { readsNothing: true } : {}) };
|
|
108
|
+
}
|
|
109
|
+
/** A user's attributes: at most 64 names, each a value (one line, bounded) or a list of 1 to 100 of them. A value is a
|
|
110
|
+
* value (a tenant's id), put to the exact test with every string (`carriesNoSecret`), so it is made one line here
|
|
111
|
+
* without the quarter rule of free text, which a thousand random ids would trip by chance (PR 609's review). */
|
|
112
|
+
function attributesOf(v) {
|
|
113
|
+
if (!obj(v) || Object.keys(v).length > 64)
|
|
114
|
+
return null;
|
|
115
|
+
const outv = {};
|
|
116
|
+
for (const [k, x] of Object.entries(v)) {
|
|
117
|
+
if (!ATTRIBUTE_NAME_RE.test(k))
|
|
118
|
+
return null;
|
|
119
|
+
if (typeof x === "string")
|
|
120
|
+
outv[k] = serviceText(x, 1000);
|
|
121
|
+
else if (Array.isArray(x) && x.length >= 1 && x.length <= 100 && x.every((y) => typeof y === "string"))
|
|
122
|
+
outv[k] = x.map((y) => serviceText(y, 1000));
|
|
123
|
+
else
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return outv;
|
|
127
|
+
}
|
|
128
|
+
const names = (v) => Array.isArray(v) && v.length >= 1 && v.length <= 64 && v.every((x) => typeof x === "string" && ATTRIBUTE_NAME_RE.test(x));
|
|
129
|
+
const timeOrNull = (v) => v === null || isServiceTime(v);
|
|
130
|
+
/** One row of a list, as `listUsers` makes it (src/cli/user.ts `UserRow`); null when it is not that shape. */
|
|
131
|
+
function rowOf(v) {
|
|
132
|
+
if (!obj(v))
|
|
133
|
+
return null;
|
|
134
|
+
const u = v;
|
|
135
|
+
if (typeof u.name !== "string" || !USER_NAME_RE.test(u.name) || typeof u.id !== "string" || !ID.test(u.id) || !isServiceTime(u.addedAt)
|
|
136
|
+
|| !timeOrNull(u.removedAt) || !timeOrNull(u.disabledAt) || !timeOrNull(u.lastUsedAt) || typeof u.live !== "boolean")
|
|
137
|
+
return null;
|
|
138
|
+
// the URL a tool pastes, WITHOUT the password: its own name, then where the replica listens
|
|
139
|
+
if (u.url !== null && !(typeof u.url === "string" && u.url.length <= 600 && u.url.startsWith(`postgres://${u.name}@`)
|
|
140
|
+
&& /^postgres:\/\/[a-z0-9_.-]+@(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}\/[A-Za-z0-9%._~!*'()-]+(?:\?sslmode=require)?$/.test(u.url)))
|
|
141
|
+
return null;
|
|
142
|
+
const role = roleOf(u);
|
|
143
|
+
if (role === null)
|
|
144
|
+
return null;
|
|
145
|
+
const attributes = u.attributes === undefined ? undefined : attributesOf(u.attributes);
|
|
146
|
+
if (attributes === null)
|
|
147
|
+
return null;
|
|
148
|
+
if (u.missingAttributes !== undefined && !names(u.missingAttributes))
|
|
149
|
+
return null;
|
|
150
|
+
if (u.roleKnown !== undefined && (typeof u.roleKnown !== "boolean" || role.roleFrom !== "file"))
|
|
151
|
+
return null;
|
|
152
|
+
return { name: u.name, id: u.id, addedAt: u.addedAt, removedAt: u.removedAt, disabledAt: u.disabledAt, live: u.live, lastUsedAt: u.lastUsedAt,
|
|
153
|
+
url: u.url, ...role, ...(attributes !== undefined ? { attributes } : {}),
|
|
154
|
+
...(u.missingAttributes !== undefined ? { missingAttributes: u.missingAttributes } : {}),
|
|
155
|
+
...(typeof u.roleKnown === "boolean" ? { roleKnown: u.roleKnown } : {}) };
|
|
156
|
+
}
|
|
157
|
+
/** `GET …/users?lastUsed=1`'s answer: mason user list --json's shape (its `next` never read); null when it is not it. */
|
|
158
|
+
export function heldList(json, secrets) {
|
|
159
|
+
if (!obj(json) || !Array.isArray(json.users) || json.users.length > MAX_USERS || !carriesNoSecret(json, secrets))
|
|
160
|
+
return null;
|
|
161
|
+
const users = [];
|
|
162
|
+
for (const v of json.users) {
|
|
163
|
+
const u = rowOf(v);
|
|
164
|
+
if (u === null)
|
|
165
|
+
return null;
|
|
166
|
+
users.push(u);
|
|
167
|
+
}
|
|
168
|
+
const note = (v) => v === null ? null : line(v, 500, secrets) ?? undefined;
|
|
169
|
+
const lastUsedNote = note(json.lastUsedNote), urlNote = note(json.urlNote);
|
|
170
|
+
const days = json.requestLogDays;
|
|
171
|
+
if (lastUsedNote === undefined || urlNote === undefined || !(days === null || (Number.isInteger(days) && days >= 1 && days <= 36500)))
|
|
172
|
+
return null;
|
|
173
|
+
return { users, lastUsedNote, urlNote, requestLogDays: days };
|
|
174
|
+
}
|
|
175
|
+
/** `POST …/users`'s answer: mason user add --json's shape with Mason up — where it listens, the URL rebuilt here by the
|
|
176
|
+
* one builder and equal to the service's —, for the name asked; null when it is not it. */
|
|
177
|
+
export function heldAdded(json, asked, secrets) {
|
|
178
|
+
if (!obj(json) || !carriesNoSecret(json, secrets, ["password", "url", "psql"]))
|
|
179
|
+
return null;
|
|
180
|
+
const a = json;
|
|
181
|
+
if (a.user !== asked || typeof a.id !== "string" || !ID.test(a.id) || !isServiceTime(a.addedAt))
|
|
182
|
+
return null;
|
|
183
|
+
// the password is the user's, by its one grammar: its id, then its MAC
|
|
184
|
+
if (typeof a.password !== "string" || !new RegExp(`^mason_${a.id}_[0-9a-f]{32}$`).test(a.password))
|
|
185
|
+
return null;
|
|
186
|
+
if (typeof a.database !== "string" || databaseNameProblem(a.database) !== null || serviceText(a.database, 63) !== a.database)
|
|
187
|
+
return null;
|
|
188
|
+
if (typeof a.host !== "string" || !HOST.test(a.host) || !Number.isInteger(a.port) || a.port < 1 || a.port > 65535)
|
|
189
|
+
return null;
|
|
190
|
+
if (a.sslmode !== "require" && a.sslmode !== "disable")
|
|
191
|
+
return null;
|
|
192
|
+
const url = connectionUrl({ url: "", host: a.host, port: a.port, database: a.database, ...(a.sslmode === "require" ? { sslmode: "require" } : {}) }, asked, a.password);
|
|
193
|
+
if (a.url !== url || a.psql !== psqlLine(url))
|
|
194
|
+
return null;
|
|
195
|
+
const role = roleOf(a);
|
|
196
|
+
if (role === null)
|
|
197
|
+
return null;
|
|
198
|
+
const attributes = a.attributes === undefined ? undefined : attributesOf(a.attributes);
|
|
199
|
+
if (attributes === null)
|
|
200
|
+
return null;
|
|
201
|
+
return { user: asked, id: a.id, addedAt: a.addedAt, password: a.password, database: a.database, url, psql: psqlLine(url), host: a.host, port: a.port, sslmode: a.sslmode,
|
|
202
|
+
...role, ...(attributes !== undefined ? { attributes } : {}) };
|
|
203
|
+
}
|
|
204
|
+
/** A user as disable, enable and remove name it: the name asked, its id, when it was added, and `more` times. */
|
|
205
|
+
function userOf(v, asked, more) {
|
|
206
|
+
if (!obj(v) || v.name !== asked || typeof v.id !== "string" || !ID.test(v.id) || !isServiceTime(v.addedAt) || !more.every((k) => isServiceTime(v[k])))
|
|
207
|
+
return null;
|
|
208
|
+
return { name: asked, id: v.id, addedAt: v.addedAt, ...Object.fromEntries(more.map((k) => [k, v[k]])) };
|
|
209
|
+
}
|
|
210
|
+
export function heldDisabled(json, asked, secrets) {
|
|
211
|
+
if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
|
|
212
|
+
return null;
|
|
213
|
+
const d = userOf(json.disabled, asked, ["disabledAt"]);
|
|
214
|
+
return d === null ? null : { disabled: d, already: json.already };
|
|
215
|
+
}
|
|
216
|
+
export function heldEnabled(json, asked, secrets) {
|
|
217
|
+
if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
|
|
218
|
+
return null;
|
|
219
|
+
const e = userOf(json.enabled, asked, []);
|
|
220
|
+
return e === null ? null : { enabled: e, already: json.already };
|
|
221
|
+
}
|
|
222
|
+
export function heldRemoved(json, asked, secrets) {
|
|
223
|
+
if (!obj(json) || !carriesNoSecret(json, secrets))
|
|
224
|
+
return null;
|
|
225
|
+
const r = userOf(json.removed, asked, ["removedAt"]);
|
|
226
|
+
return r === null ? null : { removed: r };
|
|
227
|
+
}
|
|
228
|
+
// ------------------------------------------------------------------ the command
|
|
229
|
+
/** What each subcommand asked, in the words of a failure outside the contract (`unexpected`). */
|
|
230
|
+
const STEP = { list: "the replica's users were asked for", add: "a user was added", disable: "a user was disabled", enable: "a user was enabled", remove: "a user was removed" };
|
|
231
|
+
/** `mason user <verb> … --replica <org>/<name>`: one request, its answer held and printed as the local command prints it. */
|
|
232
|
+
export async function hostedUser(r, o) {
|
|
233
|
+
const outStyle = serviceStyle(styleOut());
|
|
234
|
+
const fail = (f) => failed("user", f, { json: o.json, style: serviceStyle(styleErr()) });
|
|
235
|
+
const service = serviceOf();
|
|
236
|
+
if (isFailure(service))
|
|
237
|
+
return fail(service);
|
|
238
|
+
const token = tokenFor(service, { asked: "a replica's users are asked for", again: `mason user ${o.verb}` });
|
|
239
|
+
if (isFailure(token))
|
|
240
|
+
return fail(token);
|
|
241
|
+
// from here every byte written is put to the exact test against the token (src/cli/output-gate.ts); add prints the
|
|
242
|
+
// password it made, so a password's shape is its answer's there
|
|
243
|
+
holdOutput({ command: "user", json: o.json, secrets: [token], passwords: o.verb !== "add" });
|
|
244
|
+
const secrets = [token];
|
|
245
|
+
const req = requestOf(r, o);
|
|
246
|
+
const a = await call(service, req.method, req.path, { token, ...(req.json !== undefined ? { json: req.json } : {}), ...(o.verb === "list" ? { maxBytes: MAX_LIST_BYTES } : {}) });
|
|
247
|
+
if (isFailure(a))
|
|
248
|
+
return fail(a);
|
|
249
|
+
const refused = (x) => serviceFailure(x, secrets, (status) => failureNext(status, r)) ?? unexpected(service, x, STEP[o.verb]);
|
|
250
|
+
if (a.status !== 200)
|
|
251
|
+
return fail(refused(a));
|
|
252
|
+
const outside = () => fail(unexpected(service, a, STEP[o.verb]));
|
|
253
|
+
const print = (value, next, text) => {
|
|
254
|
+
out(o.json ? jsonText(USER, value, next) : text());
|
|
255
|
+
return 0;
|
|
256
|
+
};
|
|
257
|
+
switch (o.verb) {
|
|
258
|
+
case "list": {
|
|
259
|
+
const l = heldList(a.json, secrets);
|
|
260
|
+
if (l === null)
|
|
261
|
+
return outside();
|
|
262
|
+
const next = onReplica(listNext(l.users), r);
|
|
263
|
+
return print({ ...l }, next, () => listText(replicaText(r), l, NO_ROLES, next, outStyle));
|
|
264
|
+
}
|
|
265
|
+
case "add": {
|
|
266
|
+
const added = heldAdded(a.json, o.name, secrets);
|
|
267
|
+
// the service answered 200: the replica may have made the user, whose password nobody has now been shown
|
|
268
|
+
if (added === null) {
|
|
269
|
+
const f = unexpected(service, a, STEP[o.verb]);
|
|
270
|
+
return fail({ ...f, why: `${f.why}; the replica may have added ${o.name} all the same, and its password was not shown`,
|
|
271
|
+
fix: `mason user list --replica ${replicaText(r)} says whether it did; if so, mason user remove ${o.name} --replica ${replicaText(r)}, then add it again`,
|
|
272
|
+
next: [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]] });
|
|
273
|
+
}
|
|
274
|
+
const next = onReplica(addedNext(added.user, true), r);
|
|
275
|
+
return print({ ...added }, next, () => addedReport(added, { roles: NO_ROLES, listener: null, next }, outStyle));
|
|
276
|
+
}
|
|
277
|
+
case "disable": {
|
|
278
|
+
const d = heldDisabled(a.json, o.name, secrets);
|
|
279
|
+
if (d === null)
|
|
280
|
+
return outside();
|
|
281
|
+
const next = onReplica(disabledNext(d.disabled.name), r);
|
|
282
|
+
return print({ ...d }, next, () => disabledReport(d, true, next, outStyle));
|
|
283
|
+
}
|
|
284
|
+
case "enable": {
|
|
285
|
+
const e = heldEnabled(a.json, o.name, secrets);
|
|
286
|
+
if (e === null)
|
|
287
|
+
return outside();
|
|
288
|
+
const next = onReplica(enabledNext(e.enabled.name), r);
|
|
289
|
+
return print({ ...e }, next, () => enabledReport(e, next, outStyle));
|
|
290
|
+
}
|
|
291
|
+
case "remove": {
|
|
292
|
+
const x = heldRemoved(a.json, o.name, secrets);
|
|
293
|
+
if (x === null)
|
|
294
|
+
return outside();
|
|
295
|
+
const next = onReplica(REMOVED_NEXT, r);
|
|
296
|
+
return print({ ...x }, next, () => removedReport(x, true, next, outStyle));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
package/dist/src/cli/user.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* called by the run's command server; so are `disableUser` and `enableUser`.
|
|
12
12
|
*/
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import { failed, jsonText, parseCommand, reportText, withEnd } from "./command.js";
|
|
14
|
+
import { failed, jsonText, parseCommand, reportText, strict, withEnd } from "./command.js";
|
|
15
15
|
import { reach } from "./state.js";
|
|
16
16
|
import { callRun, RunUnreachable } from "./control.js";
|
|
17
17
|
import { connectionUrl, listenerTips, postgresLink, psqlLine } from "./pg-url.js";
|
|
@@ -24,16 +24,18 @@ import { ATTRIBUTE_NAME_RE, ATTRIBUTE_NAME_RULE, whereAttributes, whereParts } f
|
|
|
24
24
|
import { readSource, shownPath } from "./sources.js";
|
|
25
25
|
import { readState } from "./commands.js";
|
|
26
26
|
import { projectDatabase } from "./project.js";
|
|
27
|
+
import { replicaOf } from "./replica.js";
|
|
28
|
+
import { hostedUser } from "./user-hosted.js";
|
|
27
29
|
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
28
30
|
export const USER = {
|
|
29
31
|
needs: "project",
|
|
30
32
|
name: "user",
|
|
31
33
|
summary: "the users the Postgres API takes: a name and a password that does not expire, for a BI tool or an app",
|
|
32
|
-
usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--json]
|
|
33
|
-
mason user list [--json]
|
|
34
|
-
mason user disable <name> [--json]
|
|
35
|
-
mason user enable <name> [--json]
|
|
36
|
-
mason user remove <name> [--json]
|
|
34
|
+
usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--replica <org>/<name>] [--json]
|
|
35
|
+
mason user list [--replica <org>/<name>] [--json]
|
|
36
|
+
mason user disable <name> [--replica <org>/<name>] [--json]
|
|
37
|
+
mason user enable <name> [--replica <org>/<name>] [--json]
|
|
38
|
+
mason user remove <name> [--replica <org>/<name>] [--json]
|
|
37
39
|
|
|
38
40
|
A user of this project's Postgres API: a name and a password, as a BI tool's datasource form asks for them
|
|
39
41
|
(Metabase, Grafana, Superset, Power BI) or an app's DATABASE_URL carries them. Its password does not expire:
|
|
@@ -91,6 +93,14 @@ export const USER = {
|
|
|
91
93
|
mason user enable metabase metabase connects again, with the same password
|
|
92
94
|
mason user remove metabase metabase connects no more
|
|
93
95
|
docker compose exec mason mason user add grafana beside mason serve, in its data folder
|
|
96
|
+
mason user add metabase --replica acme/shop a user of a replica on a hosted Mason service
|
|
97
|
+
|
|
98
|
+
--replica <org>/<name> manages the users of a replica on a hosted Mason service instead of this project's (else
|
|
99
|
+
MASON_REPLICA; each part lower case letters, digits and -): the same subcommands, --role and --set, the same
|
|
100
|
+
output and --json, the replica's Mason checking each as it does here. It needs no project here, and reads and writes
|
|
101
|
+
nothing of one. The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else
|
|
102
|
+
the default. A refusal is the service's words, each one line, its code the CLI's own by the HTTP status (not_found,
|
|
103
|
+
refused, failed: never usage), exit 1.
|
|
94
104
|
|
|
95
105
|
<name> ${USER_NAME_RULE}
|
|
96
106
|
--json as data: add { user (the name), id, addedAt, password, database, role (the role it reads as: its own
|
|
@@ -106,7 +116,7 @@ export const USER = {
|
|
|
106
116
|
before) }; enable { enabled: { name, id, addedAt }, already (it was not disabled) }; remove { removed: { name,
|
|
107
117
|
id, addedAt, removedAt } }; next
|
|
108
118
|
`,
|
|
109
|
-
options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true } },
|
|
119
|
+
options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true }, replica: { type: "string" } },
|
|
110
120
|
json: ["user", "id", "addedAt", "password", "url", "psql", "host", "port", "database", "sslmode", "role", "attributes", "roleFrom", "readsNothing", "users", "disabled", "enabled", "already", "removed", "lastUsedNote", "urlNote", "requestLogDays", "next"],
|
|
111
121
|
};
|
|
112
122
|
/** The users of a project and why one is not live: added under an API key since rotated. */
|
|
@@ -194,6 +204,9 @@ export function attributesToAdd(name, role, sets, live) {
|
|
|
194
204
|
return { values: out };
|
|
195
205
|
}
|
|
196
206
|
// ------------------------------------------------------------------ the role a user is added with
|
|
207
|
+
/** Why `role` is not a role's name (a usage failure's message), or null: asked by `roleToAdd`, and by `mason user add
|
|
208
|
+
* --role --replica` before a byte is sent. */
|
|
209
|
+
export const roleNameProblem = (role) => ROLE_NAME_RE.test(role) ? null : `${JSON.stringify(role)} is not a role name: ${ROLE_NAME_RULE}`;
|
|
197
210
|
/**
|
|
198
211
|
* 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
212
|
* 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
|
|
@@ -202,8 +215,9 @@ export function attributesToAdd(name, role, sets, live) {
|
|
|
202
215
|
* that reads nothing by mistake — one whose role is removed LATER is refused at its next statement (fail closed).
|
|
203
216
|
*/
|
|
204
217
|
export function roleToAdd(role, live) {
|
|
205
|
-
|
|
206
|
-
|
|
218
|
+
const named = roleNameProblem(role);
|
|
219
|
+
if (named !== null)
|
|
220
|
+
return { message: named, code: "usage" };
|
|
207
221
|
const file = live.file ?? `mason/${ROLES_FILE}`;
|
|
208
222
|
if (live.problem !== null) {
|
|
209
223
|
return { message: `${file} cannot be read as it is now, so no user can be added with a role: ${live.problem}`, code: "bad_roles",
|
|
@@ -484,6 +498,91 @@ export function enableUser(file, name) {
|
|
|
484
498
|
const u = found;
|
|
485
499
|
return { enabled: { name: u.name, id: u.id, addedAt: u.addedAt }, already: !Array.isArray(r) };
|
|
486
500
|
}
|
|
501
|
+
/** What to run after a list. */
|
|
502
|
+
export const listNext = (users) => [["mason user add <name>", "a user for a tool: its password and URL, printed once"],
|
|
503
|
+
...(users.some((u) => u.removedAt === null && u.disabledAt !== null) ? [["mason user enable <name>", "a disabled user connects again, with the same password"]] : []),
|
|
504
|
+
...(users.some((u) => u.removedAt === null) ? [["mason user remove <name>", "that user connects no more"]] : [])];
|
|
505
|
+
/** `mason user list` for a person: the users of `of` (the project's name, or the replica's), a line each, then its tips
|
|
506
|
+
* and `next`. `roles`: the roles as a command beside them sees them (NO_ROLES where none can be read here). */
|
|
507
|
+
export function listText(of, l, roles, next, s = styleOut()) {
|
|
508
|
+
const { users, lastUsedNote, requestLogDays } = l;
|
|
509
|
+
if (!users.length)
|
|
510
|
+
return strict(withEnd(`${s.bold(`No users yet for ${of}.`)}`, { next }, s), s);
|
|
511
|
+
const active = users.filter((u) => u.removedAt === null);
|
|
512
|
+
const gone = users.filter((u) => u.removedAt !== null);
|
|
513
|
+
const width = Math.max(...users.map((u) => u.name.length));
|
|
514
|
+
const rolesFileShown = roles.file ?? `mason/${ROLES_FILE}`;
|
|
515
|
+
// the last use in the words the web page's Users page says it too (lastUsedText): "last used" before a time or "unknown"
|
|
516
|
+
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}`; };
|
|
517
|
+
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)}`)}` +
|
|
518
|
+
(u.disabledAt !== null ? ` ${s.yellow(`${disabledText(u.disabledAt)}: does not connect until mason user enable ${u.name}`)}`
|
|
519
|
+
: u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`) +
|
|
520
|
+
(u.roleKnown === false ? ` ${s.yellow(`its role ${u.role} is not in the roles file: its statements are refused`)}` : "") +
|
|
521
|
+
(u.readsNothing === true ? ` ${s.yellow(`has no role, and ${rolesFileShown} names no default: its statements are refused`)}` : "") +
|
|
522
|
+
(u.missingAttributes !== undefined ? ` ${s.yellow(`has no value for ${u.missingAttributes.join(", ")}: its statements are refused`)}` : ""));
|
|
523
|
+
const disabled = active.filter((u) => u.disabledAt !== null);
|
|
524
|
+
const removedLines = gone.map((u) => ` ${s.dim(`${u.name.padEnd(width)} id ${u.id} · added ${stamp(u.addedAt)} UTC · removed ${stamp(u.removedAt)} UTC`)}`);
|
|
525
|
+
const rotated = active.filter((u) => !u.live && u.disabledAt === null);
|
|
526
|
+
const orphan = active.find((u) => u.roleKnown === false);
|
|
527
|
+
const roleless = active.find((u) => u.readsNothing === true);
|
|
528
|
+
const unset = active.find((u) => u.missingAttributes !== undefined);
|
|
529
|
+
return strict(withEnd(`${s.bold(`${active.length} user${active.length === 1 ? "" : "s"} of ${of}${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}.`] : []),
|
|
530
|
+
...(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>.`] : []),
|
|
531
|
+
...(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 })}.`] : []),
|
|
532
|
+
...(roleless ? [`A user with no role reads nothing while ${rolesFileShown} names no default: ${noRoleWayOut(roleless.name, rolesFileShown, exampleRole(roles))}.`] : []),
|
|
533
|
+
...(roles.problem !== null && active.length ? [rolesProblemText(rolesFileShown, roles.problem)] : []),
|
|
534
|
+
...(lastUsedNote !== null ? [`Last used: ${lastUsedNote}.`] : [])], next }, s), s);
|
|
535
|
+
}
|
|
536
|
+
/** `mason user disable` for a person. `up`: Mason runs, so the sessions the user holds end at their next statement. */
|
|
537
|
+
export const disabledReport = (r, up, next, s = styleOut()) => {
|
|
538
|
+
const d = r.disabled;
|
|
539
|
+
return reportText({ headline: r.already ? `The user ${d.name} is disabled already: nothing changed` : `The user ${d.name} disabled: it connects no more, until enabled`,
|
|
540
|
+
details: [["user", d.name], ["disabled", d.disabledAt], ...(r.already ? [] : [["sessions", up ? "those it holds end at their next statement" : "none: Mason is not running"]])],
|
|
541
|
+
tips: [disableEffect(d.name)], next }, s);
|
|
542
|
+
};
|
|
543
|
+
/** `mason user enable` for a person. */
|
|
544
|
+
export const enabledReport = (r, next, s = styleOut()) => {
|
|
545
|
+
const e = r.enabled;
|
|
546
|
+
return 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`,
|
|
547
|
+
details: [["user", e.name], ["added", e.addedAt]], next }, s);
|
|
548
|
+
};
|
|
549
|
+
/** `mason user remove` for a person. */
|
|
550
|
+
export const removedReport = (r, up, next, s = styleOut()) => {
|
|
551
|
+
const gone = r.removed;
|
|
552
|
+
return reportText({ headline: `The user ${gone.name} removed: it connects no more`,
|
|
553
|
+
details: [["user", gone.name], ["added", gone.addedAt], ["sessions", up ? "those it holds end at their next statement" : "none: Mason is not running"]],
|
|
554
|
+
tips: [`Adding ${gone.name} again makes a new password: the old one never connects again.`], next }, s);
|
|
555
|
+
};
|
|
556
|
+
/**
|
|
557
|
+
* `mason user add` for a person: the password, printed this once, in the url, psql and password rows (and nowhere else).
|
|
558
|
+
* The url, psql, host, port and ssl mode rows are there when the answer has them (Mason runs, so where it listens is
|
|
559
|
+
* known). `roles`: the roles as a command beside them sees them (the file's name in the role row; NO_ROLES where none
|
|
560
|
+
* can be read here); `listener`: how this run listens (its tips), null where it is not known here.
|
|
561
|
+
*/
|
|
562
|
+
export function addedReport(r, o, s = styleOut()) {
|
|
563
|
+
const { roles } = o;
|
|
564
|
+
return reportText({
|
|
565
|
+
headline: `The user ${r.user} added${r.replaced === true ? " (in place of one the API key's rotation had removed)" : ""}: its password is printed this once`,
|
|
566
|
+
details: [
|
|
567
|
+
...(r.url !== undefined && r.psql !== undefined ? [["url", r.url], ["psql", r.psql]] : []),
|
|
568
|
+
["user", r.user], ["password", r.password], ["database", r.database],
|
|
569
|
+
...(r.attributes !== undefined ? [["attributes", attributesText(r.attributes)]] : []),
|
|
570
|
+
...(r.roleFrom === "file" ? [["role", `${r.role} (mason/${ROLES_FILE} names what it reads)`]]
|
|
571
|
+
: r.roleFrom === "default" ? [["role", `${r.role} (the file's default: ${roles.file ?? `mason/${ROLES_FILE}`})`]]
|
|
572
|
+
: r.readsNothing === true ? [["role", `none, and ${roles.file ?? `mason/${ROLES_FILE}`} names no default: it reads nothing until one is written`]] : []),
|
|
573
|
+
...(r.host !== undefined && r.port !== undefined ? [["host", r.host], ["port", String(r.port)], ...(r.sslmode !== undefined && r.sslmode !== "disable" ? [["ssl mode", r.sslmode]] : [])] : []),
|
|
574
|
+
],
|
|
575
|
+
tips: [
|
|
576
|
+
"Paste it into the tool now: Mason keeps no copy of the password. Lost, remove the user and add it again.",
|
|
577
|
+
...(r.readsNothing === true ? [`Its statements are refused until it has a role: ${noRoleWayOut(r.user, roles.file ?? `mason/${ROLES_FILE}`, exampleRole(roles))}.`] : []),
|
|
578
|
+
...(r.url === undefined ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
|
|
579
|
+
...listenerTips(o.listener),
|
|
580
|
+
"It never expires: mason user remove ends it, at once and for every session it holds.",
|
|
581
|
+
"Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.",
|
|
582
|
+
],
|
|
583
|
+
next: o.next,
|
|
584
|
+
}, s);
|
|
585
|
+
}
|
|
487
586
|
// ------------------------------------------------------------------ the command
|
|
488
587
|
export async function user(argv) {
|
|
489
588
|
const f = parseCommand(USER, argv);
|
|
@@ -515,6 +614,17 @@ export async function user(argv) {
|
|
|
515
614
|
if (problem !== null)
|
|
516
615
|
return usage(problem);
|
|
517
616
|
}
|
|
617
|
+
// A REPLICA ON A HOSTED MASON SERVICE (--replica, else MASON_REPLICA): its users, asked of the service with the same
|
|
618
|
+
// request the run's /api/users takes, printed by the renderers above; nothing of a project here is read or written
|
|
619
|
+
const replica = replicaOf(typeof f.values.replica === "string" ? f.values.replica : undefined, USER.usage);
|
|
620
|
+
if (replica !== null && "message" in replica)
|
|
621
|
+
return failed("user", replica, { json });
|
|
622
|
+
if (replica !== null) {
|
|
623
|
+
const named = role === undefined ? null : roleNameProblem(role);
|
|
624
|
+
if (named !== null)
|
|
625
|
+
return usage(named);
|
|
626
|
+
return hostedUser(replica, { verb: verb, name, role, sets, json });
|
|
627
|
+
}
|
|
518
628
|
const w = await reach(USER, json);
|
|
519
629
|
if (w === null || w.project === null)
|
|
520
630
|
return 1;
|
|
@@ -551,42 +661,12 @@ export async function user(argv) {
|
|
|
551
661
|
}
|
|
552
662
|
}
|
|
553
663
|
const urlNote = up !== null ? null : NOT_RUNNING_URL;
|
|
554
|
-
const next =
|
|
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"]] : [])];
|
|
664
|
+
const next = listNext(users);
|
|
557
665
|
if (json) {
|
|
558
666
|
out(jsonText(USER, { users, lastUsedNote, urlNote, requestLogDays }, next));
|
|
559
667
|
return 0;
|
|
560
668
|
}
|
|
561
|
-
|
|
562
|
-
if (!users.length) {
|
|
563
|
-
out(withEnd(`${s.bold(`No users yet for ${project.config.name}.`)}`, { next }));
|
|
564
|
-
return 0;
|
|
565
|
-
}
|
|
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 }));
|
|
669
|
+
out(listText(project.config.name, { users, lastUsedNote, urlNote, requestLogDays }, roles, next));
|
|
590
670
|
return 0;
|
|
591
671
|
}
|
|
592
672
|
if (verb === "disable") {
|
|
@@ -598,10 +678,7 @@ export async function user(argv) {
|
|
|
598
678
|
out(jsonText(USER, r, next));
|
|
599
679
|
return 0;
|
|
600
680
|
}
|
|
601
|
-
|
|
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 }));
|
|
681
|
+
out(disabledReport(r, w.state === "up", next));
|
|
605
682
|
return 0;
|
|
606
683
|
}
|
|
607
684
|
if (verb === "enable") {
|
|
@@ -613,24 +690,19 @@ export async function user(argv) {
|
|
|
613
690
|
out(jsonText(USER, r, next));
|
|
614
691
|
return 0;
|
|
615
692
|
}
|
|
616
|
-
|
|
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 }));
|
|
693
|
+
out(enabledReport(r, next));
|
|
619
694
|
return 0;
|
|
620
695
|
}
|
|
621
696
|
if (verb === "remove") {
|
|
622
697
|
const r = removeUser(file, name);
|
|
623
698
|
if ("message" in r)
|
|
624
699
|
return failed("user", r, { json });
|
|
625
|
-
const gone = r.removed;
|
|
626
700
|
const next = REMOVED_NEXT;
|
|
627
701
|
if (json) {
|
|
628
702
|
out(jsonText(USER, r, next));
|
|
629
703
|
return 0;
|
|
630
704
|
}
|
|
631
|
-
out(
|
|
632
|
-
details: [["user", gone.name], ["added", gone.addedAt], ["sessions", w.state === "up" ? "those it holds end at their next statement" : "none: Mason is not running"]],
|
|
633
|
-
tips: [`Adding ${gone.name} again makes a new password: the old one never connects again.`], next }));
|
|
705
|
+
out(removedReport(r, w.state === "up", next));
|
|
634
706
|
return 0;
|
|
635
707
|
}
|
|
636
708
|
// add
|
|
@@ -654,32 +726,12 @@ export async function user(argv) {
|
|
|
654
726
|
const r = addUser(file, apiKey, name, { link, database, roles, ...(role !== undefined ? { role } : {}), attributes: attributes.values });
|
|
655
727
|
if ("message" in r)
|
|
656
728
|
return failed("user", r, { json });
|
|
657
|
-
const { replaced, ...added } = r;
|
|
729
|
+
const { replaced: _, ...added } = r;
|
|
658
730
|
const next = addedNext(r.user, link !== null);
|
|
659
731
|
if (json) {
|
|
660
732
|
out(jsonText(USER, added, next));
|
|
661
733
|
return 0;
|
|
662
734
|
}
|
|
663
|
-
out(
|
|
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`,
|
|
665
|
-
details: [
|
|
666
|
-
...(r.url !== undefined && r.psql !== undefined ? [["url", r.url], ["psql", r.psql]] : []),
|
|
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`]] : []),
|
|
672
|
-
...(link !== null ? [["host", link.host], ["port", String(link.port)], ...(link.sslmode ? [["ssl mode", link.sslmode]] : [])] : []),
|
|
673
|
-
],
|
|
674
|
-
tips: [
|
|
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))}.`] : []),
|
|
677
|
-
...(link === null ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
|
|
678
|
-
...listenerTips(run?.postgresListener ?? null),
|
|
679
|
-
"It never expires: mason user remove ends it, at once and for every session it holds.",
|
|
680
|
-
"Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.",
|
|
681
|
-
],
|
|
682
|
-
next,
|
|
683
|
-
}));
|
|
735
|
+
out(addedReport(r, { roles, listener: run?.postgresListener ?? null, next }));
|
|
684
736
|
return 0;
|
|
685
737
|
}
|