redact6. A refusal names the field, the file or the instance. It never quotes a
config value, a host path, or a raw e.message or stderr that carries one.
- organization: checkCredentialRef names the field and each forbidden root by
label; OrgError keeps .path only for a document field ($...); the registry,
fence and lock refusals use the quiet helpers (call and code).
- cli: loadSystem never relays mosaic-config stderr: instance refusals keep
their code and names, everything else reads as a field and its constraint
against a labelled config. Project-key, access warning, halt gate, sessions
file, launch socket, harness relays and the workspace rename name labels.
Launch-log reasons read a Node system error as its call and code.
- scripts/mosaic-config: configuration not found names where the config came from.
- discord, seat, control-board: config, token, context, tool-root, journal,
binding, registration and seen-marks refusals name labels.
Exit codes, refusal codes and check order are unchanged. Each new or changed
test fails on BASE be698e602.
100 lines
5.5 KiB
JavaScript
100 lines
5.5 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 "../../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);
|
|
}
|
|
});
|