@mason-data/cli 0.9.0 → 0.11.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.
Files changed (56) hide show
  1. package/README.md +6 -4
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/proxy.js +6 -160
  4. package/dist/src/api/postgres/server.js +22 -4
  5. package/dist/src/cli/command.js +169 -5
  6. package/dist/src/cli/commands.js +295 -159
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/dev.js +21 -5
  9. package/dist/src/cli/doctor.js +14 -11
  10. package/dist/src/cli/guides.js +20 -11
  11. package/dist/src/cli/hosted.js +1 -1
  12. package/dist/src/cli/init.js +110 -25
  13. package/dist/src/cli/login.js +20 -17
  14. package/dist/src/cli/machine.js +125 -40
  15. package/dist/src/cli/main.js +51 -8
  16. package/dist/src/cli/mcp.js +15 -12
  17. package/dist/src/cli/project.js +25 -2
  18. package/dist/src/cli/registry.js +80 -35
  19. package/dist/src/cli/replica-hosted.js +761 -102
  20. package/dist/src/cli/replica.js +43 -5
  21. package/dist/src/cli/replication-mode.js +5 -0
  22. package/dist/src/cli/request-filter.js +16 -6
  23. package/dist/src/cli/run-server.js +70 -31
  24. package/dist/src/cli/secret-input.js +193 -0
  25. package/dist/src/cli/security.js +7 -2
  26. package/dist/src/cli/serve.js +119 -9
  27. package/dist/src/cli/share.js +20 -15
  28. package/dist/src/cli/source-role.js +14 -15
  29. package/dist/src/cli/sources.js +20 -3
  30. package/dist/src/cli/style.js +14 -0
  31. package/dist/src/cli/sync.js +17 -8
  32. package/dist/src/cli/token-hosted.js +30 -24
  33. package/dist/src/cli/tunnel.js +1 -1
  34. package/dist/src/cli/ui-hosted.js +163 -0
  35. package/dist/src/cli/ui-read-only.js +23 -0
  36. package/dist/src/cli/ui-run-file.js +247 -0
  37. package/dist/src/cli/ui-socket.js +183 -0
  38. package/dist/src/cli/ui-ssh.js +3 -3
  39. package/dist/src/cli/ui.js +67 -26
  40. package/dist/src/cli/usage.js +19 -15
  41. package/dist/src/cli/user-hosted.js +78 -13
  42. package/dist/src/cli/user.js +168 -50
  43. package/dist/src/duck/users.js +105 -9
  44. package/dist/src/lib/cidr.js +177 -0
  45. package/dist/src/lib/table-sync.js +69 -19
  46. package/dist/src/web/app.css +5 -0
  47. package/dist/src/web/app.js +4 -4
  48. package/dist/src/web/index.html +5 -8
  49. package/dist/src/web/pages/queries.js +3 -3
  50. package/dist/src/web/pages/status.js +23 -17
  51. package/dist/src/web/pages/syncs.js +3 -3
  52. package/dist/src/web/pages/tables.js +18 -15
  53. package/dist/src/web/pages/usage.js +16 -12
  54. package/dist/src/web/pages/users.js +45 -14
  55. package/dist/src/web/shared.js +10 -4
  56. package/package.json +6 -6
