// 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 and viewer (row 193a)", () => { assert.deepEqual([...LEVELS], ["collaborator", "viewer"]); 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/); }); 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", a bus principal/); refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester", a bus principal/); refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human/); // 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/); }); 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"/); 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/); 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/); 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/); refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business/); 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/); 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 the grant's business/); 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 or viewer refuses, naming grants[i].level", () => { for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) { refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/); } refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer/); }); 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 }); // Any level reads: the least level is viewer (row 193a). const READ = { level: "viewer" }; test("covers: * and listed ids match", () => { const [g1, g2] = checkAccess(doc(), context()).grants; assert.equal(covers(g1, target({ project: P2 }), NOW, READ), true); assert.equal(covers(g2, target({ instance: "writer" }), NOW, READ), true); assert.equal(covers(g2, target(), NOW, READ), 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, READ), false); assert.equal(covers(g1, target({ instance: "writer" }), NOW, READ), false); assert.equal(covers(g2, target({ business: "acme" }), NOW, READ), false); assert.equal(covers(g1, target({ business: "acme" }), NOW, READ), 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"), READ), true); assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z"), READ), false); assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z"), READ), false); // No expires never expires. assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z"), READ), true); }); test("covers: level is the least level asked for (row 193a)", () => { const g = checkAccess(doc(), context()).grants[0]; const v = { ...g, level: "viewer" }; assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true); assert.equal(covers(g, target(), NOW, READ), true); assert.equal(covers(v, target(), NOW, READ), true); assert.equal(covers(v, target(), NOW, { level: "collaborator" }), false); }); test("covers: a missing or unknown level throws (row 193a, N1)", () => { const v = { ...checkAccess(doc(), context()).grants[0], level: "viewer" }; assert.throws(() => covers(v, target(), NOW), TypeError); assert.throws(() => covers(v, target(), NOW, {}), TypeError); assert.throws(() => covers(v, target(), NOW, { level: undefined }), TypeError); for (const bad of ["owner", "Viewer", "", null, 1]) { assert.throws(() => covers(v, target(), NOW, { level: bad }), /covers needs a level, one of collaborator, viewer/); } }); test("covers: a grant with an unknown level covers nothing (row 193a)", () => { const g = checkAccess(doc(), context()).grants[0]; for (const bad of ["owner", "Collaborator", undefined]) { assert.equal(covers({ ...g, level: bad }, target(), NOW, READ), false); } }); test("covers: now must be a valid Date", () => { const g = checkAccess(doc(), context()).grants[0]; assert.throws(() => covers(g, target(), Date.now(), READ), TypeError); assert.throws(() => covers(g, target(), new Date("nope"), READ), TypeError); });