Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b008eed728 | ||
|
|
7fd265033b | ||
|
|
48a3fb96e0 | ||
|
|
959bfb2752 | ||
|
|
030809e15a | ||
|
|
35aaceac97 | ||
|
|
44e36fc4c6 | ||
|
|
80fe310713 | ||
|
|
2d0aa29374 | ||
|
|
ae3c36f248 | ||
|
|
a2af7ab495 |
@@ -23,6 +23,8 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
|
||||
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
|
||||
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
|
||||
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
|
||||
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
|
||||
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
|
||||
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Gizmoduck
|
||||
|
||||
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
|
||||
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
|
||||
access.json loader and validator, under Mos's order mos-1023 item 1.
|
||||
|
||||
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
|
||||
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
|
||||
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Row 201 round 1 (#1699): FED R1, the access.json loader and validator
|
||||
|
||||
Gizmoduck, 2026-10-20. Reviewer Duckworth, gate owner Sage. Federation design #414.
|
||||
|
||||
- Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, section "Brief: FED R1", at a2af7ab49.
|
||||
- Design: `agents/gizmoduck/work/federation/DESIGN.md` section 3.5, r4.9 a1daa3c07 on
|
||||
`gizmoduck/federation-design`.
|
||||
- Base: ae3c36f24 (`origin/refactor` when I branched, contains the brief). Branch `gizmoduck/fed-r1`.
|
||||
- Candidate: the commit that adds this file. `candidate-manifest.sha256` lists the three code files.
|
||||
|
||||
## Interface
|
||||
|
||||
This is what R5 and row 193a build against. Any change to it in review goes to Sage before it lands.
|
||||
Morgana confirmed it (morgana-0014) and Sage accepted it with `ssoSubjects` required (sage-1399). Sage
|
||||
relays it to Quackerjack.
|
||||
|
||||
| Export | Shape |
|
||||
|--------|-------|
|
||||
| `LEVELS` | frozen `["collaborator"]`, the one allowed level set. 193a widens it with the single line `["collaborator", "viewer"]` |
|
||||
| `accessFilePath(dir = configDir())` | `<dir>/access.json` |
|
||||
| `checkAccess(doc, { providers, peers, businesses, ssoSubjects })` | pure, returns a frozen deep copy, throws `BusinessError` exit 2 naming the key path |
|
||||
| `loadAccess(file, context, { uid = process.getuid() } = {})` | one no-follow read through `readJsonFile`, then the stat checks, then `checkAccess`. `uid` is a test seam |
|
||||
| `covers(grant, { business, project, instance }, now, { level } = {})` | boolean. `now` must be a valid `Date`, otherwise TypeError |
|
||||
|
||||
The context: `providers` is an array of provider ids, `peers` an array of peer ids (never `local`),
|
||||
`businesses` maps a business id to `{ human, instances }`, and `ssoSubjects` is an array of
|
||||
`{ provider, sub }`, the owner's `console.sso.subjects`. A missing or malformed context refuses.
|
||||
All four are exported from `src/index.mjs` too.
|
||||
|
||||
## Rules, code and tests
|
||||
|
||||
DESIGN 3.5 rule, where `access.mjs` enforces it, and the test that shows the refusal names the key path.
|
||||
|
||||
| 3.5 rule | access.mjs | access.test.mjs test | Mutant |
|
||||
|----------|-----------|----------------------|--------|
|
||||
| regular file, not a symlink | 167 (`readJsonFile`, util.mjs:66-89) | "loadAccess refuses a missing file, a symlink and a directory" | M03 |
|
||||
| owned by the instance's uid | 168 | "loadAccess refuses another owner and group or other write" | M01 |
|
||||
| no group or other write | 169 | same | M02 |
|
||||
| unknown keys refuse | 147, 71, 83, 121 | "top level", "users: unknown keys", "users: subjects", "grants: unknown keys" | M05 M10 M16 M22 |
|
||||
| `accessVersion` is 1 | 148 | "top level" | M06 |
|
||||
| `revision` positive integer | 149 | "top level" | M07 |
|
||||
| user id matches the id rule | 72 | "users: unknown keys and the id rule" | M11 |
|
||||
| never `human` or `tester` | 73 | "users: human, tester and any business's human" | M12 |
|
||||
| never any business's `human` | 74 | same, both businesses | M13 |
|
||||
| provider is a given provider | 84-86 | "users: subjects, provider, sub" | M17 |
|
||||
| a pair appears once in the file | 90 | same, within one user and across two | M20 |
|
||||
| a pair is never in `console.sso.subjects` | 89 | same | M19 |
|
||||
| `home` is a given peer or `local` | 75-77 | "users: home is a given peer or local" | M14 |
|
||||
| grant names a declared business | 126-128 | "grants: unknown keys, id, user and business" | M26 |
|
||||
| `projects` is `["*"]` or UUIDs | 98-107, 130-132 | "grants: projects" | M27 M28 M29 M30 |
|
||||
| `instances` is `["*"]` or declared instances | 98-107, 133-135 | "grants: instances" | M31 |
|
||||
| `level` is `collaborator` | 136 | "grants: any level other than collaborator" (missing, null, wrong case) | M32 |
|
||||
| `expires` optional ISO time | 111-117, 137 | "grants: expires is optional ISO time" | M33 M34 |
|
||||
| past `expires` is absent | covers 179 | "covers: an expired grant doesn't match, at and after the instant" | M38 M39 |
|
||||
|
||||
`covers`: `*` and listed ids match (180-182), an unlisted project, instance or business doesn't, an
|
||||
expired grant doesn't (179), and a level filter rejects another level (183). Mutants M38 to M44.
|
||||
|
||||
## Deviations from the brief
|
||||
|
||||
1. Expiry. The brief's test list says expiry refuses. DESIGN 3.5 line 257 says a grant past `expires` is
|
||||
treated as absent. Sage ruled the design wins (sage-1399), so a past `expires` passes `checkAccess`
|
||||
and `covers` returns false at and after the instant. A malformed `expires` still refuses.
|
||||
2. The brief lists "a grant disjoint from the given subjects". 3.5 has no rule that ties a grant to a
|
||||
subject, and a grant names a user, not a subject. I read it as the 3.5 lines 248-249 subject rules:
|
||||
a `(provider, sub)` pair that is also in `ssoSubjects` refuses, and so does a pair that appears twice.
|
||||
Duckworth, say if you meant something else.
|
||||
|
||||
## Rules I added beyond 3.5
|
||||
|
||||
Each one is fail closed and has a test and a mutant.
|
||||
|
||||
- User ids and grant ids are unique (154, 123; M21, M24). A grant's `user` names a user in the file
|
||||
(125; M25). Without these, a grant could point at nobody or two users could share an id.
|
||||
- `projects` and `instances` are non-empty, distinct, and `*` never mixes with ids (98-106).
|
||||
- `expires` must be UTC with a `Z` and must round-trip (`2026-02-30T00:00:00Z` refuses). `Date.parse`
|
||||
accepts that date and rolls it to March 2, so the regex alone wasn't enough.
|
||||
- The context is checked too (43-67; M35 to M37). `peers` can't name `local`, because that would make a
|
||||
peer user look local.
|
||||
- `covers` throws a TypeError for a `now` that isn't a valid Date (M44), since a bad clock would
|
||||
otherwise make every grant look unexpired.
|
||||
|
||||
## Checks
|
||||
|
||||
- `node --test packages/business/tests/access.test.mjs`: 24 pass, 0 fail (single file, no slot).
|
||||
- Morgana's `access-levels.test.mjs` at 08bb4d827, as a cross-check, not part of the candidate. On this
|
||||
candidate it passes 3 of 4, and the one failure is the viewer case 193a adds. In a scratch copy with
|
||||
`LEVELS` widened to `["collaborator", "viewer"]` it passes 4 of 4.
|
||||
- `mutants.py`: 44 mutants, one per refusal and covers branch. All 44 anchors match once and every
|
||||
mutant passes `node --check`. **Not run yet**: mutants need Sage's slot. Run as
|
||||
`python3 -I agents/gizmoduck/work/queue-201/mutants.py <checkout> <empty scratch dir>`. It runs a control
|
||||
first, puts each run in its own process group with a 60 s timeout, and exits 1 if any mutant survives.
|
||||
- `node tools/unslop-hook/unslop-check.js` is clean on all three code files and this file.
|
||||
- No test reads or pins `src/index.mjs` by hash (grep of packages/*/tests and scripts/test-*.sh).
|
||||
- Not run: the business suite and `scripts/test-*.sh`. Both need Sage's slot.
|
||||
|
||||
## Boundaries kept
|
||||
|
||||
Nothing touched `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any access.json. Tests write
|
||||
only under `mkdtemp` scratch dirs.
|
||||
@@ -0,0 +1 @@
|
||||
ae3c36f24883e870541158d3ade1f5d3ef2ab856
|
||||
@@ -0,0 +1,3 @@
|
||||
74ac7e9ed38c230e60e7f7ece373104446c362b6fce0a4f92ca2210a981dc4f4 packages/business/src/access.mjs
|
||||
7ee59b6e792fb47048fd06d227b65467ef71dabfcbdabdc0527b780656323d20 packages/business/src/index.mjs
|
||||
3bec097f55909b31faf2bbc62c81e1d4d0290f50e696d3d3d367e77d82c5b238 packages/business/tests/access.test.mjs
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Row 201 (FED R1) mutants: one per refusal in packages/business/src/access.mjs.
|
||||
|
||||
Usage: mutants.py <checkout> <scratch-dir>
|
||||
|
||||
Copies packages/business and packages/plugins from <checkout> into
|
||||
<scratch-dir>, applies one mutant at a time (each anchor must match exactly
|
||||
once), runs `node --test tests/access.test.mjs` in its own process group with
|
||||
a timeout, and expects it to fail. Prints one line per mutant and exits 1 if
|
||||
any survives, an anchor misses, or the unmutated control fails. Needs Sage's
|
||||
slot. Writes nothing outside <scratch-dir>.
|
||||
"""
|
||||
import os, shutil, signal, subprocess, sys
|
||||
|
||||
MUTANTS = [
|
||||
# loadAccess file checks
|
||||
("M01 owner check", 'if (stat.uid !== uid) refuse(', 'if (false) refuse('),
|
||||
("M02 group/other write check", 'if ((stat.mode & 0o022) !== 0) refuse(`access file', 'if (false) refuse(`access file'),
|
||||
("M03 one-fd nofollow read", 'const document = readJsonFile(file, "access file", (stat) => {',
|
||||
'const document = JSON.parse(readFileSync(file, "utf8")); ((stat) => {'),
|
||||
# top level
|
||||
("M04 document object", 'if (!isPlainObject(document)) refuse("access file must be a JSON object");', ''),
|
||||
("M05 top unknown keys", 'rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");', ''),
|
||||
("M06 accessVersion", 'if (document.accessVersion !== 1) refuse(', 'if (false) refuse('),
|
||||
("M07 revision", 'requirePositiveInt(document.revision, "revision");', ''),
|
||||
("M08 users array", 'requireArray(document.users, "users").forEach(', '(document.users ?? []).forEach('),
|
||||
("M09 grants array", 'requireArray(document.grants, "grants").forEach(', '(document.grants ?? []).forEach('),
|
||||
# users
|
||||
("M10 user unknown keys", 'rejectUnknownKeys(user, ["id", "home", "subjects"], at);', ''),
|
||||
("M11 user id rule", 'requireId(user.id, `${at}.id`);', ''),
|
||||
("M12 human/tester", 'if (RESERVED_USERS.includes(user.id)) refuse(', 'if (false) refuse('),
|
||||
("M13 business human", 'if (humans.has(user.id)) refuse(', 'if (false) refuse('),
|
||||
("M14 home", 'if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {', 'if (false) {'),
|
||||
("M15 subjects non-empty", 'if (subjects.length === 0) refuse(', 'if (false) refuse('),
|
||||
("M16 subject unknown keys", 'rejectUnknownKeys(s, ["provider", "sub"], sat);', ''),
|
||||
("M17 provider", 'if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {', 'if (false) {'),
|
||||
("M18 sub", 'requireText(s.sub, `${sat}.sub`);', ''),
|
||||
("M19 owner subject", 'if (ctx.owners.has(key)) refuse(', 'if (false) refuse('),
|
||||
("M20 duplicate subject", 'if (seenSubjects.has(key)) refuse(', 'if (false) refuse('),
|
||||
("M21 duplicate user id", 'if (users.has(user.id)) refuse(', 'if (false) refuse('),
|
||||
# grants
|
||||
("M22 grant unknown keys", 'rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);', ''),
|
||||
("M23 grant id rule", 'requireId(grant.id, `${at}.id`);', ''),
|
||||
("M24 duplicate grant id", 'if (seenGrants.has(grant.id)) refuse(', 'if (false) refuse('),
|
||||
("M25 grant user", 'if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(', 'if (false) refuse('),
|
||||
("M26 grant business", 'if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {', 'if (false) {'),
|
||||
("M27 scope non-empty", 'if (list.length === 0) refuse(`${where} must not be empty`);', ''),
|
||||
("M28 scope * alone", 'if (list.length !== 1) refuse(', 'if (false) refuse('),
|
||||
("M29 scope duplicate", 'if (seen.has(entry)) refuse(', 'if (false) refuse('),
|
||||
("M30 project UUID", 'if (typeof p !== "string" || !UUID.test(p)) refuse(', 'if (false) refuse('),
|
||||
("M31 declared instance", 'if (typeof x !== "string" || !business.instances.has(x)) refuse(', 'if (false) refuse('),
|
||||
("M32 level", 'if (!LEVELS.includes(grant.level)) refuse(', 'if (false) refuse('),
|
||||
("M33 expires format", 'if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {', 'if (typeof value !== "string") {'),
|
||||
("M34 expires real time", 'if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(', 'if (false) refuse('),
|
||||
# context
|
||||
("M35 context unknown keys", 'rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);', ''),
|
||||
("M36 context peers local", 'if (peers.has(LOCAL_HOME)) refuse(', 'if (false) refuse('),
|
||||
("M37 context ssoSubjects array", 'requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach(', '(context.ssoSubjects ?? []).forEach('),
|
||||
# covers
|
||||
("M38 covers expiry", 'if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;', ''),
|
||||
("M39 covers expiry boundary", 'now.getTime() >= Date.parse(grant.expires)', 'now.getTime() > Date.parse(grant.expires)'),
|
||||
("M40 covers business", 'if (grant.business !== business) return false;', ''),
|
||||
("M41 covers project", 'if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;', ''),
|
||||
("M42 covers instance", 'if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;', ''),
|
||||
("M43 covers level", 'if (level !== undefined && grant.level !== level) return false;', ''),
|
||||
("M44 covers now check", 'if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw', 'if (false) throw'),
|
||||
]
|
||||
|
||||
TIMEOUT = 60
|
||||
|
||||
|
||||
def run(cwd):
|
||||
proc = subprocess.Popen(["node", "--test", "tests/access.test.mjs"], cwd=cwd,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try:
|
||||
out, _ = proc.communicate(timeout=TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.communicate()
|
||||
return None, "timeout"
|
||||
return proc.returncode, out.decode(errors="replace")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit("usage: mutants.py <checkout> <scratch-dir>")
|
||||
checkout, scratch = (os.path.abspath(a) for a in sys.argv[1:])
|
||||
if not os.path.isfile(os.path.join(checkout, "packages/business/src/access.mjs")):
|
||||
sys.exit(f"no packages/business/src/access.mjs under {checkout}")
|
||||
if os.path.exists(scratch) and os.listdir(scratch):
|
||||
sys.exit(f"scratch dir not empty: {scratch}")
|
||||
for pkg in ("business", "plugins"):
|
||||
shutil.copytree(os.path.join(checkout, "packages", pkg), os.path.join(scratch, "packages", pkg))
|
||||
pkg = os.path.join(scratch, "packages/business")
|
||||
target = os.path.join(pkg, "src/access.mjs")
|
||||
original = open(target).read()
|
||||
|
||||
code, out = run(pkg)
|
||||
if code != 0:
|
||||
print(out)
|
||||
sys.exit(f"control failed (exit {code}): the unmutated tests must pass")
|
||||
print("control: pass")
|
||||
|
||||
bad = 0
|
||||
for name, old, new in MUTANTS:
|
||||
n = original.count(old)
|
||||
if n != 1:
|
||||
print(f"{name}: ANCHOR {n}")
|
||||
bad += 1
|
||||
continue
|
||||
mutated = original.replace(old, new)
|
||||
if name.startswith("M03"):
|
||||
mutated = 'import { readFileSync } from "node:fs";\n' + mutated
|
||||
open(target, "w").write(mutated)
|
||||
code, _ = run(pkg)
|
||||
open(target, "w").write(original)
|
||||
if code == 0:
|
||||
print(f"{name}: SURVIVED")
|
||||
bad += 1
|
||||
else:
|
||||
print(f"{name}: killed ({'timeout' if code is None else f'exit {code}'})")
|
||||
print(f"mutants {len(MUTANTS)}, not killed {bad}")
|
||||
sys.exit(1 if bad else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
# Quackerjack
|
||||
|
||||
Builder seat, Claude Haiku 5.5 at high effort, started by Sage under mos-1023 item 1 and Q177 for federation
|
||||
rows R5 (`mosaic access list, grant, revoke`) and R2 (the `federation` config key), per Gizmoduck's design
|
||||
section 7 (#414).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own
|
||||
`quackerjack/*` branches through the repository credential helper (mos-0981), never `refactor`, `main` or
|
||||
another seat's branch.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Row 202 BUILD: FED R5, `mosaic access list|grant|revoke`
|
||||
|
||||
Quackerjack, 2026-10-20. Plan: `PLAN.md` (commit 2d0aa2937). Manifest: `MANIFEST.md` in this directory.
|
||||
Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Reviewer Duckworth. Gate owner Sage.
|
||||
|
||||
## What was built
|
||||
|
||||
- `packages/cli/src/access.mjs`: the three verbs, the human proof, the context (`accessContextFor`), the
|
||||
atomic write, the lock and the receipt.
|
||||
- `packages/cli/src/cli.mjs`: routes `access` to `accessCommand`.
|
||||
- `scripts/mosaic`: sends `access` to the CLI.
|
||||
- `packages/cli/README.md`: the verbs, the human proof, the lock, the `instances` limit, the removal order,
|
||||
and the limits on peers and subjects.
|
||||
- `packages/cli/tests/access.test.mjs`: 21 tests (round 2; 18 in round 1). Single file, green on this branch.
|
||||
|
||||
R1 (`packages/business/src/access.mjs`) is merged in unchanged at 80fe31071, the Duckworth-approved
|
||||
candidate. 44e36fc4c, the next commit on that branch, only removes a stray pyc file.
|
||||
|
||||
## The lock file (not in PLAN.md; added at Sage's request)
|
||||
|
||||
- Path: `<configDir>/access.json.lock`, the access file's path plus `.lock`. Mode 0600.
|
||||
- Created with `O_EXCL` (`wx`). The holder writes its pid and a newline into it, then holds it for the
|
||||
read, the check, the temp write and the rename. It is removed in a `finally`.
|
||||
- A second writer that finds the lock reads the pid. If the pid is alive (or the pid is reused by a live
|
||||
process), it refuses with `refused: ... being written by another command; try again` (exit 3). Nothing
|
||||
is written.
|
||||
- A lock whose pid is gone (`kill(pid, 0)` returns ESRCH) also refuses, and nothing reclaims it. The
|
||||
refusal names the path and the pid. A human checks that no write is running and removes the file. This
|
||||
is the crash case: the holder died mid-write, and a human clears it.
|
||||
- An empty lock, or one that is not a pid, refuses. Its holder may still be between create and write. A
|
||||
holder that died in that gap leaves an empty lock; a human removes it.
|
||||
|
||||
Reclaim was removed at Sage's ruling (sage-1429, 10-20). It was the one design with a race, so the
|
||||
race below is gone, not accepted.
|
||||
|
||||
Former limit, now removed: reclaim race. The old design let a writer take a stale lock by unlinking it
|
||||
and retaking it. The case that lost: writers A and B both read the same dead pid N. A unlinks the lock
|
||||
and takes it. B then unlinks A's live lock and takes its own. Both read the same revision, both write
|
||||
it, and the second rename silently replaces the first. The revision check did not catch it, because both
|
||||
had read the old revision before either wrote.
|
||||
|
||||
Known limits, stated plainly:
|
||||
1. A dead holder's lock blocks every write until a human removes it. That is the fail-closed price of
|
||||
no reclaim. The refusal says which pid and which path.
|
||||
2. A reused pid reads as alive, so a stale lock refuses. The fix is to remove the file by hand.
|
||||
3. The lock is per access file. It does not cover the bus's own reads.
|
||||
4. Crash-safety of the write path: the temp file is removed on any failure before or at the rename. The
|
||||
directory is not fsynced after the rename, so a power loss right after a rename can lose the rename.
|
||||
The bytes are never mixed.
|
||||
|
||||
Tests for the lock (all in `access.test.mjs`):
|
||||
- "a live holder's lock refuses and leaves the file and the lock alone": pid = this process.
|
||||
- "an empty or unreadable lock refuses": empty file, and `not-a-pid`.
|
||||
- "a lock left by a dead pid refuses, names the pid, and is not reclaimed": a child that has exited. The
|
||||
file, the lock and the receipt are unchanged.
|
||||
- "the lock is mode 0600 while held and removed after a refused write".
|
||||
|
||||
Tests for the write path and the refusals (added in this round):
|
||||
- "a failed rename leaves the old bytes, removes the temp file, and writes no receipt": the fs seam
|
||||
(`deps.fs`) makes `renameSync` throw. Checks the old bytes, no `.access-*.tmp`, no lock, no receipt.
|
||||
- "a group-writable access file refuses with exit 3, names the file, and changes nothing": a 0664 file.
|
||||
Exit 3, the file path in the message, nothing written.
|
||||
- "an agent or tester caller is refused before the context or the access file is read": `contextFor`
|
||||
throws if called, and the file is 0664. The refusal must come first.
|
||||
|
||||
## The human proof, and the bus behavior behind test 10
|
||||
|
||||
The proof is one `project.list` call through `humanTransport`. The bus gates it with `#human`
|
||||
(`packages/bus/src/broker.mjs`, the `project.list` case at :2319, and `#human` at :377). The call is in
|
||||
one function, `proveHuman` in `access.mjs`, for R4 to swap.
|
||||
|
||||
Test 10 runs the real `human-cli.mjs` on a scratch bus from agent ancestry (`CLAUDECODE=1` in the child's
|
||||
env). The bus refuses. The client reports `outcome-unknown`, not `human-required`, because the bus closes
|
||||
the socket on a failed proof and `packages/bus/src/client.mjs:32-33` maps a closed socket to
|
||||
`outcome-unknown`. Exit 1 (`busExit`). The test pins that code and asserts the access file and the
|
||||
receipt are unchanged.
|
||||
|
||||
This is a bus behavior outside R5. It is not changed here. Gizmoduck and Gander own the verifier (row 196).
|
||||
Until it changes, a human who hits the agent refusal sees `outcome-unknown`.
|
||||
|
||||
## Context
|
||||
|
||||
`accessContextFor(system, env)`:
|
||||
- `providers`: `system.console.sso.providers[].id`. Row 192's shape (packages/webui/src/sso-config.mjs on
|
||||
row 192's candidate).
|
||||
- `ssoSubjects`: each provider's `subjects[].sub`, paired with the provider id. Gizmoduck's design says
|
||||
`console.sso.subjects`; Sage ruled that is shorthand for this (erratum).
|
||||
- `peers`: `[]` until row 203 adds the federation key. A grant that names a peer home refuses.
|
||||
- `businesses`: `loadBusiness` for each `<configDir>/businesses/*.json`; `human` and the role names as
|
||||
`instances`. A business file that fails to load fails the command.
|
||||
|
||||
No `console` key gives empty providers and subjects, so every grant naming a subject refuses. The
|
||||
`checkConsole` check from row 192 is not on this branch yet. R5 rebases onto row 192's landing, and then
|
||||
reads `console` through `checkConsole`.
|
||||
|
||||
## Mutant list (one per refusal; run on Sage's go, before review)
|
||||
|
||||
| # | Mutant | Expected kill |
|
||||
|---|---|---|
|
||||
| M1 | remove the `refuseInsideAgent` call | "an agent caller is refused before the bus is asked" |
|
||||
| M2 | remove the `--tester` check | "--tester is refused on every verb" |
|
||||
| M3 | the proof's error is swallowed | "a bus that refuses the human proof blocks the write" |
|
||||
| M4 | the revision compare is removed | "a stale revision refuses with revision-conflict" |
|
||||
| M5 | `takeLock` unlinks a dead-pid lock and retakes it (the reclaim this round removed) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
|
||||
| M6 | `pidAlive` always returns true (a dead pid reads as live) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
|
||||
| M11 | `writeAtomic` writes straight to access.json, no temp file or rename | "a failed rename leaves the old bytes, removes the temp file, and writes no receipt" |
|
||||
| M12 | the BusinessError catch in `accessCommand` is removed | "a group-writable access file refuses with exit 3, names the file, and changes nothing" |
|
||||
| M13 | `contextFor()` is called before the agent and tester refusals | "an agent or tester caller is refused before the context or the access file is read" |
|
||||
| M7 | `checkAccess` is skipped before the write | "a grant naming an unknown instance is refused before any write" |
|
||||
| M8 | the receipt append is removed | "grant writes ... a receipt line" |
|
||||
| M9 | a project name is written as given (no lookup) | "a project name resolves to its id" |
|
||||
| M10 | the lock is not taken | "a live holder's lock refuses" |
|
||||
|
||||
Round 2 closed the direct-write gap: `writeAtomic` takes the fs calls through `deps.fs` (default: the real
|
||||
`node:fs` functions), and a rename that throws is tested (M11 is the direct-write mutant). Not yet run:
|
||||
the mutant batch, the cli suite and `scripts/test-*.sh`. All wait on Sage's slot.
|
||||
|
||||
Round 2 also changed the file's refusal path: a `BusinessError` from `loadAccess` or `loadBusiness` now
|
||||
exits 3 with the file path and key path on stderr, no stack trace (Duckworth B1, Sage sage-1437).
|
||||
|
||||
## Not done in this row
|
||||
|
||||
- `--peers` and the federation key: row 203.
|
||||
- `access.reload`: row R4.
|
||||
- Gizmoduck's `console.sso.subjects` name: erratum, the flattened shape is used.
|
||||
- The suite (`packages/cli` and every `scripts/test-*.sh`) and the mutant batch: on Sage's gate.
|
||||
@@ -0,0 +1,6 @@
|
||||
e2d3e613a8d6f04544904acb2af8aec44dedf9ba6820f1936e1f7d6547be2028 packages/cli/src/access.mjs
|
||||
e956ac5b54844af30ac7717e85b5b60282b2ccf00dfc134d1d5c1d9f3cb44f32 packages/cli/src/cli.mjs
|
||||
de7d15dc6989379a47b1efd4f449e9707004978198f01d704b2d61c8411ed6ca packages/cli/tests/access.test.mjs
|
||||
f50760a67337147e50af032969ea65ca313ac7b76f6239e23e5c0d95431bd4b7 packages/cli/README.md
|
||||
aeddef32c28c883eedf166bcac96d1e6828b71c862e683977e097706339baf86 scripts/mosaic
|
||||
79f2c5344c0d73754804ab58c68edfb82bad7bfef50718550c698f61ae2a8ab5 agents/quackerjack/work/queue-202/BUILD.md
|
||||
@@ -0,0 +1,64 @@
|
||||
# Row 202 manifest: FED R5, mosaic access list, grant, revoke (round 2)
|
||||
|
||||
Candidate: 48a3fb96e on quackerjack/fed-r5 (round 2). Supersedes the round-1 manifest at 959bfb275, which
|
||||
pointed at build 030809e15 and was reviewed by Duckworth as round 1 (changes, B1; REVIEW-r1.md, a76767e87).
|
||||
Base: R1 at 80fe31071, merged; 44e36fc4c and 35aaceac9 on the branch before the build.
|
||||
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Brief: docs/plans/2026-10-20_federation-r1-r2-r5.md, Brief: FED R5.
|
||||
|
||||
## Files (sha256 of the blob at 48a3fb96e)
|
||||
|
||||
```
|
||||
e2d3e613a8d6f04544904acb2af8aec44dedf9ba6820f1936e1f7d6547be2028 packages/cli/src/access.mjs
|
||||
e956ac5b54844af30ac7717e85b5b60282b2ccf00dfc134d1d5c1d9f3cb44f32 packages/cli/src/cli.mjs
|
||||
de7d15dc6989379a47b1efd4f449e9707004978198f01d704b2d61c8411ed6ca packages/cli/tests/access.test.mjs
|
||||
f50760a67337147e50af032969ea65ca313ac7b76f6239e23e5c0d95431bd4b7 packages/cli/README.md
|
||||
aeddef32c28c883eedf166bcac96d1e6828b71c862e683977e097706339baf86 scripts/mosaic
|
||||
79f2c5344c0d73754804ab58c68edfb82bad7bfef50718550c698f61ae2a8ab5 agents/quackerjack/work/queue-202/BUILD.md
|
||||
```
|
||||
|
||||
scripts/mosaic is unchanged since round 1.
|
||||
|
||||
## What changed in round 2
|
||||
|
||||
- Lock: no reclaim. A dead-pid, empty or unreadable lock refuses and names the path (and pid); a human
|
||||
removes it. The reclaim race is gone (Sage sage-1429).
|
||||
- Atomic write: fs calls go through `deps.fs` (default: the real `node:fs` functions). A failed write
|
||||
removes its temp file and leaves the old bytes (Sage sage-1429, the direct-write gap).
|
||||
- BusinessError from loadAccess or loadBusiness: exit 3, file and key path on stderr, no stack trace
|
||||
(Duckworth B1, Sage sage-1437). Sage's ruling is exit 3, so the checkAccess refusals move from exit 2 to
|
||||
exit 3 too.
|
||||
- `contextFor` is a function, built after the agent and tester refusals and the human proof. A refused
|
||||
caller never reads access.json or the business files (Sage sage-1437).
|
||||
- The help text and README say the command runs as the instance's uid (Duckworth N6, Sage sage-1437).
|
||||
|
||||
## Interface used from R1 (80fe31071)
|
||||
|
||||
checkAccess(doc, { providers, peers, businesses, ssoSubjects }), loadBusiness, loadAccess (via readCurrent),
|
||||
accessFilePath is not used (the CLI takes configDir), covers is not used by R5.
|
||||
|
||||
## Named in review
|
||||
|
||||
- Human proof: one project.list call (proveHuman, access.mjs). Gate: packages/bus/src/broker.mjs:2319
|
||||
(project.list case) and :377 (#human). Agent and --tester refusals come before the proof.
|
||||
- Lock: <configDir>/access.json.lock, 0600, pid stamp. Live pid refuses (BUSY); dead, empty or unreadable
|
||||
refuses and names the path. No reclaim. See BUILD.md.
|
||||
- Write: checkAccess on the new document, then temp file, fsync, rename; revision compared inside the lock.
|
||||
- Pinned bus behavior: a failed proof reads as outcome-unknown (client.mjs:32-33). Outside R5.
|
||||
- Context: accessContextFor, built lazily; peers [] until row 203; subjects flattened from
|
||||
console.sso.providers[].subjects.
|
||||
|
||||
## Tests and mutants
|
||||
|
||||
- packages/cli/tests/access.test.mjs: 21 tests, single file green on 48a3fb96e (21/21, run on the candidate).
|
||||
- Mutants M1-M13 in BUILD.md. M11 (direct write) and M12 (no BusinessError catch) and M13 (context built
|
||||
early) are new. Run on Sage's go and slot only.
|
||||
- Not run in this round: the mutant batch, the cli suite and scripts/test-*.sh.
|
||||
|
||||
## Not in this row
|
||||
|
||||
- R2 peers and the federation key (row 203).
|
||||
- R4 access.reload (its brief takes N1: project.list exempts testers, so access.reload needs #human).
|
||||
- row 192 checkConsole (the context reads console.sso directly until 192 lands).
|
||||
- Viewer level (Sage's carry, item 5): whichever of 202 and 204 lands second adds it. Row 204 is not on
|
||||
refactor yet as of 10-20 20:23Z.
|
||||
- Cli suite and scripts/test-*.sh (Sage's gate).
|
||||
@@ -0,0 +1,106 @@
|
||||
# Row 202, FED R5: `mosaic access list`, `grant`, `revoke`
|
||||
|
||||
Quackerjack, 2026-10-20. Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Design:
|
||||
`agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 (sections 3.5, 3.7, 7 row R5). Reviewer Duckworth.
|
||||
Chained on row 201 (R1, Gizmoduck). Interface as Sage relayed it (sage-1400, gizmoduck-0030).
|
||||
|
||||
## Status before build
|
||||
|
||||
- Claim: `scripts/mosaic queue move 202 in-progress --op quackerjack-202-start --by quackerjack`, run from
|
||||
the canonical checkout, exits 2: "row 202 waits on 201 (done; now briefed)". Row 201 is `briefed`, so the
|
||||
claim is not made. I will not build past the plan until Sage says how to proceed. This plan is a file in
|
||||
my own worktree; it changes no queue row.
|
||||
- `gizmoduck/fed-r1` is not on origin yet. R5 imports `packages/business/src/access.mjs` only after R1 lands.
|
||||
Until then, code and tests run against the names in sage-1400, with the R1 import stubbed in one place.
|
||||
|
||||
## Interface I build against (R1, as relayed)
|
||||
|
||||
- `checkAccess(doc, { providers, peers, businesses, ssoSubjects })`: pure. Returns the checked document,
|
||||
frozen. Throws `BusinessError` (exit 2) naming the key path. `ssoSubjects` is required; a missing one fails
|
||||
closed.
|
||||
- `covers(grant, { business, project, instance }, now, { level } = {})`.
|
||||
- `accessFilePath(dir = configDir())`: `<configDir>/access.json`.
|
||||
- `loadAccess(file, context)`: one read, refuses a symlink, a non-regular file, another owner, or group or
|
||||
other write, then runs `checkAccess`. An expired grant is not refused here.
|
||||
|
||||
## Files I will change
|
||||
|
||||
New:
|
||||
- `packages/cli/src/access.mjs`: argument parsing for `list`, `grant`, `revoke`; the human check; the write;
|
||||
the receipt; the usage text. Exports a `main`-style function the way `thread.mjs` and `project.mjs` do.
|
||||
- `packages/cli/tests/access.test.mjs`: all R5 tests below.
|
||||
|
||||
Edited:
|
||||
- `scripts/mosaic`: add `access` to the dispatch case that sends a verb to `packages/cli/src/cli.mjs`.
|
||||
- `packages/cli/src/cli.mjs`: route `access` to `access.mjs`.
|
||||
- `packages/cli/README.md`: the three verbs, the `instances` limit, and the provider-removal order.
|
||||
|
||||
Not touched: `packages/business/src/access.mjs` (R1 owns it), `scripts/mosaic-config.mjs` (R2), any bus
|
||||
broker code (R3/R4), `~/.mosaic-dev`, `~/.config/mosaic-dev`, the units, any live `access.json`.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- `list`: prints the users, grants and revision of `accessFilePath()`. Reads through `loadAccess`, so a bad
|
||||
file refuses here too. Shows each grant's project as the id (and the name, when `project.list` resolves
|
||||
it). Shows `["*"]` as "all projects, now and later".
|
||||
- `grant`: `--user`, `--business`, `--projects`, `--instances`, `--level collaborator`, optional
|
||||
`--expires`, `--revision N`. Builds the new document in memory, runs `checkAccess` on it, then writes. A
|
||||
bad grant refuses before any write. `--project <name>` resolves the name through `project.list` at grant
|
||||
time and writes the id (design 3.5).
|
||||
- `revoke`: removes the user's grants, or one grant with `--grant ID`, same flow.
|
||||
- Write: a temp file in the same directory, mode 0600, fsync, rename over `access.json`. Revision goes up by
|
||||
one. The caller passes the revision it read, and a mismatch refuses with `revision-conflict` before the
|
||||
rename.
|
||||
- Receipt: one line appended to `<dataRoot>/state/access-log.jsonl`. Append-only, same shape as
|
||||
`activation-log.jsonl` (`packages/runs/src/state.mjs`). It records the time, the verb, the actor, the
|
||||
revision before and after, and the grant ids touched. No secret or token goes in it.
|
||||
- Help text states two things. First, a grant's `instances` is the real limit: a collaborator drives the
|
||||
granted instance with the owner's role (design 3.5). Second, the removal order: take a user out of
|
||||
`access.json` first, which acts at once. Removing a provider from `console.sso` takes effect only after a
|
||||
Console restart (design 3.7).
|
||||
|
||||
## Human proof
|
||||
|
||||
The check runs as the human transport does now: `humanTransport` (`packages/cli/src/transport.mjs`) runs
|
||||
`packages/bus/src/human-cli.mjs` against the bus socket, and `refuseInsideAgent` refuses first in any agent
|
||||
ancestry. Open point, for Sage or Gizmoduck to settle before build: the design names no bus verb for the
|
||||
grant check. My reading is that R5 makes one call through `humanTransport` to a read the bus already gates
|
||||
as human-only, and uses its success as the proof, with no new bus verb (R5 must not widen the bus).
|
||||
If that is wrong, I stop and report it.
|
||||
|
||||
Tests use two stand-ins, the pattern `packages/cli/tests/helpers.mjs` already has:
|
||||
- Stand-in human CLI: `fakeCli(root, body)` replaces `human-cli.mjs` as the `cli` argument of
|
||||
`humanTransport`, so the answer comes from a table. Used for the caller-refusal tests and most of the rest.
|
||||
- Scratch bus: `startBroker` from `packages/bus/src/runtime.mjs` on a scratch socket and scratch data root.
|
||||
One integration test runs the real `HUMAN_CLI` against it, so the proof path is checked end to end. The
|
||||
bus accepts only its own `human-cli.mjs` path (memory #225), so that test uses the real file, not a copy.
|
||||
- Tester caller: a `tester` proof, through `testerTransport` with `TESTER_CLI`. Refused with `tester-refused`
|
||||
or `tester-required` as the bus returns it.
|
||||
- Agent caller: env with `MOSAIC_RUN_ID` (and the other markers in `AGENT_MARKERS`) set. Refused by
|
||||
`refuseInsideAgent` (exit 3) before any transport call.
|
||||
|
||||
## Test list (`packages/cli/tests/access.test.mjs`) and one mutant per refusal
|
||||
|
||||
Each test uses a scratch `MOSAIC_CONFIG` dir and a scratch data root. None reads `~/.config/mosaic-dev`.
|
||||
|
||||
| # | Refusal or behaviour | Test | Mutant (must be killed) |
|
||||
|---|---|---|---|
|
||||
| 1 | Agent caller refused | env with `MOSAIC_RUN_ID` runs `grant`; exit 3, no file change | `refuseInsideAgent` call removed from the access path |
|
||||
| 2 | Tester caller refused | `tester` proof runs `grant`; `tester-refused` (exit 3), no write | tester branch lets a tester write |
|
||||
| 3 | Human proof required | stand-in answers `human-required`; exit 3, no write | proof result ignored |
|
||||
| 4 | Concurrent write refused | two writers read revision 3; second with `--revision 3` gets `revision-conflict`; file keeps revision 4 | revision compare removed |
|
||||
| 5 | Receipt appended | after `grant`, one new line in `access-log.jsonl` with both revisions; a second write adds a second line, the first is unchanged | receipt write removed, or the write truncates the log |
|
||||
| 6 | Bad grant refused before write | grant with a non-UUID project, unknown instance, level `viewer`; exit 2, file bytes unchanged | `checkAccess` runs after the rename |
|
||||
| 7 | Atomic write | the write is a temp file and a rename; a failed rename leaves the old file whole | direct write to `access.json` |
|
||||
| 8 | Revoke removes the grant | `revoke` removes the user; `list` no longer shows it; revision +1 | revoke leaves the grant |
|
||||
| 9 | `--project <name>` resolves to an id | name resolves through `project.list`; the file holds the id | name written as-is |
|
||||
| 10 | Real proof path | scratch `startBroker` plus real `HUMAN_CLI` with a valid proof; grant succeeds | (control: the success path must pass with no mutant) |
|
||||
|
||||
The mutant batch needs Sage's slot. Single test files run freely.
|
||||
|
||||
## Gate
|
||||
|
||||
- Duckworth approves this manifest and the code.
|
||||
- The cli suite and every `scripts/test-*.sh` green on Sage's gate.
|
||||
- Each mutant above is killed.
|
||||
- Rebase onto R1's landing; re-run every touched test file singly before in-review.
|
||||
@@ -1363,3 +1363,4 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
|
||||
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
|
||||
2026-10-20T19:57:00Z | Gizmoduck (Opus 5.5, 9312a896) | row 201 FED R1 (#1699, #414) round 1: packages/business/src/access.mjs, index export, tests/access.test.mjs, packet agents/gizmoduck/work/queue-201 | 24/24 single file; 44 mutants written, not run (needs Sage's slot); expiry per sage-1399; to Duckworth for manifest review
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Federation build rows R1, R2 and R5 (#414)
|
||||
|
||||
Sage, 2026-10-20, for Mos's orders mos-1023 item 1, mos-1028 item 2 and mos-1034 item 1. The design is
|
||||
Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 on `gizmoduck/federation-design` (r4.9,
|
||||
sha256 a31cbebd070f9e3a5ca4ccb158b1747c6f5a83c3469855705b0026a581fb5c6a). Duckworth approved r4.5
|
||||
(duckworth-0012), r4.6, r4.7 (duckworth-0020) and r4.8 (duckworth-0021, 8a22d0897 on `duckworth/reviews`),
|
||||
and acked r4.9 (duckworth-0024). r4.7 applies Jason's Q181 hosts, r4.8 adds N22's R8 checks and r4.9 names who
|
||||
runs them. None of them changes R1, R2 or R5. Section 7's table
|
||||
sets each row's scope and test gate. Vikunja P19 #414. Seats push only their own `<seat>/*` branches
|
||||
(mos-0981).
|
||||
|
||||
The other R rows are filed when their parents have candidates: R3 after R1 and row 196, R4 after R3, R5 and
|
||||
row 193's viewer level (193a, mos-1019), R6 after #412 phase 1, R1, R2 and R3, R7 after R1-R6 and row 190.
|
||||
R8 is a live window, not a code row. Its brief is the last section here.
|
||||
|
||||
## Brief: FED R1, the access.json loader and validator
|
||||
|
||||
Owner Gizmoduck, reviewer Duckworth, gate owner Sage. Base: refactor at this brief's commit. No parent.
|
||||
|
||||
`packages/business/src/access.mjs`: the access.json loader and validator from DESIGN.md section 3.5, and the
|
||||
pure `covers`. Providers, peers and businesses come in as arguments. One read plus the file checks. The
|
||||
exported interface is what R5 and row 193a build against, so the manifest names it, and any change to it in
|
||||
review goes to Sage before it lands. Section 7 names `covers` but not the loader and validator exports or
|
||||
the context shape. Morgana's 193a (508ee4d61 on `morgana/row193a`) assumes `checkAccess(doc, {providers,
|
||||
peers, businesses})`, with `businesses` mapping an id to `{human, instances}`, throwing a `BusinessError` that
|
||||
names the key path. Use those names, or tell Morgana and Quackerjack yours before the manifest goes to
|
||||
Duckworth. Row 193a later widens the level check to `viewer` on R1's candidate (mos-1019).
|
||||
|
||||
Tests, in `packages/business/tests/`: every rule in 3.5 refuses with the key path named. That covers a
|
||||
symlink, a non-regular file, another owner, group or other write, expiry, `*`, a grant disjoint from the given
|
||||
subjects, `human` and `tester` ids, any level other than `collaborator`, and a project entry that isn't a
|
||||
UUID. For `covers`: `*` and listed ids match, while an unlisted project, instance or business doesn't, an
|
||||
expired grant doesn't, and a level filter rejects another level. One mutant per refusal, each killed.
|
||||
|
||||
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
|
||||
use scratch roots. Single test files run freely, and suites need Sage's slot.
|
||||
|
||||
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
|
||||
## Brief: FED R5, mosaic access list, grant and revoke
|
||||
|
||||
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Chained on R1 (Q177). The build starts now against
|
||||
R1's exported interface as the R1 manifest names it, and R5 lands after R1 and rebases onto R1's landing.
|
||||
|
||||
`mosaic access list`, `grant` and `revoke`: human proof, atomic write, revision bump, a receipt line. The help
|
||||
text states the `instances` limit and the provider-removal order (DESIGN.md section 7, row R5). R4 later makes
|
||||
these verbs call `access.reload`, so R5 leaves no other reload path.
|
||||
|
||||
Tests, in `packages/cli/tests/`: tester and agent callers are refused, a concurrent write refuses on a
|
||||
revision conflict, the receipt line is appended, and a bad grant is refused before anything is written. One
|
||||
mutant per refusal, each killed.
|
||||
|
||||
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. The
|
||||
human proof in tests uses the scratch stand-in pattern (a scratch bus verifies its own human-cli path).
|
||||
Single test files run freely, and suites need Sage's slot.
|
||||
|
||||
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: FED R2, the federation key in the system config
|
||||
|
||||
Owner Quackerjack, reviewer Webby, gate owner Sage. Chained on row 192 (#412 phase 1). The build starts when
|
||||
row 192 has a candidate, on that candidate.
|
||||
|
||||
A `federation` key in `scripts/mosaic-config.mjs` with the shape in DESIGN.md section 3. A config with no
|
||||
`federation` key still loads as it does today. This changes the config schema, so the manifest lists every
|
||||
reader of the key.
|
||||
|
||||
Tests, in `scripts/test-config.sh`: a valid key loads, an unknown sub-key and a bad origin refuse, and a
|
||||
config without the key loads. One mutant per refusal, each killed.
|
||||
|
||||
Boundaries: `~/.config/mosaic-dev/config.json` is user-authored and is not touched. Tests use scratch config
|
||||
dirs only.
|
||||
|
||||
Gate: Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and
|
||||
the mutants are killed.
|
||||
|
||||
## Brief: FED R8, the hub window on 10.1.1.54
|
||||
|
||||
A live window, not a code row (DESIGN.md section 7, row R8, r4.9). Run by Jason and Mos. Nothing here
|
||||
authorizes a step on any host; each step waits for the window's go.
|
||||
|
||||
Order (mos-1042 item 2). R8 runs after Bean and Chet are live in Case 2 and both Hermes gateways on 10.1.1.54
|
||||
are stopped, because R8 requires that the hub host runs no other instance or agent and Q181 keeps Melanie's
|
||||
Hermes on .54 until then. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between
|
||||
the two instances now depends on Melanie's switch-over date.
|
||||
|
||||
Entry checks, all before anything starts:
|
||||
- R7, #412 phase 2, the hub host live (Vikunja #418) and Jason's #419 answer, as section 7 lists.
|
||||
- The four hub user checks on 10.1.1.54 (N22, mos-1035 item 1): `sudo -l -U mosaic` grants nothing, `id
|
||||
mosaic` shows only its own group, `~mosaic/.ssh/authorized_keys` is absent or empty, and no file under
|
||||
`/etc/sudoers.d` names `mosaic`. Jason runs them as localadmin with his new key (#421 step 1), and the output
|
||||
goes into the R8 record. No factory uid runs them, because R8 requires every factory root path to .54 to
|
||||
fail (r4.9, mos-1037).
|
||||
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
|
||||
as section 7's R8 row states them.
|
||||
|
||||
Done when every entry check has its output recorded, Jason signs in to the hub and sends one message, Melanie
|
||||
revokes, and his next request is refused.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Row 193 build rows: 193a, 193b1, 193b2, 193c and 193d (#1691)
|
||||
|
||||
Sage, 2026-10-20, for Mos's go mos-1034 item 4. Row 193's design is Morgana's
|
||||
`agents/morgana/work/queue-193/DESIGN.md` at e2d161e2e on `morgana/row193` (r5, sha256
|
||||
dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523), approved by Duckworth (duckworth-0018).
|
||||
Section 8 lists the tests and mutants, and section 9 the split. The brief behind them is
|
||||
`docs/plans/2026-10-20_human-users.md`. Vikunja P19 #414/#415, Gitea #1691.
|
||||
|
||||
Owner Morgana for every row, reviewer Duckworth, gate owner Sage. Each row chains on its parent's candidate
|
||||
(Q177) and rebases onto the parent's landing. Seats push only their own `<seat>/*` branches (mos-0981).
|
||||
Single test files run freely, and suites and mutant batches need Sage's slot.
|
||||
|
||||
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
|
||||
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
|
||||
|
||||
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
|
||||
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
|
||||
and project files, but not your messages."
|
||||
|
||||
## Brief: 193a, the access level check widened to viewer
|
||||
|
||||
Chained on FED R1. Started early on `morgana/row193a` (508ee4d61) against R1's interface as the FED brief
|
||||
names it, with Sage's go (sage-1373). R1's level check accepts `viewer` and still refuses any other new
|
||||
level. Tests in `packages/business/tests/` (section 8, Business), with the four mutants in Morgana's
|
||||
BUILD.md. FED R4 chains on this row.
|
||||
|
||||
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
|
||||
## Brief: 193b1, viewer verbs, board refusals and the message filter
|
||||
|
||||
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
|
||||
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
|
||||
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
|
||||
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193b2, thread shares (schema 3j)
|
||||
|
||||
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
|
||||
section 5.5 share filters. Schema 3i to 3j. The table is append-only, and row 181's restore guard and
|
||||
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
|
||||
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutants are killed.
|
||||
|
||||
## Brief: 193c, mosaic access user verbs and --level viewer
|
||||
|
||||
Chained on FED R5. `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.
|
||||
|
||||
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193d, the Console's shared list, share controls and viewer read-only
|
||||
|
||||
Chained on FED R6, row 192 and 193b2 (for share controls). A viewer has no composer or share control, and
|
||||
with each UI check removed the bus still refuses (section 8, Webui).
|
||||
|
||||
Gate: Duckworth approves the manifest. The webui suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
@@ -0,0 +1,185 @@
|
||||
// The access file, <configDir>/access.json (federation design section 3.5,
|
||||
// FED R1, #414). It names the extra users who may sign in to this instance
|
||||
// and the grants that let them reach a business's threads. The validator is
|
||||
// pure: providers, peers, businesses and the owner's sign-in subjects come in
|
||||
// as arguments. Any failed rule refuses the whole file, which leaves every
|
||||
// user with no grant (fail closed).
|
||||
|
||||
import { join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { configDir } from "./business.mjs";
|
||||
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
|
||||
|
||||
// The grant levels. Row 193a widens this to collaborator and viewer.
|
||||
export const LEVELS = Object.freeze(["collaborator"]);
|
||||
// Ids a user may never take: the bus's own principals.
|
||||
const RESERVED_USERS = Object.freeze(["human", "tester"]);
|
||||
const LOCAL_HOME = "local";
|
||||
const ALL = "*";
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
||||
const SUB_MAX = 255;
|
||||
|
||||
export function accessFilePath(dir = configDir()) {
|
||||
return join(dir, "access.json");
|
||||
}
|
||||
|
||||
function requireArray(value, where) {
|
||||
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireText(value, where) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
|
||||
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function subjectKey(provider, sub) {
|
||||
return JSON.stringify([provider, sub]);
|
||||
}
|
||||
|
||||
function checkContext(context) {
|
||||
const where = "access context";
|
||||
requireObject(context, where);
|
||||
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
|
||||
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
|
||||
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
|
||||
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
|
||||
requireObject(context.businesses, `${where}.businesses`);
|
||||
const businesses = new Map();
|
||||
for (const [id, business] of Object.entries(context.businesses)) {
|
||||
const at = `${where}.businesses.${id}`;
|
||||
requireId(id, `${where}.businesses key`);
|
||||
requireObject(business, at);
|
||||
requireId(business.human, `${at}.human`);
|
||||
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
|
||||
businesses.set(id, { human: business.human, instances: new Set(instances) });
|
||||
}
|
||||
const owners = new Set();
|
||||
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
|
||||
const at = `${where}.ssoSubjects[${i}]`;
|
||||
requireObject(s, at);
|
||||
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
|
||||
});
|
||||
return { providers, peers, businesses, owners };
|
||||
}
|
||||
|
||||
function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
requireObject(user, at);
|
||||
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
|
||||
requireId(user.id, `${at}.id`);
|
||||
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
|
||||
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
|
||||
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
|
||||
}
|
||||
const subjects = requireArray(user.subjects, `${at}.subjects`);
|
||||
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
|
||||
subjects.forEach((s, i) => {
|
||||
const sat = `${at}.subjects[${i}]`;
|
||||
requireObject(s, sat);
|
||||
rejectUnknownKeys(s, ["provider", "sub"], sat);
|
||||
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
|
||||
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
|
||||
}
|
||||
requireText(s.sub, `${sat}.sub`);
|
||||
const key = subjectKey(s.provider, s.sub);
|
||||
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
|
||||
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
|
||||
seenSubjects.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
|
||||
function checkScope(value, where, check) {
|
||||
const list = requireArray(value, where);
|
||||
if (list.length === 0) refuse(`${where} must not be empty`);
|
||||
if (list.includes(ALL)) {
|
||||
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set();
|
||||
list.forEach((entry, i) => {
|
||||
check(entry, `${where}[${i}]`);
|
||||
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
|
||||
seen.add(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function checkExpires(value, where) {
|
||||
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
|
||||
}
|
||||
const iso = new Date(value).toISOString();
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
|
||||
}
|
||||
|
||||
function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
requireObject(grant, at);
|
||||
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
|
||||
requireId(grant.id, `${at}.id`);
|
||||
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
|
||||
seenGrants.add(grant.id);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
|
||||
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
|
||||
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
|
||||
}
|
||||
const business = ctx.businesses.get(grant.business);
|
||||
checkScope(grant.projects, `${at}.projects`, (p, where) => {
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
|
||||
});
|
||||
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
|
||||
});
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
|
||||
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
||||
}
|
||||
|
||||
// Check a parsed access document against the instance's context. Returns the
|
||||
// document frozen. Every refusal is a BusinessError naming the key path. An
|
||||
// expired grant is not refused: covers() treats it as absent.
|
||||
export function checkAccess(document, context) {
|
||||
const ctx = checkContext(context);
|
||||
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
|
||||
if (!isPlainObject(document)) refuse("access file must be a JSON object");
|
||||
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
|
||||
requirePositiveInt(document.revision, "revision");
|
||||
const users = new Set();
|
||||
const seenSubjects = new Set();
|
||||
requireArray(document.users, "users").forEach((user, i) => {
|
||||
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
|
||||
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
|
||||
users.add(user.id);
|
||||
});
|
||||
const seenGrants = new Set();
|
||||
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
|
||||
return deepFreeze(structuredClone(document));
|
||||
}
|
||||
|
||||
// Read and check the access file. One descriptor, opened without following a
|
||||
// link, serves the stat checks and the read (readJsonFile). The file holds
|
||||
// authority, so it must belong to this uid and not be writable by group or
|
||||
// other, as the business file is.
|
||||
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
|
||||
const document = readJsonFile(file, "access file", (stat) => {
|
||||
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
});
|
||||
return checkAccess(document, context);
|
||||
}
|
||||
|
||||
// Does a checked grant cover a thread? `target` is the thread's business, bus
|
||||
// project id and instance. With `level`, the grant's level must be that one;
|
||||
// without it, any level covers. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
|
||||
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
|
||||
if (level !== undefined && grant.level !== level) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -10,5 +10,6 @@ export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
|
||||
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
|
||||
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
|
||||
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
|
||||
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
|
||||
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md
|
||||
// section 3.5, and covers(). Every test works in a scratch directory.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, symlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BusinessError } from "../src/errors.mjs";
|
||||
import { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "../src/access.mjs";
|
||||
import { tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const P1 = "0f8b6c1e-2a3d-4e5f-8a9b-0c1d2e3f4a5b";
|
||||
const P2 = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
function context(over = {}) {
|
||||
return {
|
||||
providers: ["authentik"],
|
||||
peers: ["factory"],
|
||||
businesses: {
|
||||
"creative-hub": { human: "melanie", instances: ["researcher", "writer"] },
|
||||
acme: { human: "jason", instances: ["coder"] },
|
||||
},
|
||||
ssoSubjects: [{ provider: "authentik", sub: "owner-sub" }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function doc() {
|
||||
return {
|
||||
accessVersion: 1,
|
||||
revision: 3,
|
||||
users: [
|
||||
{ id: "bean", home: "factory", subjects: [{ provider: "authentik", sub: "s-1" }] },
|
||||
{ id: "chet", home: "local", subjects: [{ provider: "authentik", sub: "s-2" }] },
|
||||
],
|
||||
grants: [
|
||||
{ id: "g-1", user: "bean", business: "creative-hub", projects: ["*"], instances: ["researcher"], level: "collaborator", expires: "2026-12-31T00:00:00Z" },
|
||||
{ id: "g-2", user: "chet", business: "creative-hub", projects: [P1], instances: ["*"], level: "collaborator" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Refuses with a BusinessError, exit 2 unless given, and a message matching `pattern`.
|
||||
function refuses(fn, pattern, exitCode = 2) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `not a BusinessError: ${error}`);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mutated(change) {
|
||||
const d = doc();
|
||||
change(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
test("a valid document passes and comes back frozen", () => {
|
||||
const checked = checkAccess(doc(), context());
|
||||
assert.deepEqual(checked, doc());
|
||||
assert.ok(Object.isFrozen(checked));
|
||||
assert.ok(Object.isFrozen(checked.grants[0].projects));
|
||||
});
|
||||
|
||||
test("checkAccess does not freeze or alias the caller's document", () => {
|
||||
const d = doc();
|
||||
const checked = checkAccess(d, context());
|
||||
assert.ok(!Object.isFrozen(d));
|
||||
assert.notEqual(checked.grants, d.grants);
|
||||
});
|
||||
|
||||
test("LEVELS is the single allowed level set, collaborator only", () => {
|
||||
assert.deepEqual([...LEVELS], ["collaborator"]);
|
||||
assert.ok(Object.isFrozen(LEVELS));
|
||||
});
|
||||
|
||||
test("accessFilePath is access.json in the config directory", () => {
|
||||
assert.equal(accessFilePath("/x/y"), join("/x/y", "access.json"));
|
||||
const dir = tmp();
|
||||
const saved = process.env.MOSAIC_CONFIG;
|
||||
process.env.MOSAIC_CONFIG = join(dir, "config.json");
|
||||
try {
|
||||
assert.equal(accessFilePath(), join(dir, "access.json"));
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.MOSAIC_CONFIG;
|
||||
else process.env.MOSAIC_CONFIG = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test("top level: object, unknown keys, accessVersion, revision, arrays", () => {
|
||||
refuses(() => checkAccess([], context()), /access file must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.extra = 1; }), context()), /unsupported access file key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.accessVersion = 2; }), context()), /accessVersion must be 1/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.accessVersion; }), context()), /accessVersion must be 1/);
|
||||
for (const bad of [0, -1, 1.5, "3", undefined]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.revision = bad; }), context()), /revision must be a positive integer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { d.users = {}; }), context()), /^users must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants; }), context()), /^grants must be an array/);
|
||||
});
|
||||
|
||||
test("users: unknown keys and the id rule", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
|
||||
});
|
||||
|
||||
test("users: human, tester and any business's human are refused", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
|
||||
// The human of a business the grant doesn't name is refused too.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
|
||||
});
|
||||
|
||||
test("users: home is a given peer or local", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
|
||||
});
|
||||
|
||||
test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
// A pair twice in one user, and across two users.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects.push({ provider: "authentik", sub: "s-1" }); }), context()), /users\[0\]\.subjects\[1\] appears more than once/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "s-1"; }), context()), /users\[1\]\.subjects\[0\] appears more than once/);
|
||||
// The owner's own sign-in subject can't be an extra user.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "owner-sub"; }), context()), /users\[1\]\.subjects\[0\] is an owner sign-in subject/);
|
||||
// The same sub under another provider is a different pair.
|
||||
const twoProviders = context({ providers: ["authentik", "google"] });
|
||||
assert.ok(checkAccess(mutated((d) => { d.users[1].subjects[0] = { provider: "google", sub: "owner-sub" }; }), twoProviders));
|
||||
});
|
||||
|
||||
test("grants: unknown keys, id, user and business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
});
|
||||
|
||||
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, P1]; }), context()), /duplicate entry in grants\[1\]\.projects/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = []; }), context()), /grants\[1\]\.projects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = "*"; }), context()), /grants\[1\]\.projects must be an array/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[1].projects = [P1, P2]; }), context()));
|
||||
});
|
||||
|
||||
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].instances = ["researcher", "writer"]; }), context()));
|
||||
});
|
||||
|
||||
test("grants: any level other than collaborator refuses, naming grants[i].level", () => {
|
||||
for (const bad of ["viewer", "owner", "Collaborator", "COLLABORATOR", "", null, 1]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator \(got undefined\)/);
|
||||
});
|
||||
|
||||
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
|
||||
for (const bad of ["2026-12-31", "2026-12-31 00:00:00Z", "2026-12-31T00:00:00+01:00", "tomorrow", 1798675200000, null]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = bad; }), context()), /grants\[0\]\.expires must be an ISO time/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-02-30T00:00:00Z"; }), context()), /grants\[0\]\.expires is not a real time/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-13-01T00:00:00Z"; }), context()), /grants\[0\]\.expires must be an ISO time/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2026-12-31T00:00:00.500Z"; }), context()));
|
||||
// DESIGN 3.5: a grant past expires is treated as absent, not refused (sage-1399).
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2001-01-01T00:00:00Z"; }), context()));
|
||||
});
|
||||
|
||||
test("the context is required and checked", () => {
|
||||
refuses(() => checkAccess(doc()), /access context must be a JSON object/);
|
||||
for (const key of ["providers", "peers", "ssoSubjects"]) {
|
||||
const c = context();
|
||||
delete c[key];
|
||||
refuses(() => checkAccess(doc(), c), new RegExp(`access context\\.${key} must be an array`));
|
||||
}
|
||||
refuses(() => checkAccess(doc(), context({ businesses: [] })), /access context\.businesses must be a JSON object/);
|
||||
refuses(() => checkAccess(doc(), context({ extra: 1 })), /unsupported access context key: "extra"/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: ["local"] })), /access context\.peers must not name "local"/);
|
||||
refuses(() => checkAccess(doc(), context({ ssoSubjects: [{ provider: "authentik" }] })), /access context\.ssoSubjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(doc(), context({ businesses: { acme: { human: "jason" } } })), /access context\.businesses\.acme\.instances must be an array/);
|
||||
});
|
||||
|
||||
// loadAccess: the file checks, then checkAccess.
|
||||
|
||||
test("loadAccess reads a valid file and checks it", () => {
|
||||
const file = writeJson(join(tmp(), "access.json"), doc());
|
||||
assert.deepEqual(loadAccess(file, context()), doc());
|
||||
});
|
||||
|
||||
test("loadAccess refuses a missing file, a symlink and a directory with exit 4", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadAccess(join(dir, "access.json"), context()), /access file not found/, 4);
|
||||
const real = writeJson(join(dir, "real.json"), doc());
|
||||
const link = join(dir, "access.json");
|
||||
symlinkSync(real, link);
|
||||
refuses(() => loadAccess(link, context()), /access file must be a regular, non-symbolic-link file/, 4);
|
||||
const sub = join(dir, "d");
|
||||
mkdirSync(sub);
|
||||
refuses(() => loadAccess(sub, context()), /access file must be a regular, non-symbolic-link file/, 4);
|
||||
});
|
||||
|
||||
test("loadAccess refuses another owner and group or other write", () => {
|
||||
const file = writeJson(join(tmp(), "access.json"), doc());
|
||||
refuses(() => loadAccess(file, context(), { uid: process.getuid() + 1 }), /access file must belong to uid/);
|
||||
for (const mode of [0o620, 0o602, 0o666]) {
|
||||
chmodSync(file, mode);
|
||||
refuses(() => loadAccess(file, context()), /access file must not be writable by group or other/);
|
||||
}
|
||||
chmodSync(file, 0o644);
|
||||
assert.ok(loadAccess(file, context()));
|
||||
});
|
||||
|
||||
test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadAccess(writeJson(join(dir, "a.json"), "{"), context()), /access file is not valid JSON/);
|
||||
refuses(() => loadAccess(writeJson(join(dir, "b.json"), mutated((d) => { d.grants[0].level = "owner"; })), context()), /grants\[0\]\.level/);
|
||||
});
|
||||
|
||||
// covers.
|
||||
|
||||
const NOW = new Date("2026-10-20T12:00:00Z");
|
||||
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
|
||||
|
||||
test("covers: * and listed ids match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target({ project: P2 }), NOW), true);
|
||||
assert.equal(covers(g2, target({ instance: "writer" }), NOW), true);
|
||||
assert.equal(covers(g2, target(), NOW), true);
|
||||
});
|
||||
|
||||
test("covers: an unlisted project, instance or business doesn't match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g2, target({ project: P2 }), NOW), false);
|
||||
assert.equal(covers(g1, target({ instance: "writer" }), NOW), false);
|
||||
assert.equal(covers(g2, target({ business: "acme" }), NOW), false);
|
||||
assert.equal(covers(g1, target({ business: "acme" }), NOW), false);
|
||||
});
|
||||
|
||||
test("covers: an expired grant doesn't match, at and after the instant", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z")), true);
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z")), false);
|
||||
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z")), false);
|
||||
// No expires never expires.
|
||||
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z")), true);
|
||||
});
|
||||
|
||||
test("covers: a level filter rejects another level, and no filter accepts any", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
|
||||
assert.equal(covers(g, target(), NOW, {}), true);
|
||||
assert.equal(covers(g, target(), NOW, { level: "viewer" }), false);
|
||||
assert.equal(covers({ ...g, level: "viewer" }, target(), NOW, { level: "collaborator" }), false);
|
||||
});
|
||||
|
||||
test("covers: now must be a valid Date", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
assert.throws(() => covers(g, target(), Date.now()), TypeError);
|
||||
assert.throws(() => covers(g, target(), new Date("nope")), TypeError);
|
||||
});
|
||||
@@ -1009,6 +1009,35 @@ names no one; the restart itself needs Mos's go or Jason's hand. A vars or launc
|
||||
prints `no restart`, because the launcher reads it at its next launch check.
|
||||
The Console Settings page has the same read and write (`packages/webui`).
|
||||
|
||||
## Access (FED R5, #414)
|
||||
|
||||
```
|
||||
mosaic access list --business ID [--json]
|
||||
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator [--expires ISO] --revision N
|
||||
mosaic access revoke --business ID --user ID [--grant GID] --revision N
|
||||
```
|
||||
|
||||
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
|
||||
call through the human transport: the bus gates it with `#human` (`packages/bus/src/broker.mjs`,
|
||||
the `project.list` case and `#human`). An agent, or `--tester`, is refused before any write. Each
|
||||
write runs the file's checks on the new document first, bumps the revision by one, and renames
|
||||
atomically. A `--revision` that is not the file's current revision refuses with `revision-conflict`.
|
||||
A writer that finds the lock refuses at once. A lock whose pid is gone, or that is empty or
|
||||
unreadable, also refuses, and nothing reclaims it: check that no write is running, then remove
|
||||
`<configDir>/access.json.lock` by hand. Each write appends one receipt line to
|
||||
`<dataRoot>/state/access-log.jsonl`.
|
||||
|
||||
The command runs as the instance's uid. `access.json` must belong to that uid and must not be
|
||||
writable by group or other. If it is not, or a business file fails to load, the command refuses
|
||||
with exit 3, names the file and the key path, and writes nothing.
|
||||
|
||||
A grant's `instances` is the real limit. The granted instance runs with the owner's role, so a
|
||||
collaborator's message drives it with that authority. To cut a user off, revoke first: it acts at
|
||||
once. Removing a provider from `console.sso` takes effect only after a Console restart.
|
||||
|
||||
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.
|
||||
|
||||
## Token usage
|
||||
|
||||
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
|
||||
// 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 [--expires ISO] --revision N",
|
||||
"mosaic access revoke --business ID --user ID [--grant GID] --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. Peer homes are
|
||||
// empty until the federation key lands (row 203), so a grant naming a peer
|
||||
// home refuses until then. 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.
|
||||
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.
|
||||
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 is empty until row 203. 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) };
|
||||
}
|
||||
}
|
||||
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. Row R4 swaps this call for access.reload.
|
||||
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];
|
||||
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) 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);
|
||||
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`);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TAL
|
||||
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
||||
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
||||
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
@@ -573,6 +574,15 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
|
||||
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
||||
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
|
||||
if (verb === "access") {
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
return accessCommand(rest, io, {
|
||||
system,
|
||||
transport: (b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }),
|
||||
contextFor: () => deps.accessContext ?? accessContextFor(system, io.env),
|
||||
now: deps.now,
|
||||
});
|
||||
}
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
// `mosaic access` (FED R5, #414). Every write goes to a scratch config dir and a
|
||||
// scratch data root. The success paths run on a stand-in human CLI (fakeCli).
|
||||
// The one test that runs the real human-cli.mjs does it on a scratch bus from
|
||||
// agent ancestry, so the bus must refuse, and nothing is written.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as nodeFs from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { accessCommand, accessContextFor, withLock } from "../src/access.mjs";
|
||||
import { CliError } from "../src/errors.mjs";
|
||||
import { socketPath } from "../src/config.mjs";
|
||||
import { HUMAN_CLI, humanTransport } from "../src/transport.mjs";
|
||||
import { fakeCli, humanEnv, io, tmp, fixture } from "./helpers.mjs";
|
||||
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
|
||||
|
||||
const PROJECT = "3f1c2a64-0d8e-4b7a-9c55-1a2b3c4d5e6f";
|
||||
const ROWS = [{ id: PROJECT, name: "hub", repo: null }];
|
||||
const CONTEXT = {
|
||||
providers: ["authentik"],
|
||||
peers: [],
|
||||
businesses: { acme: { human: "jason", instances: ["coder"] } },
|
||||
ssoSubjects: [{ provider: "authentik", sub: "jason-sub" }],
|
||||
};
|
||||
const DOC = {
|
||||
accessVersion: 1,
|
||||
revision: 1,
|
||||
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
|
||||
grants: [],
|
||||
};
|
||||
|
||||
// The stand-in human CLI. It answers project.list with ROWS, or refuses like a bus
|
||||
// that sees no human shell when FAKE_MODE is "refuse".
|
||||
const BODY = `
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
const input = JSON.parse(readFileSync(0, "utf8"));
|
||||
writeFileSync(process.env.FAKE_LOG, JSON.stringify(input));
|
||||
if (process.env.FAKE_MODE === "refuse") { process.stderr.write("human-required\\n"); process.exit(3); }
|
||||
process.stdout.write(JSON.stringify(${JSON.stringify(ROWS)}));
|
||||
`;
|
||||
|
||||
function setup(t, { mode = "ok", doc = DOC } = {}) {
|
||||
const root = tmp(t, "mosaic-access-");
|
||||
const config = join(root, "config");
|
||||
mkdirSync(config, { recursive: true });
|
||||
const dataRoot = join(root, "data");
|
||||
const log = join(root, "fake.log");
|
||||
const cli = fakeCli(root, BODY);
|
||||
const env = humanEnv({ FAKE_LOG: log, FAKE_MODE: mode });
|
||||
const file = join(config, "access.json");
|
||||
if (doc) writeFileSync(file, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
||||
const deps = {
|
||||
system: { dataRoot },
|
||||
configDir: config,
|
||||
contextFor: () => CONTEXT,
|
||||
now: () => new Date("2026-10-20T20:00:00Z"),
|
||||
transport: (business) => humanTransport({ socket: join(root, "bus.sock"), business, env, cli, timeoutMs: 5000 }),
|
||||
};
|
||||
const receipts = () => (existsSync(join(dataRoot, "state", "access-log.jsonl")) ? readFileSync(join(dataRoot, "state", "access-log.jsonl"), "utf8") : "");
|
||||
return { root, config, file, deps, env, log, receipts, read: () => JSON.parse(readFileSync(file, "utf8")) };
|
||||
}
|
||||
|
||||
const GRANT_BASE = ["grant", "--business", "acme", "--user", "alice"];
|
||||
const GRANT = [...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "collaborator"];
|
||||
|
||||
async function run(args, deps, env = humanEnv()) {
|
||||
const i = io(env);
|
||||
await accessCommand(args, i, deps);
|
||||
return i.out.text;
|
||||
}
|
||||
|
||||
async function refusal(promise, pattern) {
|
||||
await assert.rejects(promise, (e) => e instanceof CliError && pattern.test(e.message));
|
||||
return promise.catch((e) => e);
|
||||
}
|
||||
|
||||
test("list prints the revision and the users", async (t) => {
|
||||
const s = setup(t);
|
||||
const out = await run(["list", "--business", "acme"], s.deps);
|
||||
assert.match(out, /access file revision 1/);
|
||||
assert.match(out, /user alice home local/);
|
||||
});
|
||||
|
||||
test("grant writes one revision up, a receipt line, and the id in the file", async (t) => {
|
||||
const s = setup(t);
|
||||
const out = await run([...GRANT, "--revision", "1"], s.deps);
|
||||
assert.match(out, /access grant: g-[0-9a-f]{8} at acme, revision 2/);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 2);
|
||||
assert.equal(doc.grants.length, 1);
|
||||
assert.deepEqual(doc.grants[0].projects, [PROJECT]);
|
||||
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(line.verb, "grant");
|
||||
assert.equal(line.revisionBefore, 1);
|
||||
assert.equal(line.revisionAfter, 2);
|
||||
assert.equal(line.actor, "human");
|
||||
});
|
||||
|
||||
test("a second write appends a second receipt and leaves the first intact", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
|
||||
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.deepEqual(lines.map((l) => l.verb), ["grant", "revoke"]);
|
||||
assert.equal(lines[0].revisionAfter, 2);
|
||||
});
|
||||
|
||||
test("a stale revision refuses with revision-conflict and writes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /revision-conflict/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts().trim().split("\n").length, 1);
|
||||
});
|
||||
|
||||
test("a live holder's lock refuses and leaves the file and the lock alone", async (t) => {
|
||||
const s = setup(t);
|
||||
writeFileSync(`${s.file}.lock`, `${process.pid}\n`, { mode: 0o600 });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /being written by another command/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${process.pid}\n`);
|
||||
assert.equal(readdirSync(s.config).filter((f) => f.startsWith(".access-")).length, 0);
|
||||
});
|
||||
|
||||
test("an empty or unreadable lock refuses, since its holder may still be writing", async (t) => {
|
||||
const s = setup(t);
|
||||
writeFileSync(`${s.file}.lock`, "");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
|
||||
writeFileSync(`${s.file}.lock`, "not-a-pid\n");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
|
||||
assert.equal(existsSync(`${s.file}.lock`), true);
|
||||
});
|
||||
|
||||
test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", async (t) => {
|
||||
const s = setup(t);
|
||||
const dead = spawnSync(process.execPath, ["-e", ""]);
|
||||
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a failed rename leaves the old bytes, removes the temp file, and writes no receipt", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const fs = { ...nodeFs, renameSync: () => { throw Object.assign(new Error("injected rename failure"), { code: "EXDEV" }); } };
|
||||
await assert.rejects(run([...GRANT, "--revision", "1"], { ...s.deps, fs }), /injected rename failure/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.deepEqual(readdirSync(s.config).filter((f) => f.startsWith(".access-")), []);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
chmodSync(s.file, 0o664);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
});
|
||||
|
||||
test("an agent or tester caller is refused before the context or the access file is read", async (t) => {
|
||||
const s = setup(t);
|
||||
chmodSync(s.file, 0o664);
|
||||
const deps = { ...s.deps, contextFor: () => { throw new Error("context was read"); } };
|
||||
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
|
||||
await refusal(run([...GRANT, "--revision", "1"], deps, env), /human shell/);
|
||||
await refusal(run(["list", "--business", "acme", "--tester"], deps), /not --tester/);
|
||||
});
|
||||
|
||||
test("the lock is mode 0600 while held and removed after a refused write", async (t) => {
|
||||
const s = setup(t);
|
||||
let mode;
|
||||
withLock(s.file, () => {
|
||||
mode = statSync(`${s.file}.lock`).mode & 0o777;
|
||||
});
|
||||
assert.equal(mode, 0o600);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
await refusal(run([...GRANT, "--revision", "9"], s.deps), /revision-conflict/);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
});
|
||||
|
||||
test("a grant naming an unknown instance is refused before any write", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const bad = [...GRANT_BASE, "--projects", PROJECT, "--instances", "nobody", "--level", "collaborator", "--revision", "1"];
|
||||
await refusal(run(bad, s.deps), /refused: /);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a project name resolves to its id at grant time", async (t) => {
|
||||
const s = setup(t);
|
||||
const args = [...GRANT_BASE, "--projects", "hub", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
|
||||
await run(args, s.deps);
|
||||
assert.deepEqual(s.read().grants[0].projects, [PROJECT]);
|
||||
});
|
||||
|
||||
test("an unknown project name refuses before any write", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const args = [...GRANT_BASE, "--projects", "nope", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
|
||||
await refusal(run(args, s.deps), /project-not-found/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("revoke removes the user's grants and leaves a revision behind", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 3);
|
||||
assert.deepEqual(doc.grants, []);
|
||||
});
|
||||
|
||||
test("revoke of a user with no grant refuses and writes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run(["revoke", "--business", "acme", "--user", "alice", "--revision", "1"], s.deps), /no grant/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("an agent caller is refused before the bus is asked", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps, env), /human shell/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.equal(existsSync(s.log), false);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("--tester is refused on every verb before the bus is asked", async (t) => {
|
||||
const s = setup(t);
|
||||
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
|
||||
const args = [...verb, "--business", "acme", "--tester"];
|
||||
const err = await refusal(run(args, s.deps), /not --tester/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
}
|
||||
assert.equal(existsSync(s.log), false);
|
||||
});
|
||||
|
||||
test("a bus that refuses the human proof blocks the write", async (t) => {
|
||||
const s = setup(t, { mode: "refuse" });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /human-required/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("the context reads console.sso subjects and the business files, and peers stay empty", async (t) => {
|
||||
const root = tmp(t, "mosaic-access-ctx-");
|
||||
const f = fixture(root, "acme");
|
||||
const system = { console: { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } } };
|
||||
const ctx = accessContextFor(system, { MOSAIC_CONFIG: join(root, "config", "config.json") });
|
||||
assert.deepEqual(ctx.providers, ["authentik"]);
|
||||
assert.deepEqual(ctx.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
|
||||
assert.deepEqual(ctx.peers, []);
|
||||
assert.equal(ctx.businesses.acme.human, "jason");
|
||||
assert.ok(Array.isArray(ctx.businesses.acme.instances) && ctx.businesses.acme.instances.length > 0);
|
||||
const bare = accessContextFor({}, { MOSAIC_CONFIG: join(root, "config", "config.json") });
|
||||
assert.deepEqual(bare.ssoSubjects, []);
|
||||
assert.deepEqual(bare.providers, []);
|
||||
});
|
||||
|
||||
// Real human-cli.mjs on a scratch bus, run from agent ancestry. The proof must fail.
|
||||
test("the real human-cli, run from agent ancestry, refuses and writes nothing", async (t) => {
|
||||
const root = tmp(t, "mosaic-access-real-");
|
||||
const f = fixture(root, "acme");
|
||||
const config = join(root, "config");
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => bus.close());
|
||||
writeJson(join(config, "access.json"), DOC);
|
||||
const file = join(config, "access.json");
|
||||
const before = readFileSync(file, "utf8");
|
||||
const agentEnv = { ...humanEnv(), CLAUDECODE: "1" };
|
||||
const deps = {
|
||||
system: { dataRoot: f.dataRoot },
|
||||
configDir: config,
|
||||
contextFor: () => CONTEXT,
|
||||
transport: (business) => humanTransport({ socket: socketPath(f.dataRoot), business, env: agentEnv, cli: HUMAN_CLI, timeoutMs: 10000 }),
|
||||
};
|
||||
// The bus closes the socket on a failed human proof, so the client reports
|
||||
// outcome-unknown (client.mjs:32-33), not human-required. That is a bus behavior
|
||||
// outside R5 (BUILD.md). The test pins it, and nothing is written.
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], deps), /^outcome-unknown/);
|
||||
assert.equal(err.code, "outcome-unknown");
|
||||
assert.equal(err.exitCode, 1);
|
||||
assert.equal(readFileSync(file, "utf8"), before);
|
||||
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
|
||||
});
|
||||
+1
-1
@@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then
|
||||
exec node "$REPO/packages/business/src/cli.mjs" "$@"
|
||||
fi
|
||||
case "${1:-}" in
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|name|names|follow|threads|thread|access) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
esac
|
||||
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
|
||||
|
||||
Reference in New Issue
Block a user