132 lines
7.0 KiB
JavaScript
132 lines
7.0 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";
|
|
|
|
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 = ["--business", "--reason", "--url", "--account", "--rotate-by", "--revision"];
|
|
|
|
function parse(args) {
|
|
const out = { positional: [], flags: new Set(), values: {} };
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
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);
|
|
}
|
|
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);
|
|
}
|
|
}
|