336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|