DC-145: access map store (grants, effective access, change events) + /network/access routes + tests
This commit is contained in:
4 files changed
+1264
No files matched your search
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in new issue
Block a user