@@ -11,11 +11,11 @@
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, strict, withEnd } from "./command.js";
14
+ import { failed, jsonText, listFlag, 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";
18
- import { changeUsers, isActive, isDisabled, 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, userRanges, USER_NAME_RE, USER_NAME_RULE, USERS_FILE } from "../duck/users.js";
19
19
  import { didYouMean } from "../lib/did-you-mean.js";
20
20
  import { out as styleOut } from "./style.js";
21
21
  import { NO_ROLES } from "../duck/grants.js";
@@ -31,7 +31,8 @@ export const USER = {
31
31
  needs: "project",
32
32
  name: "user",
33
33
  summary: "the users the Postgres API takes: a name and a password that does not expire, for a BI tool or an app",
34
- usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--replica <org>/<name>] [--json]
34
+ usage: `usage: mason user add <name> [--role <role>] [--set <attribute>=<value> …] [--from <range> …] [--replica <org>/<name>] [--json]
35
+ mason user from <name> <range> … | --any [--replica <org>/<name>] [--json]
35
36
  mason user list [--replica <org>/<name>] [--json]
36
37
  mason user disable <name> [--replica <org>/<name>] [--json]
37
38
  mason user enable <name> [--replica <org>/<name>] [--json]
@@ -44,6 +45,28 @@ export const USER = {
44
45
  The user name a tool sends must be the user's own. A user disabled is kept with its password: its logins are
45
46
  refused (as Postgres refuses a role without LOGIN) and its sessions end at their next statement, until enable.
46
47
 
48
+ mason user add metabase a user for Metabase: its password and the URL to paste
49
+ mason user add looker --from 203.0.113.0/24 --from 2001:db8:7::/48 connects from these alone
50
+ mason user from looker 198.51.100.0/24 its ranges replaced
51
+ mason user from looker --any it connects from any address again
52
+ mason user add agent --role agent a user held to the role agent
53
+ mason user add acme --role tenant --set tenant=42 the rows of tenant 42
54
+ mason user add emea --role region --set regions=eu --set regions=uk
55
+ mason user list
56
+ mason user disable metabase metabase connects no more, until mason user enable metabase
57
+ mason user enable metabase metabase connects again, with the same password
58
+ mason user remove metabase metabase connects no more
59
+ docker compose exec mason mason user add grafana beside mason serve, in its data folder
60
+ mason user add metabase --replica acme/shop a user of a replica on a hosted Mason service
61
+
62
+ <name> ${USER_NAME_RULE}
63
+ --role <role> add: what the user reads, a role of mason/roles.yml (below)
64
+ --set <attribute>=<value> add: its value of an attribute its role declares (below)
65
+ --from <range> add: the addresses it connects from (below)
66
+ --any from: its ranges removed, so it connects from any address
67
+ --replica <org>/<name> a replica's users on a hosted Mason service, not this project's (below)
68
+ --json as data: its shape is at the end
69
+
47
70
  The list is .mason/users.json (names and ids, no password), beside the running Mason, which reads it again by itself.
48
71
  A removed user stays in it, marked removed, so the list shows who was removed and when; its name can be added again.
49
72
  Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.
@@ -84,40 +107,42 @@ export const USER = {
84
107
  statement, never answered with no rows: add it again with the --set. Postgres refuses a where: it cannot read
85
108
  when the running Mason reads the file, at its key.
86
109
 
87
- mason user add metabase a user for Metabase: its password and the URL to paste
88
- mason user add agent --role agent a user held to the role agent
89
- mason user add acme --role tenant --set tenant=42 the rows of tenant 42
90
- mason user add emea --role region --set regions=eu --set regions=uk
91
- mason user list
92
- mason user disable metabase metabase connects no more, until mason user enable metabase
93
- mason user enable metabase metabase connects again, with the same password
94
- mason user remove metabase metabase connects no more
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
110
+ --from <range> limits the addresses the user connects from (repeat --from for each, or a comma list): an IPv4 or IPv6 range with its
111
+ prefix length (10.0.0.0/16, 2001:db8::/32) or one address (192.0.2.10). A connection from any other address is
112
+ refused before its password is checked, as Postgres refuses one no pg_hba.conf line takes (28000, no entry for host
113
+ …), and in the same words for a name that is no user while any user has ranges, so a user with ranges cannot be
114
+ told from a missing one (a user without ranges still answers a wrong password 28P01). An address that cannot be read (a public share's socket) is refused too. Behind a TCP proxy the address is
115
+ the client its PROXY header carries (MASON_PG_PROXY_FROM). A user added without --from connects from any address.
116
+ mason user from <name> <range> … replaces a user's ranges, and mason user from <name> --any removes them; the sessions
117
+ it holds from an address outside its new ranges end at their next statement.
97
118
 
98
119
  --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
120
+ MASON_REPLICA, said on stderr when it chose; each part lower case letters, digits and -): the same subcommands,
121
+ --role, --set and --from, the same output and --json, which names the replica in its headline and its replica key,
122
+ the replica's Mason checking each as it does here. It needs no project here, and reads and writes
101
123
  nothing of one. The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else
102
124
  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
125
  refused, failed: never usage), exit 1.
104
126
 
105
- <name> ${USER_NAME_RULE}
106
- --json as data: add { user (the name), id, addedAt, password, database, role (the role it reads as: its own
107
- 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;
108
- none), readsNothing (true: no role under a file with no default), and once Mason runs url, psql (the
109
- psql line for url), host, port, sslmode }; list { users: [{ name, id, addedAt, removedAt (null for a user
110
- not removed), disabledAt (null for a user not disabled), live (connects now), role?, attributes?,
111
- missingAttributes? (the attributes its role's filters read that it lacks: refused until added again), roleFrom, readsNothing?,
112
- roleKnown? (the roles file names its own role now), lastUsedAt (its newest statement, null when none within
113
- requestLogDays), url (to paste, without the password; null for a removed user or when Mason is not running) }],
114
- lastUsedNote (why lastUsedAt is unknown, else null), urlNote (why url is null, else null), requestLogDays
115
- (null when unknown) }; disable { disabled: { name, id, addedAt, disabledAt }, already (it was disabled
116
- before) }; enable { enabled: { name, id, addedAt }, already (it was not disabled) }; remove { removed: { name,
117
- id, addedAt, removedAt } }; next
127
+ The --json answer: add { user (the name), id, addedAt, password, database, role (the role it reads as: its own
128
+ 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;
129
+ none), readsNothing (true: no role under a file with no default), from? (its address ranges, as given
130
+ to --from in their one form; absent: any address), and once Mason runs url, psql (the
131
+ psql line for url), host, port, sslmode }; list { users: [{ name, id, addedAt, removedAt (null for a user
132
+ not removed), disabledAt (null for a user not disabled), live (connects now), from (its address ranges;
133
+ null: any address), role?, attributes?,
134
+ missingAttributes? (the attributes its role's filters read that it lacks: refused until added again), roleFrom, readsNothing?,
135
+ roleKnown? (the roles file names its own role now), lastUsedAt (its newest statement, null when none within
136
+ requestLogDays), url (to paste, without the password; null for a removed user or when Mason is not running) }],
137
+ lastUsedNote (why lastUsedAt is unknown, else null), urlNote (why url is null, else null), requestLogDays
138
+ (null when unknown) }; disable { disabled: { name, id, addedAt, disabledAt }, already (it was disabled
139
+ before) }; enable { enabled: { name, id, addedAt }, already (it was not disabled) }; from { changed: { name, id,
140
+ addedAt, from (its ranges now; null: any address) }, already (they were these before) }; remove { removedUser: { name,
141
+ id, addedAt, removedAt }, removed (the same, kept until 1.0) }; next. With --replica (or MASON_REPLICA) every answer also has replica ("<org>/<name>":
142
+ the replica it acted on), and a project's never does
118
143
  `,
119
- options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true }, replica: { type: "string" } },
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"],
144
+ options: { json: { type: "boolean", default: false }, role: { type: "string" }, set: { type: "string", multiple: true }, from: { type: "string", multiple: true }, any: { type: "boolean", default: false }, replica: { type: "string" } },
145
+ json: ["replica", "user", "id", "addedAt", "password", "url", "psql", "host", "port", "database", "sslmode", "role", "attributes", "roleFrom", "readsNothing", "users", "disabled", "enabled", "already", "removedUser", "removed", "from", "changed", "lastUsedNote", "urlNote", "requestLogDays", "next"],
121
146
  };
