// The access file, /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); }