98 lines
5.3 KiB
JavaScript
98 lines
5.3 KiB
JavaScript
// FED R6 (#1800, sage-3142, sage-3153): the bus host's boot config carries the runtime's `access`
|
|
// ({ file, context }) when <configDir>/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 "../../business/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);
|
|
assert.deepEqual(s.warnings, [TRACKER, `access file refused at boot (${code}), no granted user binds until it is fixed: ${s.path}`], why);
|
|
assert.doesNotMatch(s.warnings.join("\n"), /secret-marker/, why);
|
|
}
|
|
});
|