Rename design r2, "Selector scopes and parser matrix". packages/business/src/selector.mjs and scripts/selector.sh carry one rule set: in a parser's scope each spelling appears once, and a second spelling must carry exactly the same value; a repeated spelling, a missing or flag-shaped value, an invalid id and unequal values refuse. The file family (--business-file/--org-file) compares paths as given. Every CLI parser that took --business now takes --org through orgSelector, and the old global --org rewrite in main() is gone, so a value another option consumes is never read as a selector. The id is checked while parsing (OrgError org-invalid, exit 3), so grants admin set no longer reads the token and proxy init no longer prompts for the key before an invalid id refuses. access gains the same local id check.
101 lines
5.6 KiB
JavaScript
101 lines
5.6 KiB
JavaScript
// `mosaic credentials` (row 199, #1697, design section 6): an agent's per-agent credentials through
|
|
// the broker's credentials, credentials.issue, credentials.revoke and credentials.delete. Every verb
|
|
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
|
|
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
|
|
import { CliError } from "./errors.mjs";
|
|
import { orgSelector } from "./org-selector.mjs";
|
|
|
|
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
|
|
"issue|rotate AGENT vikunja --expires DATE | issue|rotate AGENT host.ID --rotate-by DATE | " +
|
|
"revoke AGENT [KIND] --reason TEXT [--tester] | delete AGENT --reason TEXT [--business ID]";
|
|
const DATE_FLAG = Object.freeze({ gitea: "--rotate-by", vikunja: "--expires" });
|
|
// Row 210 (#1707, design D1): host.<id> takes --rotate-by, as gitea does.
|
|
const HOST_KIND = /^host\.[a-z0-9][a-z0-9-]{0,31}$/;
|
|
const dateFlag = (kind) => (HOST_KIND.test(kind ?? "") ? "--rotate-by" : DATE_FLAG[kind]);
|
|
const MAX_TOKEN = 16384;
|
|
|
|
function parse(args) {
|
|
const out = { positional: [], flags: new Set(), values: {} };
|
|
// --org and --business are one selector in this parser's scope (rename design r2).
|
|
const selector = orgSelector(() => new CliError(CREDENTIALS_USAGE, 4));
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
|
|
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
|
|
else if (["--rotate-by", "--expires", "--reason"].includes(arg)) {
|
|
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
|
|
out.values[arg] = args[++i];
|
|
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
|
|
else out.positional.push(arg);
|
|
}
|
|
if (selector.given) out.values["--business"] = selector.value;
|
|
return out;
|
|
}
|
|
|
|
// The whole of stdin, one token; a terminal shows what is typed, so a pipe or a paste then EOF.
|
|
async function readToken(stdin) {
|
|
let text = "";
|
|
for await (const chunk of stdin) {
|
|
text += chunk;
|
|
if (text.length > MAX_TOKEN + 2) throw new CliError("refused: the token on stdin is too long", 2);
|
|
}
|
|
if (!text.trim()) throw new CliError("refused: no token on stdin; pipe or paste it, then end input", 4);
|
|
return text;
|
|
}
|
|
|
|
// The states the bus's status() gives a held token: gitea is valid or rotation-due, vikunja valid,
|
|
// expiring or expired. not-issued and revoked hold none.
|
|
const ISSUED = new Set(["valid", "rotation-due", "expiring", "expired"]);
|
|
|
|
export async function credentialsCommand(args, io, callFor) {
|
|
const parsed = parse(args);
|
|
const [verb = "status", agent, kind, ...extra] = parsed.positional;
|
|
const v = parsed.values;
|
|
const shape = {
|
|
status: () => kind === undefined && !v["--rotate-by"] && !v["--expires"] && !v["--reason"],
|
|
issue: () => agent && dateFlag(kind) && v[dateFlag(kind)] && !v[dateFlag(kind) === "--rotate-by" ? "--expires" : "--rotate-by"] && !v["--reason"],
|
|
revoke: () => agent && (kind === undefined || dateFlag(kind)) && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
|
|
delete: () => agent && kind === undefined && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
|
|
};
|
|
shape.rotate = shape.issue;
|
|
if (extra.length || !shape[verb]?.()) throw new CliError(CREDENTIALS_USAGE, 4);
|
|
// Q173 (duckworth N1): a token the tester issued would pass through its session.
|
|
if (parsed.flags.has("--tester") && !["status", "revoke"].includes(verb)) {
|
|
throw new CliError(`--tester takes only status and revoke\n${CREDENTIALS_USAGE}`, 4);
|
|
}
|
|
try {
|
|
const call = callFor(parsed);
|
|
let result;
|
|
if (verb === "status") {
|
|
const rows = await call("credentials", {});
|
|
result = agent ? rows.filter((r) => r.role === agent) : rows;
|
|
if (!parsed.flags.has("--json")) {
|
|
io.stdout.write(result.map((r) => `${r.role}\t${r.service}\t${r.state}\t${r.dateKind} ${r.date}${r.fingerprint ? `\t${r.fingerprint}` : ""}`).join("\n") + (result.length ? "\n" : ""));
|
|
return;
|
|
}
|
|
} else if (verb === "issue" || verb === "rotate") {
|
|
// rotate names an entry the bus holds now; checked before stdin is read.
|
|
if (verb === "rotate") {
|
|
const held = (await call("credentials", {})).find((r) => r.role === agent && r.service === kind);
|
|
if (!ISSUED.has(held?.state)) throw new CliError(`refused: ${agent} has no issued ${kind} credential to rotate; use issue`, 2);
|
|
}
|
|
const token = await readToken(io.stdin);
|
|
result = await call("credentials.issue", { agent, service: kind, token, date: v[dateFlag(kind)] });
|
|
// Row 210 (design D9): public material only. The from= address is the stack host's, which
|
|
// ops-01 fills in when it installs the line.
|
|
if (result.publicKey && !parsed.flags.has("--json")) {
|
|
const business = v["--business"] ?? "BUSINESS";
|
|
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
io.stdout.write(`authorized_keys line for ops-01:\nfrom="<stack host address>",restrict ${result.publicKey} mosaic-${business}-${agent}-${kind.slice("host.".length)}\n`);
|
|
return;
|
|
}
|
|
} else if (verb === "revoke") {
|
|
result = await call("credentials.revoke", { agent, ...(kind ? { service: kind } : {}), reason: v["--reason"] });
|
|
} else result = await call("credentials.delete", { agent, reason: v["--reason"] });
|
|
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
} catch (error) {
|
|
if (error instanceof CliError) throw error;
|
|
throw new CliError(error.message, 2);
|
|
}
|
|
}
|