301 lines
18 KiB
JavaScript
301 lines
18 KiB
JavaScript
// 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);
|
|
});
|