/** * Network access store — DC-145. * * The access map is the single source of truth for who can reach which * resource on the DashCaddy Private Network (spec §2). It is a grid of * principals (people or groups) × resources with allow/deny grants: * * data/network-access.json: * { * grants: [{ principal: {type:'person'|'group', id}, resource, allowed }], * updatedAt * } * * Resolution semantics (effectiveAccess): * - A person's effective access = union of the grants of every group they * belong to, then person-level grants OVERRIDE group grants — an explicit * person grant (true or false) always wins over any group grant. * - A group's effective access is just its own grants. * * The store knows only principal IDs — it never reads people.json. Group * membership is supplied by the caller (from the people store) as an array * of group IDs on the principal. * * Change events: subscribe(fn) registers a listener fired after every * successful mutation with the new map. The ACL compiler (DC-148) uses this * to recompile provider rules. Listeners run after the atomic write has * completed, are isolated (one listener throwing never breaks the mutation * or other listeners), and unsubscribe on return value. * * Storage: same atomic JSON pattern as invite-store/user-store (DC-099 * fsync'd tmp+rename via the shared atomic-write util). All mutations run * through a promise mutex so concurrent writers serialize. */ 'use strict'; const path = require('path'); const fs = require('fs'); const platformPaths = require('../../platform-paths'); const { atomicWriteJSON } = require('../utils/atomic-write'); const PRINCIPAL_TYPES = ['person', 'group']; function _nowIso() { return new Date().toISOString(); } function _readJSON(filePath, fallback) { try { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } catch (err) { if (err && err.code === 'ENOENT') return fallback; return fallback; } } /** * Validate a single grant object. Returns null when valid, else a string * describing the problem (machine-y reason code + detail). */ function validateGrant(grant) { if (!grant || typeof grant !== 'object' || Array.isArray(grant)) { return 'invalid_grant: not an object'; } const { principal, resource, allowed } = grant; if (!principal || typeof principal !== 'object' || Array.isArray(principal)) { return 'invalid_principal: principal must be an object'; } if (!PRINCIPAL_TYPES.includes(principal.type)) { return `invalid_principal_type: ${JSON.stringify(principal.type)} (expected person|group)`; } if (typeof principal.id !== 'string' || principal.id.trim().length === 0) { return 'invalid_principal_id: must be a non-empty string'; } if (typeof resource !== 'string' || resource.trim().length === 0) { return 'invalid_resource: must be a non-empty string'; } if (typeof allowed !== 'boolean') { return 'invalid_allowed: must be a boolean'; } return null; } /** * Normalize + validate a whole grants array. * - validates each grant * - dedupes exact principal(type+id)+resource collisions, LAST WINS * Returns { ok: true, grants } or { ok: false, reason }. */ function normalizeGrants(list) { if (!Array.isArray(list)) return { ok: false, reason: 'invalid_grants: must be an array' }; const out = []; const seen = new Map(); // key -> index in out (last-wins by replacement) for (const raw of list) { const err = validateGrant(raw); if (err) return { ok: false, reason: err }; const g = { principal: { type: raw.principal.type, id: raw.principal.id }, resource: raw.resource, allowed: raw.allowed, }; const key = `${g.principal.type}\u0000${g.principal.id}\u0000${g.resource}`; if (seen.has(key)) { out[seen.get(key)] = g; // last-wins dedupe } else { seen.set(key, out.length); out.push(g); } } return { ok: true, grants: out }; } function createNetworkAccessStore(opts = {}) { const candidates = [ opts.dataDir, opts.platformPaths && opts.platformPaths.dataDir, platformPaths && platformPaths.dataDir, ]; const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0) || require('os').tmpdir(); const log = opts.log || { info() {}, warn() {}, error() {} }; const file = path.join(dataDir, 'network-access.json'); let _mutex = Promise.resolve(); function _enqueue(fn) { const next = _mutex.then(fn, fn); _mutex = next.catch(() => {}); return next; } // ── change events ──────────────────────────────────────────────────────── const _listeners = new Set(); /** * Subscribe to change events. fn(map) is called after every successful * mutation with the freshly saved map ({ grants, updatedAt }). * Returns an unsubscribe function. */ function subscribe(fn) { if (typeof fn !== 'function') { throw new TypeError('subscribe requires a function'); } _listeners.add(fn); return () => _listeners.delete(fn); } function _emit(map) { for (const fn of _listeners) { try { fn(map); } catch (err) { log.warn && log.warn('network-access', 'change listener threw (isolated)', { err: err && err.message, }); } } } // ── load / save ────────────────────────────────────────────────────────── function _defaultData() { return { grants: [], updatedAt: null }; } function _load() { const data = _readJSON(file, _defaultData()); if (!Array.isArray(data.grants)) data.grants = []; // Defensive: tolerate pre-existing junk on disk by re-normalizing. const norm = normalizeGrants(data.grants.filter(g => validateGrant(g) === null)); data.grants = norm.grants; return data; } function _save(data) { data.updatedAt = _nowIso(); atomicWriteJSON(file, data); } /** * Read the whole access map ({ grants, updatedAt }). Never rejects — * missing/corrupt file yields the empty map. */ function getAccessMap() { return _enqueue(() => _load()); } /** * Atomically replace the whole map. Validates every grant (type in * person|group, non-empty string resource, boolean allowed), dedupes * exact principal+resource collisions last-wins, writes fsync'd * tmp+rename, then emits a change event. * Returns { ok: true, map } | { ok: false, reason }. */ function saveAccessMap(map) { return _enqueue(() => { const grantsInput = map && typeof map === 'object' ? map.grants : map; const norm = normalizeGrants(grantsInput); if (!norm.ok) { return { ok: false, reason: norm.reason }; } const data = { grants: norm.grants, updatedAt: null }; _save(data); _emit(data); return { ok: true, map: data }; }); } function _key(g) { return `${g.principal.type}\u0000${g.principal.id}\u0000${g.resource}`; } /** * Set (upsert) one grant. Validates; rejects duplicates by construction * (same principal+resource is replaced). Returns the saved map. */ function setGrant(principal, resource, allowed) { return _enqueue(() => { const norm = normalizeGrants([{ principal, resource, allowed }]); if (!norm.ok) return { ok: false, reason: norm.reason }; const grant = norm.grants[0]; const data = _load(); const k = _key(grant); const idx = data.grants.findIndex(g => _key(g) === k); if (idx >= 0) data.grants[idx] = grant; else data.grants.push(grant); _save(data); _emit(data); return { ok: true, map: data }; }); } /** * Remove one grant (exact principal+resource). Returns * { ok: true, removed: true, map } | { ok: true, removed: false } when * the grant was absent (idempotent delete), or { ok:false, reason } on * invalid input. */ function removeGrant(principal, resource) { return _enqueue(() => { const norm = normalizeGrants([{ principal, resource, allowed: true }]); if (!norm.ok) return { ok: false, reason: norm.reason }; const g = norm.grants[0]; const data = _load(); const k = _key(g); const before = data.grants.length; data.grants = data.grants.filter(x => _key(x) !== k); if (data.grants.length === before) return { ok: true, removed: false, map: data }; _save(data); _emit(data); return { ok: true, removed: true, map: data }; }); } /** * Compute a principal's effective access → { resources: {name: allowed} }. * * principal: {type:'group', id} → the group's own grants. * principal: {type:'person', id, groups?: string[]} → union of all * groups' grants, then person-level grants OVERRIDE (explicit person * grant true/false beats any group grant, including per-resource deny). */ async function effectiveAccess(principal) { const map = await getAccessMap(); const resources = {}; if (!principal || typeof principal !== 'object' || !PRINCIPAL_TYPES.includes(principal.type) || typeof principal.id !== 'string' || principal.id.length === 0) { return { ok: false, reason: 'invalid_principal' }; } if (principal.type === 'group') { for (const g of map.grants) { if (g.principal.type === 'group' && g.principal.id === principal.id) { resources[g.resource] = g.allowed; } } return { ok: true, resources }; } // person: union of group grants first … const groups = Array.isArray(principal.groups) ? principal.groups : []; const groupSet = new Set(groups.filter(x => typeof x === 'string')); for (const g of map.grants) { if (g.principal.type === 'group' && groupSet.has(g.principal.id)) { resources[g.resource] = g.allowed; } } // … then person-level grants override. for (const g of map.grants) { if (g.principal.type === 'person' && g.principal.id === principal.id) { resources[g.resource] = g.allowed; } } return { ok: true, resources }; } /** * Derived view: distinct principals referenced by any grant. * [{ type, id, grantCount }] sorted by type then id. */ async function listPrincipals() { const map = await getAccessMap(); const acc = new Map(); for (const g of map.grants) { const k = `${g.principal.type}\u0000${g.principal.id}`; const cur = acc.get(k) || { type: g.principal.type, id: g.principal.id, grantCount: 0 }; cur.grantCount += 1; acc.set(k, cur); } return [...acc.values()].sort((a, b) => a.type === b.type ? a.id.localeCompare(b.id) : a.type.localeCompare(b.type)); } /** * Derived view: distinct resource names referenced by any grant, * sorted alphabetically. */ async function listResources() { const map = await getAccessMap(); const set = new Set(); for (const g of map.grants) set.add(g.resource); return [...set].sort(); } return { getAccessMap, saveAccessMap, setGrant, removeGrant, effectiveAccess, subscribe, listPrincipals, listResources, PRINCIPAL_TYPES, }; } module.exports = { createNetworkAccessStore, validateGrant, normalizeGrants, PRINCIPAL_TYPES, };