122
147
  /** The users of a project and why one is not live: added under an API key since rotated. */
123
148
  const liveOf = (u, apiKey) => underKey(u, apiKey);
@@ -339,7 +364,7 @@ export function listUsers(file, apiKey, link, roles = NO_ROLES) {
339
364
  fix: `repair ${file}, or move it aside, which removes every user` };
340
365
  // roleKnown only where the file can be read: under a problem the listener refuses every user held to it for the FILE
341
366
  return { users: r.users.map((u) => ({ name: u.name, id: u.id, addedAt: u.addedAt, removedAt: u.removedAt ?? null, disabledAt: u.disabledAt ?? null,
342
- live: isActive(u) && !isDisabled(u) && liveOf(u, apiKey),
367
+ live: isActive(u) && !isDisabled(u) && liveOf(u, apiKey), from: u.from ?? null,
343
368
  lastUsedAt: null, url: link !== null && isActive(u) ? connectionUrl(link, u.name, null) : null,
344
369
  ...roleFields(u, roles), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}),
345
370
  ...((m) => m === undefined ? {} : { missingAttributes: m.missing })(usersMissingAttributes([u], roles)[0]),
@@ -373,6 +398,9 @@ export function addUser(file, apiKey, name, o) {
373
398
  const usage = userNameProblem("add", name);
374
399
  if (usage !== null)
375
400
  return { message: usage, code: "usage" };
401
+ const ranges = o.from === undefined ? null : rangesToSet(o.from, `mason user add ${name}`);
402
+ if (ranges !== null && "message" in ranges)
403
+ return ranges;
376
404
  const fingerprint = keyFingerprint(apiKey);
377
405
  let added;
378
406
  let replaced = false;
@@ -386,7 +414,7 @@ export function addUser(file, apiKey, name, o) {
386
414
  replaced = was !== undefined;
387
415
  const now = new Date().toISOString();
388
416
  added = { name, id: newUserId(), addedAt: now, key: fingerprint, ...(o.role !== undefined ? { role: o.role } : {}),
389
- ...(o.attributes !== undefined && Object.keys(o.attributes).length > 0 ? { attributes: o.attributes } : {}) };
417
+ ...(o.attributes !== undefined && Object.keys(o.attributes).length > 0 ? { attributes: o.attributes } : {}), ...(ranges !== null ? { from: ranges.from } : {}) };
390
418
  return [...users.map((u) => u === was ? { ...u, removedAt: now } : u), added];
391
419
  });
392
420
  }
@@ -404,8 +432,58 @@ export function addUser(file, apiKey, name, o) {
404
432
  const { link } = o;
405
433
  return { user: u.name, id: u.id, addedAt: u.addedAt, password, database: link?.database ?? o.database,
406
434
  ...(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" } : {}),
407
- ...roleFields(u, o.roles ?? NO_ROLES), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}), replaced };
435
+ ...roleFields(u, o.roles ?? NO_ROLES), ...(u.attributes !== undefined ? { attributes: u.attributes } : {}), ...(u.from !== undefined ? { from: u.from } : {}), replaced };
436
+ }
437
+ /** Address ranges as `--from` (or `/api/users`' `from`) gives them, each read by the one grammar (src/duck/users.ts
438
+ * `userRanges`) and in its one form; or a wrong line (`usage`), every entry that is not a range said, nothing written. */
439
+ export function rangesToSet(given, line) {
440
+ const r = userRanges(given);
441
+ if ("problem" in r)
442
+ return { message: `${line}: ${r.problem}`, code: "usage", fix: "an IPv4 or IPv6 range with its prefix length (10.0.0.0/16, 2001:db8::/32) or one address (192.0.2.10), one --from each" };
443
+ return { from: r.from };
444
+ }
445
+ /**
446
+ * SET A USER'S ADDRESS RANGES in `file` under its lock, replacing those it had (`from`: the ranges as given; null: any
447
+ * address, its ranges removed). The listener reads them at the user's next connection and at each statement of a session
448
+ * it holds, so a session from outside the new ranges ends there. The same ranges as now are said so (`already`), nothing
449
+ * written. A range that is not one is a wrong line, nothing written.
450
+ */
451
+ export function setUserFrom(file, name, from) {
452
+ const usage = userNameProblem("from", name);
453
+ if (usage !== null)
454
+ return { message: usage, code: "usage" };
455
+ const ranges = from === null ? null : rangesToSet(from, `mason user from ${name}`);
456
+ if (ranges !== null && "message" in ranges)
457
+ return ranges;
458
+ const next = ranges === null ? undefined : ranges.from;
459
+ let found;
460
+ let live = [];
461
+ let r;
462
+ try {
463
+ r = changeUsers(file, (users) => {
464
+ found = users.find((u) => u.name === name && isActive(u));
465
+ live = users.filter(isActive).map((u) => u.name);
466
+ if (found === undefined)
467
+ return { refused: "unknown" };
468
+ if (JSON.stringify(found.from ?? null) === JSON.stringify(next ?? null))
469
+ return { refused: "already" };
470
+ return users.map((u) => { if (u !== found)
471
+ return u; const { from: _, ...rest } = u; return next === undefined ? rest : { ...rest, from: next }; });
472
+ });
473
+ }
474
+ catch (e) {
475
+ return cantChange(file, e);
476
+ }
477
+ if (found === undefined)
478
+ return noSuchUser(name, live);
479
+ const u = found;
480
+ return { changed: { name: u.name, id: u.id, addedAt: u.addedAt, from: next ?? null }, already: !Array.isArray(r) };
408
481
  }
