@mason-data/cli 0.9.0 → 0.10.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 +6 -4
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/proxy.js +6 -160
- package/dist/src/api/postgres/server.js +22 -4
- package/dist/src/cli/command.js +169 -5
- package/dist/src/cli/commands.js +290 -157
- package/dist/src/cli/deploy.js +134 -28
- package/dist/src/cli/doctor.js +14 -11
- package/dist/src/cli/guides.js +20 -11
- package/dist/src/cli/hosted.js +1 -1
- package/dist/src/cli/init.js +110 -25
- package/dist/src/cli/login.js +20 -17
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +51 -8
- package/dist/src/cli/mcp.js +15 -12
- package/dist/src/cli/project.js +1 -1
- package/dist/src/cli/registry.js +80 -35
- package/dist/src/cli/replica-hosted.js +761 -102
- package/dist/src/cli/replica.js +43 -5
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/run-server.js +25 -2
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/serve.js +2 -2
- package/dist/src/cli/share.js +20 -15
- package/dist/src/cli/source-role.js +14 -15
- package/dist/src/cli/sources.js +20 -3
- package/dist/src/cli/style.js +14 -0
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +30 -24
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +3 -3
- package/dist/src/cli/ui.js +67 -26
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +78 -13
- package/dist/src/cli/user.js +168 -50
- package/dist/src/duck/users.js +105 -9
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/table-sync.js +69 -19
- package/package.json +6 -6
package/dist/src/cli/user.js
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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,
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 ${
|
|
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 ${
|
|
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;
|
package/dist/src/duck/users.js
CHANGED
|
@@ -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
|
-
/**
|
|
209
|
-
|
|
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
|
}
|