358 lines
16 KiB
JavaScript
358 lines
16 KiB
JavaScript
// `mosaic access list|grant|revoke|user` (federation design section 3.5, FED R5, #414;
|
|
// `user add|remove`, row 193c, #1705).
|
|
// The human edits <configDir>/access.json here. Each write is checked against the
|
|
// instance's context before it lands, bumps the revision by one, and appends one
|
|
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
|
|
// through humanTransport: a bus that does not see a human shell refuses it, so
|
|
// no agent and no tester can write. The context (providers, peers, businesses,
|
|
// ssoSubjects) comes in as a function; the caller decides where it comes from.
|
|
|
|
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, openSync, writeSync, fsyncSync, closeSync, existsSync, unlinkSync } from "node:fs";
|
|
import { randomBytes } from "node:crypto";
|
|
import { join, dirname } from "node:path";
|
|
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
|
|
import { rolesDir } from "./config.mjs";
|
|
import { CliError } from "./errors.mjs";
|
|
import { refuseInsideAgent } from "./transport.mjs";
|
|
|
|
export const ACCESS_USAGE = [
|
|
"mosaic access list --business ID [--json]",
|
|
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N",
|
|
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
|
|
"mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N",
|
|
"mosaic access user remove ID --business ID --revision N",
|
|
].join("\n");
|
|
|
|
// The help text. A grant's `instances` is the real limit: the granted instance
|
|
// runs with the owner's role, so a collaborator drives it with that authority.
|
|
// Removing a user acts at once. Removing a provider from console.sso waits for a
|
|
// Console restart, so take the users out of access.json first. A user's home is
|
|
// local or a peer named in federation.peers (FED R6, #1800). A grant naming a
|
|
// subject needs console.sso.
|
|
export const ACCESS_HELP = `${ACCESS_USAGE}
|
|
|
|
A grant gives a user exactly what the granted instances can do. The instances
|
|
list is the real limit: a collaborator's message drives a granted instance with
|
|
the owner's role.
|
|
To cut a user off, revoke them here first. It acts at once. Removing a provider
|
|
from console.sso takes effect only after a Console restart, so do that second.
|
|
grant and revoke ask the bus to re-read the file after the write. If the bus
|
|
refuses, the write still stands (exit 0), stderr names the refusal, and the bus's
|
|
timer applies the change within 30 s.
|
|
user remove refuses while the user has a grant at any business: revoke those first.
|
|
A user's home is local or a peer id from federation.peers in the system config;
|
|
any other home refuses. A grant that names a sign-in subject needs console.sso
|
|
in the system config; without it every such grant refuses.
|
|
The command runs as the instance's uid: access.json must belong to it and must
|
|
not be writable by group or other, or the command refuses (exit 3) and writes nothing.`;
|
|
|
|
// The instance's context for checkAccess, read from the system config and the business
|
|
// files. providers and ssoSubjects come from console.sso (row 192's shape: each provider
|
|
// has an id and a subjects list). peers are the ids in federation.peers (FED R6, F8),
|
|
// empty without the key. A business file that fails to load fails the whole command.
|
|
export function accessContextFor(system, env) {
|
|
const providers = system.console?.sso?.providers ?? [];
|
|
const ssoSubjects = providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, sub: s.sub })));
|
|
const dir = join(configDir(env), "businesses");
|
|
const businesses = {};
|
|
if (existsSync(dir)) {
|
|
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
|
|
const id = file.slice(0, -".json".length);
|
|
const doc = loadBusiness(id, { dir: configDir(env), rolesDir: rolesDir(env) });
|
|
businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) };
|
|
}
|
|
}
|
|
const peers = (system.federation?.peers ?? []).map((p) => p.id);
|
|
return { providers: providers.map((p) => p.id), peers, businesses, ssoSubjects };
|
|
}
|
|
|
|
// Every verb needs --business: the human proof runs through that business's bus.
|
|
// --tester is refused outright on every verb, before any bus call.
|
|
function parse(args) {
|
|
const out = { positional: [], flags: new Set(), values: {} };
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i];
|
|
if (a === "--json" || a === "--tester") out.flags.add(a);
|
|
else if (a.startsWith("--")) {
|
|
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
|
|
out.values[a] = args[++i];
|
|
} else out.positional.push(a);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const list = (value) => (value === "*" ? ["*"] : value.split(",").filter(Boolean));
|
|
|
|
function readCurrent(file, context) {
|
|
if (!existsSync(file)) return { document: { accessVersion: 1, revision: null, users: [], grants: [] }, revision: 0 };
|
|
const document = loadAccess(file, context);
|
|
return { document, revision: document.revision };
|
|
}
|
|
|
|
// The file calls the write path makes. A test passes a stand-in through deps.fs.
|
|
const realFs = { openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync };
|
|
|
|
// Atomic write: a temp file in the same directory, 0600, fsynced, then renamed over
|
|
// the old file. A reader sees the old bytes or the new bytes, never a mix. A failure
|
|
// before or at the rename removes the temp file and leaves the old bytes in place.
|
|
function writeAtomic(file, text, fs = realFs) {
|
|
const tmp = join(dirname(file), `.access-${randomBytes(8).toString("hex")}.tmp`);
|
|
try {
|
|
const fd = fs.openSync(tmp, "wx", 0o600);
|
|
try {
|
|
fs.writeSync(fd, text);
|
|
fs.fsyncSync(fd);
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
fs.renameSync(tmp, file);
|
|
} catch (error) {
|
|
try {
|
|
fs.unlinkSync(tmp);
|
|
} catch {
|
|
// Already gone: the temp file was never created.
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function receipt(dataRoot, line) {
|
|
const dir = join(dataRoot, "state");
|
|
mkdirSync(dir, { recursive: true });
|
|
appendFileSync(join(dir, "access-log.jsonl"), `${JSON.stringify(line)}\n`, { mode: 0o600 });
|
|
}
|
|
|
|
// Two writers never interleave: the read, the check and the rename happen under an
|
|
// exclusive lock file, `<access.json>.lock`, mode 0600. The file holds the holder's
|
|
// pid and a newline. A lock whose holder is alive refuses. A lock that is empty,
|
|
// unreadable, or whose pid is gone also refuses, and nothing reclaims it: a human
|
|
// removes it once no write is running. Fail closed, with no reclaim race.
|
|
const BUSY = "refused: the access file is being written by another command; try again";
|
|
|
|
function lockPid(lock) {
|
|
let text;
|
|
try {
|
|
text = readFileSync(lock, "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
const pid = Number(text.trim());
|
|
return Number.isSafeInteger(pid) && pid >= 1 ? pid : null;
|
|
}
|
|
|
|
function pidAlive(pid) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return error.code !== "ESRCH";
|
|
}
|
|
}
|
|
|
|
function lockRefusal(lock) {
|
|
const pid = lockPid(lock);
|
|
if (pid !== null && pidAlive(pid)) return BUSY;
|
|
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
|
|
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
|
|
}
|
|
|
|
function takeLock(lock, fs) {
|
|
try {
|
|
return fs.openSync(lock, "wx", 0o600);
|
|
} catch (error) {
|
|
if (error.code !== "EEXIST") throw error;
|
|
}
|
|
throw new CliError(lockRefusal(lock), 3);
|
|
}
|
|
|
|
export function withLock(file, fn, fs = realFs) {
|
|
const lock = `${file}.lock`;
|
|
const fd = takeLock(lock, fs);
|
|
try {
|
|
fs.writeSync(fd, `${process.pid}\n`);
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
fs.unlinkSync(lock);
|
|
}
|
|
}
|
|
|
|
// The one human-proof call: project.list, which the bus gates with #human
|
|
// (broker.mjs:2319, and #human at :377). It also returns the rows that resolve
|
|
// project names. R4 (#1801) keeps it, since grant needs those rows, and calls access.reload after the write.
|
|
async function proveHuman(transport, business) {
|
|
return transport(business)("project.list", {});
|
|
}
|
|
|
|
// Resolve a --projects item: "*" and UUIDs pass through, anything else is a project
|
|
// name looked up in project.list at grant time (the id is what gets written).
|
|
function resolveProjects(items, rows) {
|
|
return items.map((item) => {
|
|
if (item === "*" || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item)) return item;
|
|
const hits = rows.filter((r) => r.name === item);
|
|
if (hits.length !== 1) throw new CliError(`refused: project-not-found: no project named ${item} in this business (or the name is ambiguous)`, 2);
|
|
return hits[0].id;
|
|
});
|
|
}
|
|
|
|
// deps: { system: { dataRoot }, transport(business) -> call(verb, args), contextFor() -> context, configDir?, now?, fs? }
|
|
export async function accessCommand(args, io, deps) {
|
|
refuseInsideAgent(io.env);
|
|
try {
|
|
return await accessVerb(args, io, deps);
|
|
} catch (error) {
|
|
// A file or business doc the checks refuse to load (group-writable, a wrong uid, a
|
|
// hand edit) is a refusal with exit 3 and the file and key path, not a crash.
|
|
if (error instanceof BusinessError) throw new CliError(`refused: ${error.message}`, 3);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function accessVerb(args, io, deps) {
|
|
const parsed = parse(args);
|
|
const sub = parsed.positional[0];
|
|
const shape = sub === "user" ? ["add", "remove"].includes(parsed.positional[1]) && parsed.positional.length === 3 : ["list", "grant", "revoke"].includes(sub) && parsed.positional.length === 1;
|
|
if (!shape) throw new CliError(ACCESS_HELP, 4);
|
|
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
|
|
const business = parsed.values["--business"];
|
|
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
|
|
|
|
const rows = await proveHuman(deps.transport, business);
|
|
const file = deps.file ?? accessFilePath(deps.configDir ?? configDir(io.env));
|
|
// Built after the human proof, so a refused caller never reads the business files.
|
|
const context = deps.contextFor();
|
|
|
|
if (sub === "list") {
|
|
const { document } = readCurrent(file, context);
|
|
io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(document, null, 2)}\n` : formatList(document));
|
|
return;
|
|
}
|
|
|
|
const expected = parsed.values["--revision"];
|
|
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
|
|
if (sub === "user") return userVerb(parsed, io, deps, { business, file, context, expected });
|
|
const user = parsed.values["--user"];
|
|
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
|
|
|
|
const result = withLock(file, () => {
|
|
const { document, revision } = readCurrent(file, context);
|
|
if (Number(expected) !== revision) {
|
|
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
|
|
}
|
|
let next;
|
|
let touched;
|
|
if (sub === "grant") {
|
|
const level = parsed.values["--level"];
|
|
if (!parsed.values["--projects"] || !parsed.values["--instances"] || level === undefined) {
|
|
throw new CliError(`grant needs --projects, --instances and --level\n${ACCESS_USAGE}`, 4);
|
|
}
|
|
if (!LEVELS.includes(level)) throw new CliError(`--level takes ${LEVELS.join(", ")}`, 2);
|
|
const id = `g-${randomBytes(4).toString("hex")}`;
|
|
const grant = {
|
|
id,
|
|
user,
|
|
business,
|
|
projects: resolveProjects(list(parsed.values["--projects"]), rows),
|
|
instances: list(parsed.values["--instances"]),
|
|
level,
|
|
};
|
|
if (parsed.values["--expires"] !== undefined) grant.expires = parsed.values["--expires"];
|
|
touched = [id];
|
|
next = { ...document, revision: revision + 1, grants: [...document.grants, grant] };
|
|
} else {
|
|
const only = parsed.values["--grant"];
|
|
const drop = (g) => g.user === user && g.business === business && (only === undefined || g.id === only);
|
|
touched = document.grants.filter(drop).map((g) => g.id);
|
|
if (touched.length === 0) throw new CliError(`refused: no grant for ${user} at ${business} to revoke`, 2);
|
|
next = { ...document, revision: revision + 1, grants: document.grants.filter((g) => !drop(g)) };
|
|
}
|
|
// Every check runs on the new document before a byte is written.
|
|
const checked = checkAccess(next, context);
|
|
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
|
|
return { checked, revision, touched };
|
|
}, deps.fs);
|
|
|
|
receipt(deps.system.dataRoot, {
|
|
at: (deps.now ?? (() => new Date()))().toISOString(),
|
|
verb: sub,
|
|
business,
|
|
user,
|
|
actor: "human",
|
|
revisionBefore: result.revision,
|
|
revisionAfter: result.checked.revision,
|
|
grants: result.touched,
|
|
});
|
|
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
|
|
await reload(deps.transport, business, io);
|
|
}
|
|
|
|
// FED R4 (#1801, design 3.7 trigger 3): after a grant or revoke, the bus re-reads the file and ends
|
|
// the runs a loss leaves uncovered, at once. The write already stands, so a refusal here is exit 0
|
|
// (sage-3139): a nonzero exit invites a retry that would hit revision-conflict.
|
|
async function reload(transport, business, io) {
|
|
try {
|
|
await transport(business)("access.reload", {});
|
|
} catch (error) {
|
|
io.stderr.write(`access.reload refused: ${error.code ?? error.message}; the bus's timer applies the change within 30 s\n`);
|
|
}
|
|
}
|
|
|
|
// `user add ID` and `user remove ID` (row 193c): the same lock, revision check, file checks,
|
|
// atomic write and receipt as grant and revoke. add takes one subject, PROVIDER:SUB, split at the
|
|
// first colon, so a sub may hold colons. remove refuses while any grant names the user, at any
|
|
// business, so a user is never removed with live grants (the owner revokes first, as the help says).
|
|
async function userVerb(parsed, io, deps, { business, file, context, expected }) {
|
|
const [, action, user] = parsed.positional;
|
|
let entry;
|
|
if (action === "add") {
|
|
const home = parsed.values["--home"];
|
|
const subject = parsed.values["--subject"] ?? "";
|
|
const colon = subject.indexOf(":");
|
|
if (home === undefined || colon < 1 || colon === subject.length - 1) {
|
|
throw new CliError(`user add needs --home and --subject PROVIDER:SUB\n${ACCESS_USAGE}`, 4);
|
|
}
|
|
entry = { id: user, home, subjects: [{ provider: subject.slice(0, colon), sub: subject.slice(colon + 1) }] };
|
|
}
|
|
const result = withLock(file, () => {
|
|
const { document, revision } = readCurrent(file, context);
|
|
if (Number(expected) !== revision) {
|
|
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
|
|
}
|
|
let users;
|
|
if (action === "add") users = [...document.users, entry];
|
|
else {
|
|
if (!document.users.some((u) => u.id === user)) throw new CliError(`refused: no user ${user} to remove`, 2);
|
|
const held = document.grants.filter((g) => g.user === user).map((g) => g.id);
|
|
if (held.length) throw new CliError(`refused: user-has-grants: ${user} still holds ${held.join(", ")}; revoke them first`, 2);
|
|
users = document.users.filter((u) => u.id !== user);
|
|
}
|
|
const checked = checkAccess({ ...document, revision: revision + 1, users }, context);
|
|
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
|
|
return { checked, revision };
|
|
}, deps.fs);
|
|
|
|
receipt(deps.system.dataRoot, {
|
|
at: (deps.now ?? (() => new Date()))().toISOString(),
|
|
verb: `user.${action}`,
|
|
business,
|
|
user,
|
|
actor: "human",
|
|
revisionBefore: result.revision,
|
|
revisionAfter: result.checked.revision,
|
|
grants: [],
|
|
});
|
|
io.stdout.write(`access user ${action}: ${user}, revision ${result.checked.revision}\n`);
|
|
}
|
|
|
|
function formatList(doc) {
|
|
const lines = [`access file revision ${doc.revision ?? "none"}`];
|
|
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
|
|
for (const g of doc.grants) {
|
|
lines.push(`grant ${g.id} user ${g.user} business ${g.business} projects ${g.projects.join(",")} instances ${g.instances.join(",")} level ${g.level}${g.expires ? ` expires ${g.expires}` : ""}`);
|
|
}
|
|
return `${lines.join("\n")}\n`;
|
|
}
|