482
+ /** a user's ranges as `mason user` says them: `10.0.0.0/16, 2001:db8::/32`, or any address */
483
+ export const rangesText = (from) => from === null || from === undefined ? "any address" : from.join(", ");
484
+ /** What to run after a `mason user from`. */
485
+ export const fromNext = (name, from) => [["mason user list", "the users and the addresses each connects from"],
486
+ ...(from !== null ? [[`mason user from ${name} --any`, "it connects from any address again"]] : [])];
409
487
  /** REMOVE A USER from `file` under its lock: it connects no more. A name the file lacks is refused with the closest it has. */
410
488
  export function removeUser(file, name) {
411
489
  const usage = userNameProblem("remove", name);
@@ -514,7 +592,7 @@ export function listText(of, l, roles, next, s = styleOut()) {
514
592
  const rolesFileShown = roles.file ?? `mason/${ROLES_FILE}`;
515
593
  // the last use in the words the web page's Users page says it too (lastUsedText): "last used" before a time or "unknown"
516
594
  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)}`)}` +
595
+ 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)})` : ""} ` : ""}${u.from !== null ? `from ${rangesText(u.from)} ` : ""}${s.dim(`id ${u.id} · added ${stamp(u.addedAt)} UTC · ${used(u)}`)}` +
518
596
  (u.disabledAt !== null ? ` ${s.yellow(`${disabledText(u.disabledAt)}: does not connect until mason user enable ${u.name}`)}`
519
597
  : u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`) +
520
598
  (u.roleKnown === false ? ` ${s.yellow(`its role ${u.role} is not in the roles file: its statements are refused`)}` : "") +
@@ -533,23 +611,34 @@ export function listText(of, l, roles, next, s = styleOut()) {
533
611
  ...(roles.problem !== null && active.length ? [rolesProblemText(rolesFileShown, roles.problem)] : []),
534
612
  ...(lastUsedNote !== null ? [`Last used: ${lastUsedNote}.`] : [])], next }, s), s);
535
613
  }
614
+ /** A user in a headline: its name, and the replica it is a user of when the command acted on one (`on`), so the answer
615
+ * says its target. */
616
+ const userOn = (name, on) => on === undefined ? name : `${name} of the replica ${on}`;
536
617
  /** `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`,
618
+ export const disabledReport = (r, up, next, s = styleOut(), on) => {
619
+ const d = r.disabled, who = userOn(d.name, on);
620
+ return reportText({ headline: r.already ? `The user ${who} is disabled already: nothing changed` : `The user ${who} disabled: it connects no more, until enabled`,
540
621
  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
622
  tips: [disableEffect(d.name)], next }, s);
542
623
  };
543
624
  /** `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`,
625
+ export const enabledReport = (r, next, s = styleOut(), on) => {
626
+ const e = r.enabled, who = userOn(e.name, on);
627
+ return reportText({ headline: r.already ? `The user ${who} is not disabled: nothing changed, it connects` : `The user ${who} enabled: it connects again, with the same password`,
547
628
  details: [["user", e.name], ["added", e.addedAt]], next }, s);
548
629
  };
630
+ /** `mason user from` for a person. `up`: Mason runs, so a session from outside the new ranges ends at its next statement. */
631
+ export const fromReport = (r, up, next, s = styleOut()) => {
632
+ const c = r.changed;
633
+ return reportText({ headline: r.already ? `The user ${c.name} connects from ${rangesText(c.from)} already: nothing changed`
634
+ : c.from === null ? `The user ${c.name} connects from any address now` : `The user ${c.name} connects from ${rangesText(c.from)} alone now`,
635
+ details: [["user", c.name], ["from", rangesText(c.from)], ...(r.already || c.from === null ? [] : [["sessions", up ? "those it holds from another address end at their next statement" : "none: Mason is not running"]])],
636
+ tips: c.from === null ? [] : ["A connection from any other address is refused before its password is checked (28000, no entry for host …)."], next }, s);
637
+ };
549
638
  /** `mason user remove` for a person. */
