Compare commits

...
4 changed files with 340 additions and 2 deletions
+138
View File
@@ -0,0 +1,138 @@
# Row 207 build notes: 193c, `mosaic access user` verbs and `--level viewer` (#1705)
Builder Megavolt, reviewer Duckworth, gate owner Sage (sage-2790, Q220, mos-1301). Branch megavolt/row207 off
aca16404c. The brief is "Brief: 193c, mosaic access user verbs and --level viewer" in
docs/plans/2026-10-20_human-users-build.md (blob c74af2510). Its gate: Duckworth approves the manifest, the cli
suite and every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
## Sources
- The brief, quoted: "`mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5 already
has all of them, this row records that and closes with no code."
- Morgana's row 193 design, section 4.5 and section 8 (agents/morgana/work/queue-193/DESIGN.md at e2d161e2e on
morgana/row193): `user add <id> --home local --subject <provider>:<sub>`, `user remove <id>`, `--level viewer`
on `grant`, a receipt line in `<dataRoot>/state/access-log.jsonl` and the `access.reload` call.
- R5 as it stands at aca16404c: packages/cli/src/access.mjs, packages/cli/tests/access.test.mjs,
packages/business/src/access.mjs (checkAccess, read only here), packages/bus/src/broker.mjs and runtime.mjs.
## 1. What R5 already has
- `grant --level viewer`: present. LEVELS is `["collaborator", "viewer"]` (packages/business/src/vocabulary.mjs),
grant checks `--level` against it, and access.test.mjs already grants at viewer. This row adds no code for it
and records it here.
- `user add` and `user remove`: absent. accessVerb takes only list, grant and revoke, and the only way to add a
user today is to hand-edit access.json. This row adds them.
- `access.reload`: absent everywhere. No bus verb has that name. The R5 brief says "R4 later makes these verbs
call `access.reload`, so R5 leaves no other reload path", FED R3's brief leaves it to R4, and access.mjs's
proveHuman says "Row R4 swaps this call for access.reload". FED R4 is not filed.
## 2. Decisions (Q items for Sage and Duckworth, stated as assumptions, not blockers)
- Q1, the reload call. 193c adds no `access.reload` call, because there is no verb to call. The user verbs take
R5's path exactly: the same `project.list` human proof, and R4 swaps the proof for `access.reload` on all five
verbs at once. Nothing is lost meanwhile: runtime.mjs hands the broker `() => loadAccess(...)`, and the broker
reads the file once per user request (broker.mjs:384), so a written change acts on the next request. If Sage
or Duckworth reads the brief as "add `access.reload` here", that is R4's bus verb and a scope change.
- Q2, remove with grants. `user remove` refuses with `user-has-grants`, exit 2, naming the grant ids, while any
grant at any business names the user. The other reading drops the user's grants in the same revision. Refusing
is the smaller, fail-closed choice: nothing disappears that the owner did not name, and the help text already
says to revoke first. checkAccess would also refuse the file (a grant naming a missing user), but with a key
path, not a reason, and exit 3.
- Q3, the subject. `--subject PROVIDER:SUB` splits at the first colon, so a sub may hold colons. One subject per
add, because parse keeps one value per flag. A second subject is a later row if anyone needs it; checkAccess
allows several.
- Q4, `--business`. Required on the user verbs too. The user list is instance-wide, but the human proof runs
through one business's bus, and the receipt names it.
- Q5, the receipt. `{at, verb: "user.add" | "user.remove", business, user, actor: "human", revisionBefore,
revisionAfter, grants: []}`: grant and revoke's shape, with an empty grants list.
- `user remove` of an id not in the file refuses, exit 2, and writes nothing, as revoke of nothing does.
- `user add` of a duplicate id, a reserved id (human, tester), a business's human, an unknown provider, a
non-local home, an owner sso subject or a subject another user holds refuses through checkAccess, exit 3,
before any write. The verb does not repeat those checks.
### Rulings (sage-2810, 16:40Z, cc Duckworth)
- Q1 agreed: no reload call. The reason is broker.mjs:384-386, the `#access` field's comment: "() => the checked
access document (R1's loadAccess), read once per user request", wired in runtime.mjs:263 as
`() => loadAccess(access.file, access.context)`. A write acts on the next request. FED R4 owns any reload verb.
- Q2 agreed: `user remove` refuses with `user-has-grants`, exit 2, naming the grant ids, and writes nothing.
Dropping the grants silently would make removal revoke access without saying so.
- Usage and help pins: grep the tests for the changed strings, sha256 and snapshot pins included, and run every
reader whole (section 6).
- Row 203 (Quackerjack) does not touch access.mjs; its stale help lines stay a follow-up, so no overlap.
### The usage and help pin sweep (sage-2810), on aca16404c plus this row
- Source readers of ACCESS_USAGE and ACCESS_HELP: packages/cli/src/access.mjs only (cli.mjs imports
accessCommand and accessContextFor, not the strings).
- `git grep` over 'packages/*/tests/*' and 'scripts/*' for ACCESS_USAGE, ACCESS_HELP, "mosaic access",
"access list", "access grant", "access revoke", "Access (FED" and the help's "revoke them here first": the only
hits are packages/cli/tests/access.test.mjs (positive control: `accessCommand` hits it twice).
- Hash pins: no test file names packages/cli/README.md or packages/cli/src/access.mjs next to a sha256 or a
64-hex value. The one 64-hex file that mentions a README (packages/queue/tests/fixtures/queue-marked.md) names
packages/queue/README.md and packages/ledger/README.md, not this one. scripts/stack-copy.sh copies
packages/cli/README.md; stack-copy.test.mjs builds its own fake repo and is in the cli package, run whole.
- packages/business/tests/access*.test.mjs import packages/business/src/access.mjs, a different file, unchanged.
- So the readers are access.test.mjs, run whole in step 1, and the cli package whole in step 2.
## 3. The change (code and docs)
- packages/cli/src/access.mjs: two ACCESS_USAGE lines and one help line; accessVerb accepts `user add ID` and
`user remove ID` (three positionals) and hands off to `userVerb` after the shared checks (tester, business,
proof, file, context, revision). `userVerb` takes the lock, checks the revision, builds the new users list,
runs checkAccess on the whole document, writes atomically and appends the receipt.
- packages/cli/README.md: the usage block and one paragraph.
- No change to packages/business, packages/bus or cli.mjs (it already routes `access` to accessCommand).
## 4. Tests (packages/cli/tests/access.test.mjs, whole file)
Manifest check against Morgana's row 275 (sage-2790), 10-22: morgana/row275 has no commits past aca16404c, and
row 275's brief ("193b1b, viewer verbs and board refusals") is bus only: the viewer verb list, `covers` level
checks and board refusals, gated on the bus suite. This row touches only packages/cli (access.mjs, README,
access.test.mjs) and this file, so no path is shared on the briefs. I recheck the paths when Morgana pushes.
- T1 `user add` writes the user (id, home, one subject), bumps the revision by one, prints the line and appends
one receipt with verb `user.add` and `grants: []`. A sub with a colon (`authentik:bob:x`) keeps `bob:x`.
- T2 `user add` refusals before any write, file and receipts unchanged: missing `--home` or `--subject`, a
subject with no colon, an empty provider or sub (exit 4); an unknown provider, a duplicate id, `tester`, the
business's human, an owner sso subject, a subject alice already holds, a non-local home (exit 3).
- T3 `user remove` of a user with no grants drops them, bumps the revision, receipt `user.remove`.
- T4 `user remove` of a user with a grant refuses `user-has-grants` naming the grant id, exit 2, nothing written.
- T5 `user remove` of an id not in the file refuses, exit 2, nothing written.
- T6 a stale `--revision` refuses `revision-conflict` on both user verbs, exit 3.
- T7 `--tester` and an agent env (MOSAIC_RUN_ID) refuse both user verbs, exit 3, and the fake human CLI is never
called.
- T8 a bad shape (`user`, `user add`, `user list x`, `user add a b`) prints the help, exit 4.
- T9 a bus that refuses the human proof (the fixture's `mode: "refuse"`) blocks both user verbs: exit 3, the
file and receipts unchanged.
## 5. Mutants
Each runs the whole access.test.mjs and counts as killed only on an assertion in the named test.
- M1 drop the `user-has-grants` refusal. Second fence: checkAccess then refuses the file with exit 3, so T4 must
assert the `user-has-grants` text and exit 2, not only "nothing written". Killed by T4.
- M2 drop the "no user to remove" check. The write then lands with an unchanged user list. Killed by T5.
- M3 split the subject at the last colon. Killed by T1's colon case.
- M4 skip checkAccess in userVerb (write the unchecked document). Killed by T2's exit-3 cases.
- M5 drop the receipt in userVerb. Killed by T1 and T3.
- M6 drop the revision check in userVerb. Killed by T6.
- M7 exempt the user verbs from the `--tester` check (`&& sub !== "user"`). Killed by T7's tester case.
- M8 skip proveHuman for the user verbs (`if (sub !== "user")`). Killed by T9. T7's agent case does not reach it: accessCommand
calls refuseInsideAgent before accessVerb, so an agent env is refused whatever the order inside.
## 6. Gate plan (mos-1275)
On Sage's CLEAR, from a rebase onto 253's landing under a new branch name, stopping at the first red:
1. access.test.mjs whole, then M1-M8, each under `timeout -k 10 180`.
2. The cli package whole.
3. Every `scripts/test-*.sh` as `timeout -k 10 LIMIT bash scripts/test-X.sh`, no script edited. LIMIT is row
281's table (Gander's row 278 limits, 3x the longest of three records plus 120 s): auth 126, conductor 145,
config 130, discord 147, extension-package 130, foundation 240, land-work 158, queue 191, release 157,
task 231 seconds. The base has the same ten scripts. Any timeout goes to Sage, then Mos, unplaced.
## Runs
Filled in after the runs.
+10
View File
@@ -1084,6 +1084,8 @@ prints a token. A usage error exits 4 and a bus refusal 2.
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
```
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
@@ -1107,6 +1109,14 @@ once. Removing a provider from `console.sso` takes effect only after a Console r
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
`user add` and `user remove` (row 193c, #1705) take the same proof, lock, revision check, file
checks and receipt. `user add` takes one sign-in subject, `PROVIDER:SUB`, split at the first colon;
the provider must be in `console.sso`. `user remove` refuses with `user-has-grants` while any grant,
at any business, names the user: revoke those first. `--business` names the business whose bus
takes the proof; the user list itself is instance-wide. Their receipt verbs are `user.add` and
`user.remove`. The bus has no `access.reload` yet (FED R4). As with grant and revoke, the broker
reads the file once per user request (`packages/bus/src/broker.mjs`), so the change acts at once.
## Token usage
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
+55 -2
View File
@@ -1,4 +1,5 @@
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
// `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
@@ -18,6 +19,8 @@ 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
@@ -33,6 +36,7 @@ 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.
user remove refuses while the user has a grant at any business: revoke those first.
Peer homes are not read yet (row 203 adds the federation key), so a grant that
names a peer home refuses. A grant that names a sign-in subject needs console.sso
in the system config; without it every such grant refuses.
@@ -206,7 +210,8 @@ export async function accessCommand(args, io, deps) {
async function accessVerb(args, io, deps) {
const parsed = parse(args);
const sub = parsed.positional[0];
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
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);
@@ -224,6 +229,7 @@ async function accessVerb(args, io, deps) {
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);
@@ -278,6 +284,53 @@ async function accessVerb(args, io, deps) {
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\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}`);
+137
View File
@@ -312,3 +312,140 @@ test("the real human-cli, run from agent ancestry, refuses and writes nothing",
assert.equal(readFileSync(file, "utf8"), before);
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
});
// Row 193c (#1705): `user add` and `user remove`. BUILD.md (agents/megavolt/work/queue-207) names
// each test T1-T9 and the mutant it kills.
const USER_ADD = (id, { subject = "authentik:bob-sub", home = "local", revision = "1" } = {}) => [
"user", "add", id, "--business", "acme", "--home", home, "--subject", subject, "--revision", revision,
];
const USER_REMOVE = (id, revision = "1") => ["user", "remove", id, "--business", "acme", "--revision", revision];
test("T1 user add writes the user one revision up, prints it and appends a user.add receipt", async (t) => {
const s = setup(t);
const out = await run(USER_ADD("bob", { subject: "authentik:bob:x" }), s.deps);
assert.match(out, /access user add: bob, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.deepEqual(doc.users[1], { id: "bob", home: "local", subjects: [{ provider: "authentik", sub: "bob:x" }] });
assert.deepEqual(doc.users[0], DOC.users[0]);
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(lines.length, 1);
assert.deepEqual(lines[0], {
at: "2026-10-20T20:00:00.000Z", verb: "user.add", business: "acme", user: "bob", actor: "human",
revisionBefore: 1, revisionAfter: 2, grants: [],
});
});
test("T2 a bad user add refuses before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const usage = [
["user", "add", "bob", "--business", "acme", "--subject", "authentik:bob-sub", "--revision", "1"],
["user", "add", "bob", "--business", "acme", "--home", "local", "--revision", "1"],
USER_ADD("bob", { subject: "authentik" }),
USER_ADD("bob", { subject: ":bob-sub" }),
USER_ADD("bob", { subject: "authentik:" }),
];
for (const args of usage) {
const err = await refusal(run(args, s.deps), /user add needs --home and --subject PROVIDER:SUB/);
assert.equal(err.exitCode, 4, args.join(" "));
}
const checked = [
[USER_ADD("bob", { subject: "github:bob-sub" }), /provider must be a configured provider/],
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id: alice/],
[USER_ADD("tester"), /a bus principal/],
[USER_ADD("jason"), /a business's human/],
[USER_ADD("bob", { subject: "authentik:jason-sub" }), /owner sign-in subject/],
[USER_ADD("bob", { subject: "authentik:alice-sub" }), /appears more than once/],
[USER_ADD("bob", { home: "peer-one" }), /home must be a configured peer/],
];
for (const [args, pattern] of checked) {
const err = await refusal(run(args, s.deps), pattern);
assert.equal(err.exitCode, 3, args.join(" "));
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T3 user remove of a user with no grant drops them one revision up, with a user.remove receipt", async (t) => {
const s = setup(t);
const out = await run(USER_REMOVE("alice"), s.deps);
assert.match(out, /access user remove: alice, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.deepEqual(doc.users, []);
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(line.verb, "user.remove");
assert.equal(line.user, "alice");
assert.equal(line.revisionBefore, 1);
assert.equal(line.revisionAfter, 2);
assert.deepEqual(line.grants, []);
});
// M1's second fence: without the user-has-grants refusal, checkAccess refuses the file with exit 3
// (a grant naming a missing user). The test pins the reason and exit 2, so only the verb's refusal passes.
test("T4 user remove refuses with user-has-grants while the user holds a grant, and writes nothing", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
const id = s.read().grants[0].id;
const before = readFileSync(s.file, "utf8");
const err = await refusal(run(USER_REMOVE("alice", "2"), s.deps), /user-has-grants: alice still holds /);
assert.equal(err.exitCode, 2);
assert.ok(err.message.includes(id));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts().trim().split("\n").length, 1);
});
test("T5 user remove of an id not in the file refuses and writes nothing", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run(USER_REMOVE("carol"), s.deps), /no user carol to remove/);
assert.equal(err.exitCode, 2);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T6 a stale revision refuses both user verbs with revision-conflict", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob", { revision: "2" }), USER_REMOVE("alice", "5")]) {
const err = await refusal(run(args, s.deps), /revision-conflict/);
assert.equal(err.exitCode, 3);
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T7 --tester and an agent caller are refused on both user verbs before the bus is asked", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob"), USER_REMOVE("alice")]) {
const tester = await refusal(run([...args, "--tester"], s.deps), /not --tester/);
assert.equal(tester.exitCode, 3);
const agent = await refusal(run(args, s.deps, { ...humanEnv(), MOSAIC_RUN_ID: "run-1" }), /human shell/);
assert.equal(agent.exitCode, 3);
}
assert.equal(existsSync(s.log), false);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("T8 a user verb of the wrong shape prints the help", async (t) => {
const s = setup(t);
for (const shape of [["user"], ["user", "add"], ["user", "list", "x"], ["user", "add", "a", "b"]]) {
const err = await refusal(run([...shape, "--business", "acme", "--revision", "1"], s.deps), /mosaic access user add ID/);
assert.equal(err.exitCode, 4, shape.join(" "));
}
assert.equal(existsSync(s.log), false);
});
test("T9 a bus that refuses the human proof blocks both user verbs", async (t) => {
const s = setup(t, { mode: "refuse" });
const before = readFileSync(s.file, "utf8");
for (const args of [USER_ADD("bob"), USER_REMOVE("alice")]) {
const err = await refusal(run(args, s.deps), /human-required/);
assert.equal(err.exitCode, 3);
}
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});