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.
137 lines
7.3 KiB
JavaScript
137 lines
7.3 KiB
JavaScript
// `mosaic grants` (row 236, #1742, #436b r5): the broker's grants verbs for the Gitea grant path. run
|
|
// starts the executor of a decision the human said yes to, and admin set, rotate and remove keep the
|
|
// admin slot; those are the human's alone. status is the human's, the tester's or the chief's; revoke
|
|
// and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never
|
|
// argv, and nothing here prints a token: the answers carry ids and names only. Row 298 (#1804, Q228):
|
|
// scope reads the chief of staff's grant scope and scope set writes it from JSON on stdin; both are
|
|
// the human's, and the set is the human's Approve of the change.
|
|
import { CliError } from "./errors.mjs";
|
|
import { orgSelector } from "./org-selector.mjs";
|
|
|
|
export const GRANTS_USAGE =
|
|
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
|
|
"revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " +
|
|
"admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " +
|
|
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea | " +
|
|
"scope [--business ID] | scope set --revision REV [--business ID] (the scope JSON on stdin)";
|
|
const MAX_TOKEN = 16384;
|
|
const MAX_SCOPE = 65536;
|
|
const VALUES = ["--reason", "--url", "--account", "--rotate-by", "--revision"];
|
|
|
|
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(GRANTS_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" || arg === "--finish") {
|
|
if (out.flags.has(arg)) throw new CliError(GRANTS_USAGE, 4);
|
|
out.flags.add(arg);
|
|
} else if (VALUES.includes(arg)) {
|
|
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(GRANTS_USAGE, 4);
|
|
out.values[arg] = args[++i];
|
|
} else if (arg.startsWith("--")) throw new CliError(GRANTS_USAGE, 4);
|
|
else out.positional.push(arg);
|
|
}
|
|
if (selector.given) out.values["--business"] = selector.value;
|
|
return out;
|
|
}
|
|
|
|
// The whole of stdin, one token, as `mosaic credentials issue` reads it.
|
|
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;
|
|
}
|
|
|
|
// Row 298: the whole of stdin, one JSON scope. {} is the empty scope.
|
|
async function readScope(stdin) {
|
|
let text = "";
|
|
for await (const chunk of stdin) {
|
|
text += chunk;
|
|
if (text.length > MAX_SCOPE) throw new CliError("refused: the scope on stdin is too long", 2);
|
|
}
|
|
if (!text.trim()) throw new CliError("refused: no scope on stdin; pipe the scope JSON, {} for none", 4);
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
throw new CliError("refused: the scope on stdin is not JSON", 4);
|
|
}
|
|
}
|
|
|
|
// Each shape: the positional count after the verb, the flags and values it takes and needs.
|
|
const SHAPES = {
|
|
status: { n: 1, flags: ["--json", "--tester"], need: [], may: ["--business"] },
|
|
run: { n: 1, flags: [], need: [], may: ["--business"] },
|
|
revoke: { n: 2, flags: ["--tester"], need: ["--reason"], may: ["--business"] },
|
|
sweep: { n: 1, flags: ["--tester"], need: [], may: ["--business"] },
|
|
"admin set": { n: 1, flags: [], need: ["--url", "--account", "--rotate-by"], may: ["--business"] },
|
|
"admin rotate": { n: 1, flags: ["--finish"], need: [], may: ["--rotate-by", "--business"] },
|
|
"admin remove": { n: 1, flags: [], need: [], may: ["--business"] },
|
|
scope: { n: 0, flags: [], need: [], may: ["--business"] },
|
|
"scope set": { n: 0, flags: [], need: ["--revision"], may: ["--business"] },
|
|
};
|
|
|
|
const lines = (s) => {
|
|
const out = [];
|
|
out.push(s.admin?.error ? `admin: ${s.admin.error}` : s.admin ? `admin: ${s.admin.account} on ${s.admin.url}, rotate by ${s.admin.rotateBy}${s.admin.rotationDue ? " (due)" : ""}` : "admin: none");
|
|
if (s.adminTokenLeft) out.push(`admin token left: ${s.adminTokenLeft}`);
|
|
for (const g of s.grants) out.push(["grant", g.agent, g.user, `token ${g.tokenId}`, g.projects.join(","), `rotate by ${g.rotateBy}`, g.decision].join("\t"));
|
|
for (const o of s.open) out.push(["open", o.decision, o.agent].join("\t"));
|
|
for (const o of s.running) out.push(["running", o.decision, o.agent, o.step ?? "spent"].join("\t"));
|
|
for (const o of s.settlePending) out.push(["settle-pending", o.decision, o.agent].join("\t"));
|
|
for (const o of s.tokenUnknown) out.push(["token-unknown", o.agent, o.decision].join("\t"));
|
|
for (const e of s.serviceLeft) out.push(["left", e.agent, e.user, String(e.id)].join("\t"));
|
|
for (const r of s.revoking) out.push(["revoking", r.agent, r.revoke].join("\t"));
|
|
return out.join("\n") + "\n";
|
|
};
|
|
|
|
export async function grantsCommand(args, io, callFor) {
|
|
const parsed = parse(args);
|
|
let [verb, ...rest] = parsed.positional;
|
|
if (verb === "admin") {
|
|
verb = `admin ${rest[0] ?? ""}`;
|
|
rest = rest.slice(1);
|
|
} else if (verb === "scope" && rest[0] === "set") {
|
|
verb = "scope set";
|
|
rest = rest.slice(1);
|
|
}
|
|
// Own keys only, so `constructor` is a usage error, not a verb.
|
|
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
|
|
if (
|
|
!shape || rest.length !== shape.n ||
|
|
[...parsed.flags].some((f) => !shape.flags.includes(f)) ||
|
|
Object.keys(parsed.values).some((v) => !shape.need.includes(v) && !shape.may.includes(v)) ||
|
|
shape.need.some((v) => !Object.hasOwn(parsed.values, v)) ||
|
|
(verb !== "run" && verb !== "scope" && verb !== "scope set" && rest.at(-1) !== "gitea") ||
|
|
(verb === "admin rotate" && parsed.flags.has("--finish") === Object.hasOwn(parsed.values, "--rotate-by"))
|
|
)
|
|
throw new CliError(GRANTS_USAGE, 4);
|
|
try {
|
|
const call = callFor(parsed);
|
|
const v = parsed.values;
|
|
let request;
|
|
if (verb === "status") request = ["grants.status", { service: "gitea" }];
|
|
else if (verb === "run") request = ["grants.run", { decision: rest[0] }];
|
|
else if (verb === "revoke") request = ["grants.revoke", { service: "gitea", agent: rest[0], reason: v["--reason"] }];
|
|
else if (verb === "sweep") request = ["grants.sweep", { service: "gitea" }];
|
|
else if (verb === "admin set")
|
|
request = ["grants.admin.set", { service: "gitea", url: v["--url"], account: v["--account"], rotateBy: v["--rotate-by"], token: await readToken(io.stdin) }];
|
|
else if (verb === "scope") request = ["grants.scope", {}];
|
|
else if (verb === "scope set") request = ["grants.scope.set", { revision: v["--revision"], scope: await readScope(io.stdin) }];
|
|
else if (verb === "admin rotate")
|
|
request = ["grants.admin.rotate", parsed.flags.has("--finish") ? { service: "gitea", finish: true } : { service: "gitea", rotateBy: v["--rotate-by"] }];
|
|
else request = ["grants.admin.remove", { service: "gitea" }];
|
|
const result = await call(...request);
|
|
io.stdout.write(verb === "status" && !parsed.flags.has("--json") ? lines(result) : JSON.stringify(result, null, 2) + "\n");
|
|
} catch (error) {
|
|
if (error instanceof CliError) throw error;
|
|
throw new CliError(error.message, 2);
|
|
}
|
|
}
|