550
- export const removedReport = (r, up, next, s = styleOut()) => {
639
+ export const removedReport = (r, up, next, s = styleOut(), on) => {
551
640
  const gone = r.removed;
552
- return reportText({ headline: `The user ${gone.name} removed: it connects no more`,
641
+ return reportText({ headline: `The user ${userOn(gone.name, on)} removed: it connects no more`,
553
642
  details: [["user", gone.name], ["added", gone.addedAt], ["sessions", up ? "those it holds end at their next statement" : "none: Mason is not running"]],
554
643
  tips: [`Adding ${gone.name} again makes a new password: the old one never connects again.`], next }, s);
555
644
  };
@@ -562,11 +651,12 @@ export const removedReport = (r, up, next, s = styleOut()) => {
562
651
  export function addedReport(r, o, s = styleOut()) {
563
652
  const { roles } = o;
564
653
  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`,
654
+ headline: `The user ${userOn(r.user, o.on)} added${r.replaced === true ? " (in place of one the API key's rotation had removed)" : ""}: its password is printed this once`,
566
655
  details: [
567
656
  ...(r.url !== undefined && r.psql !== undefined ? [["url", r.url], ["psql", r.psql]] : []),
568
657
  ["user", r.user], ["password", r.password], ["database", r.database],
569
658
  ...(r.attributes !== undefined ? [["attributes", attributesText(r.attributes)]] : []),
659
+ ...(r.from !== undefined ? [["from", `${rangesText(r.from)} (it connects from these alone)`]] : []),
570
660
  ...(r.roleFrom === "file" ? [["role", `${r.role} (mason/${ROLES_FILE} names what it reads)`]]
571
661
  : r.roleFrom === "default" ? [["role", `${r.role} (the file's default: ${roles.file ?? `mason/${ROLES_FILE}`})`]]
572
662
  : r.readsNothing === true ? [["role", `none, and ${roles.file ?? `mason/${ROLES_FILE}`} names no default: it reads nothing until one is written`]] : []),
@@ -591,13 +681,26 @@ export async function user(argv) {
591
681
  const json = f.values.json === true;
592
682
  const usage = (message) => failed("user", { message, code: "usage", usage: USER.usage }, { json });
593
683
  const [verb, name, ...rest] = f.positionals;
594
- const VERBS = ["add", "list", "disable", "enable", "remove"];
684
+ const VERBS = ["add", "list", "from", "disable", "enable", "remove"];
595
685
  if (verb === undefined)
596
- return usage("add, list, disable, enable or remove: mason user add <name> makes a user");
686
+ return usage("add, list, from, disable, enable or remove: mason user add <name> makes a user");
597
687
  if (!VERBS.includes(verb))
598
- return usage(`there is no mason user ${verb}: add, list, disable, enable or remove${didYouMean(verb, VERBS)}`);
599
- if (verb === "list" ? name !== undefined : rest.length)
688
+ return usage(`there is no mason user ${verb}: add, list, from, disable, enable or remove${didYouMean(verb, VERBS)}`);
689
+ if (verb === "list" ? name !== undefined : verb !== "from" && rest.length)
600
690
  return usage(`mason user ${verb} takes ${verb === "list" ? "no name" : "one name"}: ${[name, ...rest].filter(Boolean).join(" ")} is too many`);
691
+ // the address ranges: --from on add, the positionals of from (or its --any); each read by the one grammar, a wrong one a
692
+ // wrong line before anything is read, written or sent
693
+ // a comma list or the flag repeated, the same (src/cli/command.ts listFlag): a range holds no comma
694
+ const fromList = listFlag(f.values.from);
695
+ const fromFlags = fromList === undefined ? [] : fromList.split(",");
696
+ if (fromFlags.length > 0 && verb !== "add")
697
+ return usage(`--from goes with mason user add: mason user add <name> --from ${fromFlags[0]}${verb === "from" ? `, or mason user from <name> ${fromFlags[0]}` : ""}`);
698
+ const any = f.values.any === true;
699
+ if (any && verb !== "from")
700
+ return usage("--any goes with mason user from: mason user from <name> --any removes its address ranges");
701
+ if (verb === "from" && any === (rest.length > 0))
702
+ return usage(any ? `mason user from ${name ?? "<name>"}: --any or ranges, not both` : `mason user from ${name ?? "<name>"}: which addresses — one or more ranges (10.0.0.0/16, 2001:db8::/32, 192.0.2.10), or --any`);
703
+ const given = verb === "add" ? (fromFlags.length > 0 ? fromFlags : null) : verb === "from" && !any ? rest : null;
601
704
  const role = typeof f.values.role === "string" ? f.values.role : undefined;
602
705
  if (role !== undefined && verb !== "add")
603
706
  return usage(`--role goes with mason user add: mason user add <name> --role ${role}`);
@@ -614,6 +717,9 @@ export async function user(argv) {
614
717
  if (problem !== null)
615
718
  return usage(problem);
616
719
  }
720
+ const ranges = given === null ? null : rangesToSet(given, `mason user ${verb} ${name}`);
721
+ if (ranges !== null && "message" in ranges)
722
+ return failed("user", { ...ranges, usage: USER.usage }, { json });
617
723
  // A REPLICA ON A HOSTED MASON SERVICE (--replica, else MASON_REPLICA): its users, asked of the service with the same
618
724
  // request the run's /api/users takes, printed by the renderers above; nothing of a project here is read or written
619
725
  const replica = replicaOf(typeof f.values.replica === "string" ? f.values.replica : undefined, USER.usage);
@@ -623,7 +729,7 @@ export async function user(argv) {
623
729
  const named = role === undefined ? null : roleNameProblem(role);
624
730
  if (named !== null)
625
731
  return usage(named);
626
- return hostedUser(replica, { verb: verb, name, role, sets, json });
732
+ return hostedUser(replica, { verb: verb, name, role, sets, from: ranges?.from ?? null, json });
627
733
  }
628
734
  const w = await reach(USER, json);
629
735
  if (w === null || w.project === null)
@@ -669,6 +775,18 @@ export async function user(argv) {
669
775
  out(listText(project.config.name, { users, lastUsedNote, urlNote, requestLogDays }, roles, next));
670
776
  return 0;
671
777
  }
778
+ if (verb === "from") {
779
+ const r = setUserFrom(file, name, ranges?.from ?? null);
780
+ if ("message" in r)
781
+ return failed("user", r, { json });
782
+ const next = fromNext(r.changed.name, r.changed.from);
783
+ if (json) {
784
+ out(jsonText(USER, r, next));
785
+ return 0;
786
+ }
787
+ out(fromReport(r, w.state === "up", next));
788
+ return 0;
789
+ }
672
790
  if (verb === "disable") {
673
791
  const r = disableUser(file, name);
674
792
  if ("message" in r)
@@ -699,7 +817,7 @@ export async function user(argv) {
699
817
  return failed("user", r, { json });
700
818
  const next = REMOVED_NEXT;
701
819
  if (json) {
702
- out(jsonText(USER, r, next));
820
+ out(jsonText(USER, { removedUser: r.removed, ...r }, next));
703
821
  return 0;
704
822
  }
705
823
  out(removedReport(r, w.state === "up", next));
@@ -723,7 +841,7 @@ export async function user(argv) {
723
841
  const attributes = attributesToAdd(name, role ?? userRole({}, roles).role, sets, roles);
724
842
  if ("refused" in attributes)
725
843
  return failed("user", attributes.refused, { json });
726
- const r = addUser(file, apiKey, name, { link, database, roles, ...(role !== undefined ? { role } : {}), attributes: attributes.values });
844
+ const r = addUser(file, apiKey, name, { link, database, roles, ...(role !== undefined ? { role } : {}), attributes: attributes.values, ...(ranges !== null ? { from: ranges.from } : {}) });
727
845
  if ("message" in r)
728
846
  return failed("user", r, { json });
729
847
  const { replaced: _, ...added } = r;
@@ -24,11 +24,19 @@
24
24
  * user's session, the file read again when its inode, size or mtime moved), so a removal refuses the next connection
25
25
  * at once and ends the sessions that user holds at their next statement, without a restart. FAIL CLOSED: a file that
26
26
  * cannot be read refuses every user login with the reason (57P03, "could not tell"), never "live"; no file is no users.
27
+ *
28
+ * A USER'S ADDRESS RANGES (`from`, `mason user add --from`, `mason user from`): where it may connect from, read by the one
29
+ * grammar `MASON_PG_PROXY_FROM` is read by (src/lib/cidr.ts). Judged at login BEFORE the password, on the session's own
30
+ * address (`Session.clientAddr`, the PROXY header's client behind a named proxy), and again before each statement; an
31
+ * address that could not be read is outside every list (fail closed). While any user has ranges, a name that is no user
32
+ * is refused in the same words, and with the same work, as an address outside them: a limited user cannot be told from
33
+ * a missing one by the answer or its time (a user with no ranges still answers a wrong password 28P01).
27
34
  */
28
35
  import * as crypto from "node:crypto";
29
36
  import * as fs from "node:fs";
30
37
  import * as path from "node:path";
31
- import { ApiError, USER_DISABLED } from "../api/error.js";
38
+ import { ADDRESS_NOT_ALLOWED, ApiError, USER_DISABLED } from "../api/error.js";
39
+ import { cidrOf, cidrText, inRanges, rangeOf } from "../lib/cidr.js";
32
40
  import { tokenProblem } from "./token.js";
33
41
  import { ATTRIBUTE_NAME_RE } from "./row-filter.js";
34
42
  export const USERS_FILE = "users.json";
@@ -36,6 +44,46 @@ export const USERS_FILE = "users.json";
36
44
  * capitalises it is told so, never another user. */
37
45
  export const USER_NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,62}$/;
38
46
  export const USER_NAME_RULE = "1 to 63 lower case letters, digits, \"_\", \".\" or \"-\", starting with a letter or digit (e.g. metabase)";
47
+ /**
48
+ * A user's address ranges as given (`--from`, the file, `/api/users`): each read by the one grammar (src/lib/cidr.ts
49
+ * `cidrOf`), written back in its one form (`cidrText`: `10.0.0.0/16`, `2001:db8::/32`, `192.0.2.10`), a range given
50
+ * twice once; or every entry that is not a range, each with what is wrong. An empty list is no list of ranges: the way
51
+ * to any address is to have none (`mason user from <name> --any`).
52
+ */
53
+ export function userRanges(list) {
54
+ if (list.length === 0)
55
+ return { problem: "no address range: name one or more (10.0.0.0/16, 2001:db8::/32, 192.0.2.10)" };
56
+ if (list.length > MAX_RANGES)
57
+ return { problem: `${list.length} address ranges: a user takes at most ${MAX_RANGES}` };
58
+ const from = [], ranges = [], wrong = [];
59
+ for (const entry of list) {
60
+ const c = cidrOf(entry);
61
+ if ("problem" in c) {
62
+ wrong.push(c.problem);
63
+ continue;
64
+ }
65
+ const t = cidrText(c);
66
+ if (!from.includes(t)) {
67
+ from.push(t);
68
+ ranges.push(rangeOf(c));
69
+ }
70
+ }
71
+ return wrong.length ? { problem: wrong.join("; ") } : { from, ranges };
72
+ }
73
+ /** The most address ranges one user takes (all input is untrusted: a bound). */
74
+ export const MAX_RANGES = 100;
75
+ /** Whether a user may connect from `address` (`Session.clientAddr`): any address when it has no ranges; with ranges, one
76
+ * in them, and never an address that could not be read (null), nor any when its ranges cannot be read (fail closed). */
77
+ export function addressAllowed(u, address) {
78
+ if (u.from === undefined)
79
+ return true;
80
+ if (address === null)
81
+ return false;
82
+ const r = userRanges(u.from);
83
+ return !("problem" in r) && inRanges(r.ranges, address);
84
+ }
85
+ /** an address as a refusal says it */
86
+ export const addressShown = (address) => address === null ? "an address that could not be read" : address;
39
87
  /** a user's attributes as the file keeps them: a name each, a string or a list of strings */
40
88
  function attributesValid(v) {
41
89
  // a NUL ends the statement it is bound into, wherever it is: every statement of the user would fail as a syntax error
@@ -93,7 +141,12 @@ function parseUsers(file, text) {
93
141
  if (typeof o !== "object" || o === null || typeof o.name !== "string" || !USER_NAME_RE.test(o.name) || typeof o.id !== "string" || !/^[0-9a-f]{16}$/.test(o.id) ||
94
142
  typeof o.addedAt !== "string" || typeof o.key !== "string" || (o.role !== undefined && (typeof o.role !== "string" || !ROLE_RE.test(o.role))) ||
95
143
  (o.attributes !== undefined && !attributesValid(o.attributes))) {
96
- return { problem: `${file}: users[${i}] is not { name, id, addedAt, key, role?, attributes?[, removedAt][, disabledAt] } as mason user add writes it` };
144
+ return { problem: `${file}: users[${i}] is not { name, id, addedAt, key, role?, attributes?, from?[, removedAt][, disabledAt] } as mason user add writes it` };
145
+ }
146
+ if (o.from !== undefined) {
147
+ const r = Array.isArray(o.from) && o.from.every((x) => typeof x === "string") ? userRanges(o.from) : { problem: "not a list of strings" };
148
+ if ("problem" in r)
149
+ return { problem: `${file}: users[${i}].from is not a list of address ranges as mason user from writes it: ${r.problem}` };
97
150
  }
98
151
  if (o.removedAt !== undefined && typeof o.removedAt !== "string") {
99
152
  return { problem: `${file}: users[${i}].removedAt is not a timestamp as mason user remove writes it` };
@@ -102,7 +155,7 @@ function parseUsers(file, text) {
102
155
  return { problem: `${file}: users[${i}].disabledAt is not a timestamp as mason user disable writes it` };
103
156
  }
104
157
  users.push({ name: o.name, id: o.id, addedAt: o.addedAt, key: o.key, ...(typeof o.role === "string" ? { role: o.role } : {}),
105
- ...(attributesValid(o.attributes) ? { attributes: o.attributes } : {}),
158
+ ...(attributesValid(o.attributes) ? { attributes: o.attributes } : {}), ...(Array.isArray(o.from) ? { from: o.from } : {}),
106
159
  ...(o.removedAt !== undefined ? { removedAt: o.removedAt } : {}), ...(o.disabledAt !== undefined ? { disabledAt: o.disabledAt } : {}) });
107
160
  }
108
161
  return { users };
@@ -184,6 +237,11 @@ export class LiveUsers {
184
237
  apiKey;
185
238
  seen = null;
186
239
  read = { users: [] };
240
+ /** each live user's ranges, parsed once per read of the file (so a login or a statement costs a lookup, never a parse) */
241
+ ranges = new Map();
242
+ /** the longest of those lists: what a name that is no user is judged against, so its refusal costs what a limited
243
+ * user's does and the time of the answer does not tell them apart */
244
+ decoy = [];
187
245
  constructor(file, apiKey) { this.file = file; this.apiKey = apiKey; }
188
246
  /** the users not removed now (disabled ones included: their login is refused by name); why not, when the file cannot be read */
189
247
  now() {
@@ -202,11 +260,38 @@ export class LiveUsers {
202
260
  // a removed user is kept in the file for the list: it is no user to the listener
203
261
  this.read = "problem" in r ? r : { users: r.users.filter(isActive) };
204
262
  this.seen = stamp;
263
+ this.ranges = new Map();
264
+ this.decoy = [];
265
+ for (const u of "problem" in this.read ? [] : this.read.users) {
266
+ if (u.from === undefined)
267
+ continue;
268
+ const parsed = userRanges(u.from);
269
+ // the file was read whole, so every list parses; one that would not stays out of the map, which refuses it
270
+ if ("problem" in parsed)
271
+ continue;
272
+ this.ranges.set(u.id, parsed.ranges);
273
+ if (parsed.ranges.length > this.decoy.length)
274
+ this.decoy = parsed.ranges;
275
+ }
205
276
  }
206
277
  return this.read;
207
278
  }
208
- /** A connection's password, read as a user's: the session's token, or why it is refused. */
209
- login(user, password) {
279
+ /** Whether `address` is inside `ranges` (null: no list, any address); an address not read is inside none. The ranges
280
+ * are the parsed ones this read of the file holds, so this is a comparison, never a parse. */
281
+ inside(ranges, address) {
282
+ if (ranges === null)
283
+ return true;
284
+ return address !== null && inRanges(ranges, address);
285
+ }
286
+ /**
287
+ * A connection's password, read as a user's: the session's token, or why it is refused. `from` is the client's address
288
+ * (`Session.clientAddr`; null when it could not be read). THE ADDRESS IS JUDGED FIRST, before the password: the user
289
+ * the startup packet names, held to its ranges (`inside`); and while any user has ranges, a name that is no
290
+ * user is refused the same way (`ADDRESS_NOT_ALLOWED`, whose words the listener writes from the name and the address
291
+ * alone), so a connection from outside cannot tell a limited user from a missing one by its answer. Only the log line
292
+ * says which (the ApiError's message).
293
+ */
294
+ login(user, password, from = null) {
210
295
  const m = PASSWORD_RE.exec(password);
211
296
  if (m === null)
212
297
  throw refusal(401, "not a Mason user's password: mason user add <name> makes a user and prints its password", user);
@@ -214,6 +299,13 @@ export class LiveUsers {
214
299
  const now = this.now();
215
300
  if ("problem" in now)
216
301
  throw refusal(503, `the list of users cannot be read, so no user can connect: ${now.problem}`, user);
302
+ const named = now.users.find((u) => u.name === user);
303
+ // the same work on both sides: a limited user's own list, or for a name that is no user the longest list there is
304
+ const inside = this.inside(named === undefined ? this.decoy : named.from === undefined ? null : this.ranges.get(named.id) ?? [], from);
305
+ if (named !== undefined ? !inside : this.decoy.length > 0) {
306
+ throw refusal(401, named !== undefined ? `the user "${user}" may not connect from ${addressShown(from)}, outside its address ranges (mason user list shows them)`
307
+ : `no user "${user}" (from ${addressShown(from)}): refused as an address outside a user's ranges is, since some users have ranges`, user, ADDRESS_NOT_ALLOWED);
308
+ }
217
309
  const found = now.users.find((u) => u.id === id);
218
310
  const verifies = (name) => crypto.timingSafeEqual(Buffer.from(mac(this.apiKey, name, id)), Buffer.from(given));
219
311
  if (found === undefined) {
@@ -239,7 +331,7 @@ export class LiveUsers {
239
331
  * token as the file has the user NOW — its role as `mason user add --role` wrote it, so a role given or taken
240
332
  * applies at the user's next statement, without a restart.
241
333
  */
242
- recheck(token) {
334
+ recheck(token, from = null) {
243
335
  if (token.user === undefined)
244
336
  return token;
245
337
  const now = this.now();
@@ -250,6 +342,10 @@ export class LiveUsers {
250
342
  throw refusal(401, `the user "${token.user}" was removed`, token.user);
251
343
  if (isDisabled(found))
252
344
  throw refusal(401, `the user "${token.user}" was disabled`, token.user, USER_DISABLED);
345
+ // its ranges changed since it logged in: a session from outside them ends, as a new connection would be refused
346
+ if (!this.inside(found.from === undefined ? null : this.ranges.get(found.id) ?? [], from)) {
347
+ throw refusal(401, `the user "${token.user}" may not connect from ${addressShown(from)}: its address ranges are ${found.from.join(", ")}`, token.user, ADDRESS_NOT_ALLOWED);
348
+ }
253
349
  if (found.role === token.role && JSON.stringify(found.attributes ?? null) === JSON.stringify(token.attributes ?? null))
254
350
  return token;
255
351
  const { role: _was, attributes: _had, ...rest } = token;
@@ -269,9 +365,9 @@ export const disabledMessage = (name) => `role "${name}" is not permitted to log
269
365
  */
270
366
  export function listenerLogins(users, apiKey) {
271
367
  return {
272
- authenticate: (password, user) => {
368
+ authenticate: (password, user, from) => {
273
369
  if (isUserPassword(password))
274
- return users.login(user, password);
370
+ return users.login(user, password, from ?? null);
275
371
  if (password.startsWith(SHARE_PASSWORD_PREFIX))
276
372
  throw new Error("a mason share password logs in only at the address mason share printed, never on this port");
277
373
  const problem = tokenProblem(apiKey, password);
@@ -279,7 +375,7 @@ export function listenerLogins(users, apiKey) {
279
375
  throw new Error(problem);
280
376
  return { filters: [], subject: undefined };
281
377
  },
282
- recheck: async (t) => users.recheck(t),
378
+ recheck: async (t, from) => users.recheck(t, from ?? null),
283
379
  recheckMs: 0,
284
380
  };
285
381
  }