PR #1916 rule: a refusal names the field or file, never a config value or a host path. readJsonFile's three refusals and parseJsonFileText now name the file by its `what` label only. Callers whose label alone is ambiguous add a name that already passed a strict pattern (business id, role name, org id, turn id, project key) or a fixed path under the data root. Exit codes, refusal codes and check order are unchanged. organization: util, access, project, config-command, library-seats, org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and organization-config.mjs change only the readJsonFile label. cli: access lock, accounts owner, host state/lock, notifier journal, notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console. discord: turn record label names the turn id. Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each changed refusal and fail on BASE; old path pins updated.
175 lines
8.4 KiB
JavaScript
175 lines
8.4 KiB
JavaScript
// The access file, <configDir>/access.json (federation design section 3.5,
|
|
// FED R1, #414). It names the extra users who may sign in to this instance
|
|
// and the grants that let them reach a business's threads. The validator is
|
|
// pure: providers, peers, businesses and the owner's sign-in subjects come in
|
|
// as arguments. Any failed rule refuses the whole file, which leaves every
|
|
// user with no grant (fail closed).
|
|
|
|
import { join } from "node:path";
|
|
import { refuse } from "./errors.mjs";
|
|
import { configDir } from "./organization-config.mjs";
|
|
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
|
|
// FED R3 r5 (row 274): LEVELS, the all marker and covers() live in the vocabulary, which the session
|
|
// sandbox mounts, because the broker checks a user's grant with covers(). This file re-exports them.
|
|
import { LEVELS, GRANT_ALL as ALL, covers } from "./vocabulary.mjs";
|
|
|
|
export { LEVELS, covers };
|
|
|
|
// Ids a user may never take: the bus's own principals.
|
|
const RESERVED_USERS = Object.freeze(["human", "tester"]);
|
|
const LOCAL_HOME = "local";
|
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
const SUB_MAX = 255;
|
|
|
|
export function accessFilePath(dir = configDir()) {
|
|
return join(dir, "access.json");
|
|
}
|
|
|
|
function requireArray(value, where) {
|
|
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
|
return value;
|
|
}
|
|
|
|
function requireText(value, where) {
|
|
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
|
|
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function subjectKey(provider, sub) {
|
|
return JSON.stringify([provider, sub]);
|
|
}
|
|
|
|
function checkContext(context) {
|
|
const where = "access context";
|
|
requireObject(context, where);
|
|
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
|
|
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
|
|
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
|
|
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
|
|
requireObject(context.businesses, `${where}.businesses`);
|
|
const businesses = new Map();
|
|
for (const [id, business] of Object.entries(context.businesses)) {
|
|
const at = `${where}.businesses.${id}`;
|
|
requireId(id, `${where}.businesses key`);
|
|
requireObject(business, at);
|
|
requireId(business.human, `${at}.human`);
|
|
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
|
|
businesses.set(id, { human: business.human, instances: new Set(instances) });
|
|
}
|
|
const owners = new Set();
|
|
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
|
|
const at = `${where}.ssoSubjects[${i}]`;
|
|
requireObject(s, at);
|
|
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
|
|
});
|
|
return { providers, peers, businesses, owners };
|
|
}
|
|
|
|
function checkUser(user, at, ctx, humans, seenSubjects) {
|
|
requireObject(user, at);
|
|
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
|
|
requireId(user.id, `${at}.id`);
|
|
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
|
|
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
|
|
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
|
|
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)}`);
|
|
}
|
|
const subjects = requireArray(user.subjects, `${at}.subjects`);
|
|
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
|
|
subjects.forEach((s, i) => {
|
|
const sat = `${at}.subjects[${i}]`;
|
|
requireObject(s, sat);
|
|
rejectUnknownKeys(s, ["provider", "sub"], sat);
|
|
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
|
|
refuse(`${sat}.provider must be a configured provider`);
|
|
}
|
|
requireText(s.sub, `${sat}.sub`);
|
|
const key = subjectKey(s.provider, s.sub);
|
|
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
|
|
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
|
|
seenSubjects.add(key);
|
|
});
|
|
}
|
|
|
|
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
|
|
function checkScope(value, where, check) {
|
|
const list = requireArray(value, where);
|
|
if (list.length === 0) refuse(`${where} must not be empty`);
|
|
if (list.includes(ALL)) {
|
|
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
|
|
return;
|
|
}
|
|
const seen = new Set();
|
|
list.forEach((entry, i) => {
|
|
check(entry, `${where}[${i}]`);
|
|
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
|
|
seen.add(entry);
|
|
});
|
|
}
|
|
|
|
function checkExpires(value, where) {
|
|
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
|
|
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z`);
|
|
}
|
|
const iso = new Date(value).toISOString();
|
|
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time`);
|
|
}
|
|
|
|
function checkGrant(grant, at, ctx, users, seenGrants) {
|
|
requireObject(grant, at);
|
|
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
|
|
requireId(grant.id, `${at}.id`);
|
|
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
|
|
seenGrants.add(grant.id);
|
|
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file`);
|
|
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
|
|
refuse(`${at}.business must name a declared business`);
|
|
}
|
|
const business = ctx.businesses.get(grant.business);
|
|
checkScope(grant.projects, `${at}.projects`, (p, where) => {
|
|
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID`);
|
|
});
|
|
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
|
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of the grant's business`);
|
|
});
|
|
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")}`);
|
|
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
|
}
|
|
|
|
// Check a parsed access document against the instance's context. Returns the
|
|
// document frozen. Every refusal is a BusinessError naming the key path. An
|
|
// expired grant is not refused: covers() treats it as absent.
|
|
export function checkAccess(document, context) {
|
|
const ctx = checkContext(context);
|
|
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
|
|
if (!isPlainObject(document)) refuse("access file must be a JSON object");
|
|
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
|
|
if (document.accessVersion !== 1) refuse(`accessVersion must be 1`);
|
|
requirePositiveInt(document.revision, "revision");
|
|
const users = new Set();
|
|
const seenSubjects = new Set();
|
|
requireArray(document.users, "users").forEach((user, i) => {
|
|
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
|
|
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
|
|
users.add(user.id);
|
|
});
|
|
const seenGrants = new Set();
|
|
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
|
|
return deepFreeze(structuredClone(document));
|
|
}
|
|
|
|
// Read and check the access file. One descriptor, opened without following a
|
|
// link, serves the stat checks and the read (readJsonFile). The file holds
|
|
// authority, so it must belong to this uid and not be writable by group or
|
|
// other, as the business file is.
|
|
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
|
|
const document = readJsonFile(file, "access file", (stat) => {
|
|
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}`);
|
|
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)})`);
|
|
});
|
|
return checkAccess(document, context);
|
|
}
|