Files
dashcaddy/dashcaddy-api/src/security/network-access-store.js
T

336 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};