// FED R6 (#1800, sage-3142, sage-3153): the bus host's boot config carries the runtime's `access` // ({ file, context }) when /access.json exists, with the cli's accessContextFor as the // context. The broker then binds a granted user; with no file, or a file R1's loader refuses, no // user binds and the owner's bus still boots. A refused file gives one boot warning naming the path // and a code, never the file's contents. Scratch roots only. The boot config goes through a JSON // round trip first, as it does over the host's IPC. import test from "node:test"; import assert from "node:assert/strict"; import { chmodSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { startBroker } from "../../bus/src/runtime.mjs"; import { checkAccess } from "../../organization/src/index.mjs"; import { accessContextFor } from "../src/access.mjs"; import { bootConfig, loadSystem } from "../src/config.mjs"; import { fixture, tmp } from "./helpers.mjs"; const SSO = { sso: { providers: [{ id: "authentik", issuer: "https://idp.test", subjects: [{ sub: "jason-sub" }] }] } }; const FEDERATION = { self: "hub", peers: [{ id: "factory", origin: "https://factory.example.test", label: "Software factory" }] }; const DOC = { accessVersion: 1, revision: 1, users: [{ id: "alice", home: "factory", subjects: [{ provider: "authentik", sub: "alice-sub" }] }], grants: [{ id: "g-1", user: "alice", business: "acme", projects: ["*"], instances: ["*"], level: "collaborator" }], }; // The shared helpers.mjs business sets tracker.baseUrl without tracker.project, so every boot warns // this first: trackerFor runs before accessFor in bootConfig. const TRACKER = "business acme sets tracker.baseUrl, but no project file sets tracker.project; the broker gets no task verbs"; // F8: the context's peers are federation.peers' ids, so a user whose home is a configured peer // validates and one whose home is any other id, the hub's own included, refuses. test("R6 a configured peer id validates as a user's home and an unconfigured one refuses", (t) => { const root = tmp(t, "mosaic-access-peers-"); fixture(root, "acme"); const ctx = accessContextFor({ console: SSO, federation: FEDERATION }, { MOSAIC_CONFIG: join(root, "config", "config.json") }); assert.deepEqual(ctx.peers, ["factory"]); const doc = (home) => ({ ...DOC, users: [{ ...DOC.users[0], home }] }); assert.equal(checkAccess(doc("factory"), ctx).users[0].home, "factory"); assert.throws(() => checkAccess(doc("elsewhere"), ctx), /home must be a configured peer/); assert.throws(() => checkAccess(doc("hub"), ctx), /home must be a configured peer/); }); // A scratch instance whose system config names the sign-in provider and one peer, and the boot // config bootConfig makes for it. `file` is the access file's text, null for none, or a function // that makes the file at the path. async function boot(t, file) { const f = fixture(tmp(t, "mosaic-access-boot-"), "acme"); const system = { ...loadSystem({ env: f.env }), console: SSO, federation: FEDERATION }; const path = join(dirname(f.config), "access.json"); if (typeof file === "function") file(path); else if (file !== null) { writeFileSync(path, file); chmodSync(path, 0o600); } const warnings = []; const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: "acme", env: f.env, warn: (w) => warnings.push(w) }))); const runtime = await startBroker(config); t.after(() => runtime.close()); const bind = (user) => { try { return runtime.broker.bindUser({ business: "acme", user }) ? "bound" : "none"; } catch (e) { return e.code; } }; return { config, path, bind, warnings }; } test("R6 B1 a boot with access.json binds a granted user, whose home is a configured peer", async (t) => { const s = await boot(t, JSON.stringify(DOC)); assert.equal(s.config.access.file, s.path); assert.deepEqual(s.config.access.context.peers, ["factory"]); assert.deepEqual(s.config.access.context.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]); assert.equal(s.bind("alice"), "bound"); assert.equal(s.bind("bob"), "access-revoked"); assert.deepEqual(s.warnings, [TRACKER]); }); test("R6 B2 a boot without access.json has no access and binds no user", async (t) => { const s = await boot(t, null); assert.equal(Object.hasOwn(s.config, "access"), false); assert.equal(s.bind("alice"), "access-revoked"); assert.deepEqual(s.warnings, [TRACKER]); }); test("R6 B3 a boot with a file R1's loader refuses still boots, binds no user and says so once", async (t) => { for (const [why, file, code] of [ ["not JSON", '{"secret-marker"', "access-invalid"], ["a home that is not a configured peer", JSON.stringify({ ...DOC, users: [{ ...DOC.users[0], home: "secret-marker" }] }), "access-invalid"], ["a symbolic link", (path) => { writeFileSync(`${path}.real`, JSON.stringify(DOC), { mode: 0o600 }); symlinkSync(`${path}.real`, path); }, "access-unreadable"], ]) { const s = await boot(t, file); assert.equal(s.config.access.file, s.path, why); assert.equal(s.bind("alice"), "access-revoked", why); // #1873: the warning names access.json by its place, never by host path. assert.deepEqual(s.warnings, [TRACKER, `access file refused at boot (${code}), no granted user binds until it is fixed: access.json in the config directory`], why); assert.ok(!s.warnings.join("\n").includes(s.path), why); assert.doesNotMatch(s.warnings.join("\n"), /secret-marker/, why); } });