// `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. 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="",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); } }