merge dc/DC-145 into p7-wave1
This commit is contained in:
4 files changed
+1264
No files matched your search
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Tests for network-access-store (DC-145).
|
||||
* Coverage:
|
||||
* - CRUD: getAccessMap empty default, saveAccessMap, setGrant, removeGrant
|
||||
* - validation rejections: bad principal type, empty resource, non-bool allowed
|
||||
* - dedupe last-wins on saveAccessMap and setGrant upsert
|
||||
* - atomicity: concurrent writes serialize; invalid save leaves file untouched
|
||||
* - effective access: union of groups, person-override-beats-group (both
|
||||
* directions: person true beats group false, person false beats group true)
|
||||
* - subscribe change events + unsubscribe + listener isolation
|
||||
* - derived views: listPrincipals / listResources
|
||||
* - on-disk shape {grants, updatedAt} at data/network-access.json
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const {
|
||||
createNetworkAccessStore,
|
||||
normalizeGrants,
|
||||
validateGrant,
|
||||
} = require('../src/security/network-access-store');
|
||||
|
||||
function _tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-netaccess-'));
|
||||
}
|
||||
function _cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
function _readFile(dir) {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, 'network-access.json'), 'utf8'));
|
||||
}
|
||||
|
||||
describe('network-access-store: basic CRUD', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('empty map when file missing — {grants: [], updatedAt: null}', async () => {
|
||||
const map = await store.getAccessMap();
|
||||
expect(map).toEqual({ grants: [], updatedAt: null });
|
||||
});
|
||||
|
||||
test('saveAccessMap persists {grants, updatedAt} to network-access.json', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.map.updatedAt).toBeTruthy();
|
||||
const onDisk = _readFile(dir);
|
||||
expect(onDisk.grants).toHaveLength(1);
|
||||
expect(onDisk.grants[0].principal).toEqual({ type: 'person', id: 'p1' });
|
||||
expect(onDisk.grants[0].resource).toBe('plex');
|
||||
expect(onDisk.grants[0].allowed).toBe(true);
|
||||
expect(typeof onDisk.updatedAt).toBe('string');
|
||||
});
|
||||
|
||||
test('getAccessMap returns saved grants', async () => {
|
||||
await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: true }],
|
||||
});
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
expect(map.grants[0].principal.id).toBe('family');
|
||||
});
|
||||
|
||||
test('setGrant adds a grant', async () => {
|
||||
const r = await store.setGrant({ type: 'person', id: 'p1' }, 'plex', true);
|
||||
expect(r.ok).toBe(true);
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toEqual([
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('setGrant upserts (replaces) same principal+resource — no duplicate', async () => {
|
||||
await store.setGrant({ type: 'person', id: 'p1' }, 'plex', true);
|
||||
await store.setGrant({ type: 'person', id: 'p1' }, 'plex', false);
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
expect(map.grants[0].allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('removeGrant deletes an existing grant', async () => {
|
||||
await store.setGrant({ type: 'person', id: 'p1' }, 'plex', true);
|
||||
const r = await store.removeGrant({ type: 'person', id: 'p1' }, 'plex');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.removed).toBe(true);
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('removeGrant on absent grant is idempotent (removed: false)', async () => {
|
||||
const r = await store.removeGrant({ type: 'group', id: 'x' }, 'nothing');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.removed).toBe(false);
|
||||
});
|
||||
|
||||
test('person and group grants with same id are distinct principals', async () => {
|
||||
await store.setGrant({ type: 'person', id: 'same' }, 'plex', true);
|
||||
await store.setGrant({ type: 'group', id: 'same' }, 'plex', false);
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: validation rejections', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('saveAccessMap rejects non-array grants', async () => {
|
||||
const r = await store.saveAccessMap({ grants: 'nope' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_grants/);
|
||||
});
|
||||
|
||||
test('saveAccessMap rejects bad principal type', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'robot', id: 'x' }, resource: 'plex', allowed: true }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_principal_type/);
|
||||
});
|
||||
|
||||
test('saveAccessMap rejects missing principal', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [{ resource: 'plex', allowed: true }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_principal/);
|
||||
});
|
||||
|
||||
test('saveAccessMap rejects empty/whitespace resource', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'person', id: 'p1' }, resource: ' ', allowed: true }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_resource/);
|
||||
});
|
||||
|
||||
test('saveAccessMap rejects non-string resource', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'person', id: 'p1' }, resource: 42, allowed: true }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_resource/);
|
||||
});
|
||||
|
||||
test('saveAccessMap rejects non-boolean allowed', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: 'yes' }],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/invalid_allowed/);
|
||||
});
|
||||
|
||||
test('setGrant validates the same way', async () => {
|
||||
expect((await store.setGrant({ type: 'boat', id: 'x' }, 'plex', true)).ok).toBe(false);
|
||||
expect((await store.setGrant({ type: 'person', id: 'x' }, '', true)).ok).toBe(false);
|
||||
expect((await store.setGrant({ type: 'person', id: 'x' }, 'plex', 1)).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('validateGrant exported helper matches store behavior', () => {
|
||||
expect(validateGrant({ principal: { type: 'person', id: 'a' }, resource: 'r', allowed: false })).toBe(null);
|
||||
expect(validateGrant(null)).toMatch(/invalid_grant/);
|
||||
expect(validateGrant({ principal: { type: 'person' }, resource: 'r', allowed: false })).toMatch(/invalid_principal_id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: dedupe last-wins', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('saveAccessMap dedupes exact principal+resource, last entry wins', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'group', id: 'g1' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: false }, // dup → wins
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.map.grants).toHaveLength(2);
|
||||
const p1 = r.map.grants.find(g => g.principal.type === 'person');
|
||||
expect(p1.allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('dedupe key includes principal type (person p1 ≠ group p1)', () => {
|
||||
const r = normalizeGrants([
|
||||
{ principal: { type: 'person', id: 'z' }, resource: 'r', allowed: true },
|
||||
{ principal: { type: 'group', id: 'z' }, resource: 'r', allowed: false },
|
||||
]);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.grants).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('last-wins across all fields', async () => {
|
||||
const r = await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: true },
|
||||
{ principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: false },
|
||||
{ principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: true },
|
||||
],
|
||||
});
|
||||
expect(r.map.grants).toHaveLength(1);
|
||||
expect(r.map.grants[0].allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: atomicity + concurrency', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('invalid whole-grid save leaves the previous file untouched', async () => {
|
||||
await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }],
|
||||
});
|
||||
const before = _readFile(dir);
|
||||
const bad = await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'ghost', id: 'p2' }, resource: 'plex', allowed: true },
|
||||
],
|
||||
});
|
||||
expect(bad.ok).toBe(false);
|
||||
const after = _readFile(dir);
|
||||
expect(after).toEqual(before); // nothing written, not even updatedAt
|
||||
});
|
||||
|
||||
test('no partial file after a failed save (readers never see junk)', async () => {
|
||||
await store.saveAccessMap({
|
||||
grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }],
|
||||
});
|
||||
await store.saveAccessMap({ grants: 'garbage' });
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('concurrent setGrant calls all land (mutex serialization, no lost update)', async () => {
|
||||
await Promise.all([
|
||||
store.setGrant({ type: 'person', id: 'p1' }, 'plex', true),
|
||||
store.setGrant({ type: 'person', id: 'p2' }, 'plex', true),
|
||||
store.setGrant({ type: 'group', id: 'g1' }, 'nas', false),
|
||||
store.setGrant({ type: 'person', id: 'p1' }, 'nas', false),
|
||||
]);
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('corrupt JSON on disk falls back to empty map, not a crash', async () => {
|
||||
fs.writeFileSync(path.join(dir, 'network-access.json'), '{not json');
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toEqual([]);
|
||||
});
|
||||
|
||||
test('on-disk grants with junk entries are filtered on load', async () => {
|
||||
fs.writeFileSync(path.join(dir, 'network-access.json'), JSON.stringify({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
{ junk: true },
|
||||
],
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
}));
|
||||
const map = await store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: effectiveAccess', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => {
|
||||
dir = _tmpDir();
|
||||
store = createNetworkAccessStore({ dataDir: dir });
|
||||
});
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
async function seed() {
|
||||
await store.saveAccessMap({
|
||||
grants: [
|
||||
// groups
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: true },
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'jellyfin', allowed: false },
|
||||
{ principal: { type: 'group', id: 'adults' }, resource: 'nas', allowed: false },
|
||||
{ principal: { type: 'group', id: 'adults' }, resource: 'radarr', allowed: true },
|
||||
// person-level
|
||||
{ principal: { type: 'person', id: 'sami' }, resource: 'jellyfin', allowed: true }, // override deny
|
||||
{ principal: { type: 'person', id: 'kid' }, resource: 'nas', allowed: false }, // override allow
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
test('group principal: own grants only', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'group', id: 'family' });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resources).toEqual({ plex: true, nas: true, jellyfin: false });
|
||||
});
|
||||
|
||||
test('person with no groups and no grants → empty resources', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'nobody' });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resources).toEqual({});
|
||||
});
|
||||
|
||||
test('union of groups', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'adult1', groups: ['family', 'adults'] });
|
||||
// union: plex(T)+nas(T)+jellyfin(F) from family, nas(F)+radarr(T) from adults
|
||||
// (same-resource conflict across groups: later-processed write wins is
|
||||
// unspecified, so assert presence, then deterministic bits)
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resources.plex).toBe(true);
|
||||
expect(r.resources.jellyfin).toBe(false);
|
||||
expect(r.resources.radarr).toBe(true);
|
||||
expect(typeof r.resources.nas).toBe('boolean');
|
||||
});
|
||||
|
||||
test('person override beats group — explicit false wins over group true', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'kid', groups: ['family'] });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resources.nas).toBe(false); // person false beats family true
|
||||
expect(r.resources.plex).toBe(true); // inherited from group
|
||||
});
|
||||
|
||||
test('person override beats group — explicit true wins over group false', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'sami', groups: ['family'] });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resources.jellyfin).toBe(true); // person true beats family false
|
||||
expect(r.resources.nas).toBe(true); // inherited
|
||||
});
|
||||
|
||||
test('person-level false beats group false too (no flip)', async () => {
|
||||
await seed();
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'kid', groups: ['family', 'adults'] });
|
||||
expect(r.resources.nas).toBe(false);
|
||||
});
|
||||
|
||||
test('person grant on a resource no group touches appears', async () => {
|
||||
await store.setGrant({ type: 'person', id: 'lone' }, 'plex', true);
|
||||
const r = await store.effectiveAccess({ type: 'person', id: 'lone' });
|
||||
expect(r.resources).toEqual({ plex: true });
|
||||
});
|
||||
|
||||
test('invalid principal rejected with ok:false', async () => {
|
||||
expect((await store.effectiveAccess(null)).ok).toBe(false);
|
||||
expect((await store.effectiveAccess({ type: 'robot', id: 'x' })).ok).toBe(false);
|
||||
expect((await store.effectiveAccess({ type: 'person', id: '' })).ok).toBe(false);
|
||||
expect((await store.effectiveAccess({ type: 'person' })).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: subscribe change events', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('saveAccessMap, setGrant, removeGrant each emit the new map', async () => {
|
||||
const seen = [];
|
||||
store.subscribe(map => seen.push(['evt', map.grants.length]));
|
||||
await store.saveAccessMap({ grants: [{ principal: { type: 'group', id: 'g' }, resource: 'plex', allowed: true }] });
|
||||
await store.setGrant({ type: 'person', id: 'p' }, 'nas', true);
|
||||
await store.removeGrant({ type: 'person', id: 'p' }, 'nas');
|
||||
expect(seen).toEqual([['evt', 1], ['evt', 2], ['evt', 1]]);
|
||||
});
|
||||
|
||||
test('event map includes updatedAt', async () => {
|
||||
let got = null;
|
||||
store.subscribe(map => { got = map; });
|
||||
await store.setGrant({ type: 'person', id: 'p' }, 'plex', true);
|
||||
expect(got).toBeTruthy();
|
||||
expect(typeof got.updatedAt).toBe('string');
|
||||
expect(got.grants).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('failed saves emit nothing', async () => {
|
||||
const seen = [];
|
||||
store.subscribe(() => seen.push(1));
|
||||
await store.saveAccessMap({ grants: [{ bad: true }] });
|
||||
await store.removeGrant({ type: 'person', id: 'ghost' }, 'none'); // no-op
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
test('unsubscribe stops events', async () => {
|
||||
const seen = [];
|
||||
const off = store.subscribe(() => seen.push(1));
|
||||
await store.setGrant({ type: 'person', id: 'p' }, 'plex', true);
|
||||
off();
|
||||
await store.setGrant({ type: 'person', id: 'p' }, 'nas', true);
|
||||
expect(seen).toEqual([1]);
|
||||
});
|
||||
|
||||
test('a throwing listener is isolated — other listeners still run, save still works', async () => {
|
||||
const seen = [];
|
||||
store.subscribe(() => { throw new Error('boom'); });
|
||||
store.subscribe(() => seen.push('ok'));
|
||||
const r = await store.setGrant({ type: 'person', id: 'p' }, 'plex', true);
|
||||
expect(r.ok).toBe(true);
|
||||
expect(seen).toEqual(['ok']);
|
||||
expect((await store.getAccessMap()).grants).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('subscribe requires a function', () => {
|
||||
expect(() => store.subscribe('nope')).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access-store: derived views', () => {
|
||||
let dir, store;
|
||||
beforeEach(() => { dir = _tmpDir(); store = createNetworkAccessStore({ dataDir: dir }); });
|
||||
afterEach(() => _cleanup(dir));
|
||||
|
||||
test('listPrincipals aggregates grant counts, sorted by type then id', async () => {
|
||||
await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'zeta' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'zeta' }, resource: 'nas', allowed: false },
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'abe' }, resource: 'nas', allowed: true },
|
||||
],
|
||||
});
|
||||
const principals = await store.listPrincipals();
|
||||
expect(principals).toEqual([
|
||||
{ type: 'group', id: 'family', grantCount: 1 },
|
||||
{ type: 'person', id: 'abe', grantCount: 1 },
|
||||
{ type: 'person', id: 'zeta', grantCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('listResources distinct + sorted', async () => {
|
||||
await store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p' }, resource: 'nas', allowed: true },
|
||||
{ principal: { type: 'group', id: 'g' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'group', id: 'g' }, resource: 'nas', allowed: false },
|
||||
],
|
||||
});
|
||||
expect(await store.listResources()).toEqual(['nas', 'plex']);
|
||||
});
|
||||
|
||||
test('empty map → empty views', async () => {
|
||||
expect(await store.listPrincipals()).toEqual([]);
|
||||
expect(await store.listResources()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Route tests for /network/access (DC-145).
|
||||
* Hermetic: real network-access-store against a tmp dir, express + supertest,
|
||||
* no auth middleware (the factory is mounted under the authed apiRouter in
|
||||
* the real app — here we exercise the route contract only).
|
||||
*
|
||||
* Coverage:
|
||||
* - GET / (whole map incl. empty default)
|
||||
* - PUT / (whole-grid atomic save; validation errors → 400; bad payload
|
||||
* leaves file untouched)
|
||||
* - POST /grant + DELETE /grant (upsert/remove, 400 validation, 404
|
||||
* removing an absent grant)
|
||||
* - GET /effective/:principalType/:principalId (group + person via
|
||||
* ?groups=, person-override-beats-group, 400 on bad principalType)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const { createNetworkAccessStore } = require('../src/security/network-access-store');
|
||||
const networkAccessRoutes = require('../routes/network-access');
|
||||
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-netaccess-routes-'));
|
||||
const store = createNetworkAccessStore({ dataDir: dir });
|
||||
const router = networkAccessRoutes({ accessStore: store, asyncHandler });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v1/network/access', router);
|
||||
app.use((err, _req, res, _next) => {
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({ success: false, error: err.message });
|
||||
});
|
||||
return { app, store, dir };
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// each test creates its own dir via createApp()
|
||||
});
|
||||
|
||||
describe('network-access routes: GET / (whole map)', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('empty map default → 200 {success, grants: [], updatedAt: null}', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app).get('/api/v1/network/access');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.grants).toEqual([]);
|
||||
expect(res.body.updatedAt).toBe(null);
|
||||
});
|
||||
|
||||
test('returns saved grants after PUT', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }] });
|
||||
const res = await request(ctx.app).get('/api/v1/network/access');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.grants).toHaveLength(1);
|
||||
expect(res.body.updatedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: PUT / (whole-grid atomic save)', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('valid grid saved and returned', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({
|
||||
grants: [
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'sami' }, resource: 'nas', allowed: false },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.grants).toHaveLength(2);
|
||||
expect(typeof res.body.updatedAt).toBe('string');
|
||||
});
|
||||
|
||||
test('duplicates deduped last-wins server-side', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({
|
||||
grants: [
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: false },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.grants).toHaveLength(1);
|
||||
expect(res.body.grants[0].allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('400 on bad principal type', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'robot', id: 'x' }, resource: 'plex', allowed: true }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.error).toMatch(/principal_type/);
|
||||
});
|
||||
|
||||
test('400 on empty resource', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: '', allowed: true }] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('400 on non-boolean allowed', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: 'true' }] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('400 on non-object body / missing grants', async () => {
|
||||
ctx = createApp();
|
||||
const r1 = await request(ctx.app).put('/api/v1/network/access').send([1, 2]);
|
||||
expect(r1.status).toBe(400);
|
||||
const r2 = await request(ctx.app).put('/api/v1/network/access').send({});
|
||||
expect(r2.status).toBe(400);
|
||||
});
|
||||
|
||||
test('failed validation leaves previous map on disk untouched', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true }] });
|
||||
const before = fs.readFileSync(path.join(ctx.dir, 'network-access.json'), 'utf8');
|
||||
await request(ctx.app)
|
||||
.put('/api/v1/network/access')
|
||||
.send({ grants: [{ principal: { type: 'ghost', id: 'p2' }, resource: 'plex', allowed: true }] });
|
||||
const after = fs.readFileSync(path.join(ctx.dir, 'network-access.json'), 'utf8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: POST /grant', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('creates a grant → 201 with normalized grant', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.grant).toEqual({
|
||||
principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true,
|
||||
});
|
||||
const map = await ctx.store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('upsert: second POST replaces first (no duplicate)', async () => {
|
||||
ctx = createApp();
|
||||
await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: true });
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex', allowed: false });
|
||||
expect(res.status).toBe(201);
|
||||
const map = await ctx.store.getAccessMap();
|
||||
expect(map.grants).toHaveLength(1);
|
||||
expect(map.grants[0].allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('400 when principal missing', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ resource: 'plex', allowed: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/principal/);
|
||||
});
|
||||
|
||||
test('400 when allowed not boolean', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/allowed/);
|
||||
});
|
||||
|
||||
test('400 when resource empty', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.post('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: ' ', allowed: true });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: DELETE /grant', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
test('removes an existing grant → 200', async () => {
|
||||
ctx = createApp();
|
||||
await ctx.store.setGrant({ type: 'person', id: 'p1' }, 'plex', true);
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' }, resource: 'plex' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.removed).toBe(true);
|
||||
expect((await ctx.store.getAccessMap()).grants).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('404 when grant absent', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'ghost' }, resource: 'plex' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
test('400 when principal invalid', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'ghost', id: 'x' }, resource: 'plex' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('400 when resource missing', async () => {
|
||||
ctx = createApp();
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant')
|
||||
.send({ principal: { type: 'person', id: 'p1' } });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('query-string form also works (bodyless DELETE)', async () => {
|
||||
ctx = createApp();
|
||||
await ctx.store.setGrant({ type: 'group', id: 'g' }, 'nas', true);
|
||||
const res = await request(ctx.app)
|
||||
.delete('/api/v1/network/access/grant?resource=nas&principal%5Btype%5D=group&principal%5Bid%5D=g');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.removed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('network-access routes: GET /effective/:type/:id', () => {
|
||||
let ctx;
|
||||
afterEach(() => ctx && cleanup(ctx.dir));
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = createApp();
|
||||
await ctx.store.saveAccessMap({
|
||||
grants: [
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true },
|
||||
{ principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: false },
|
||||
{ principal: { type: 'person', id: 'sami' }, resource: 'nas', allowed: true }, // override
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('group effective access', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/group/family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ plex: true, nas: false });
|
||||
expect(res.body.principal).toEqual({ type: 'group', id: 'family' });
|
||||
});
|
||||
|
||||
test('person with groups query param inherits group grants', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/randomuser?groups=family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ plex: true, nas: false });
|
||||
});
|
||||
|
||||
test('person-level grant overrides group grant', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/sami?groups=family');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ plex: true, nas: true }); // person true beats group false
|
||||
});
|
||||
|
||||
test('person with no groups → only person grants', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/sami');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ nas: true });
|
||||
});
|
||||
|
||||
test('400 on invalid principalType', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/robot/x');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/principalType|person.*group/);
|
||||
});
|
||||
|
||||
test('empty groups param ignored (not [""])', async () => {
|
||||
const res = await request(ctx.app).get('/api/v1/network/access/effective/person/sami?groups=');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resources).toEqual({ nas: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Network access grid routes — DC-145.
|
||||
*
|
||||
* Mounted by app.js under the authed apiRouter as /network/access (the
|
||||
* factory receives the store; no auth middleware lives here).
|
||||
*
|
||||
* GET /network/access → whole access map {grants, updatedAt}
|
||||
* PUT /network/access → atomic whole-grid save (validates,
|
||||
* dedupes last-wins) → saved map
|
||||
* POST /network/access/grant → upsert one grant {principal,resource,allowed}
|
||||
* DELETE /network/access/grant → remove one grant {principal,resource}
|
||||
* GET /network/access/effective/:principalType/:principalId
|
||||
* → effectiveAccess({type,id,groups?})
|
||||
* groups from ?groups=a,b query param
|
||||
*
|
||||
* The access map file (not provider state) is the single source of truth —
|
||||
* writes here fire the store's change event which the ACL compiler (DC-148)
|
||||
* subscribes to.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
|
||||
const { ok, created } = require('../src/utils/responses');
|
||||
|
||||
const PRINCIPAL_TYPES = ['person', 'group'];
|
||||
|
||||
module.exports = function networkAccessRoutesFactory({
|
||||
accessStore,
|
||||
asyncHandler,
|
||||
log = { info() {}, warn() {}, error() {} },
|
||||
} = {}) {
|
||||
const router = require('express').Router();
|
||||
|
||||
// Store is required. Same lenient-404 policy as share routes: universal
|
||||
// deps stubs in test environments may not provide one; real runtime
|
||||
// misconfig must 404, not 500.
|
||||
if (!accessStore
|
||||
|| typeof accessStore.getAccessMap !== 'function'
|
||||
|| typeof accessStore.saveAccessMap !== 'function') {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
log.warn && log.warn('network-access', 'accessStore missing — network access routes returning 404 in this environment');
|
||||
} else {
|
||||
throw new Error('networkAccessRoutes requires accessStore');
|
||||
}
|
||||
router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-145] network access store unavailable' }));
|
||||
return router;
|
||||
}
|
||||
if (!asyncHandler) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error('networkAccessRoutes requires asyncHandler');
|
||||
}
|
||||
asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
function _parsePrincipalBody(body) {
|
||||
const principal = body && body.principal;
|
||||
if (!principal || typeof principal !== 'object' || Array.isArray(principal)) {
|
||||
throw new ValidationError('principal is required ({type, id})', 'principal');
|
||||
}
|
||||
if (!PRINCIPAL_TYPES.includes(principal.type)) {
|
||||
throw new ValidationError('principal.type must be "person" or "group"', 'principal.type');
|
||||
}
|
||||
if (typeof principal.id !== 'string' || principal.id.trim().length === 0) {
|
||||
throw new ValidationError('principal.id must be a non-empty string', 'principal.id');
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
function _parseResource(body) {
|
||||
const resource = body && body.resource;
|
||||
if (typeof resource !== 'string' || resource.trim().length === 0) {
|
||||
throw new ValidationError('resource must be a non-empty string', 'resource');
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
// ─── GET / — whole map ───────────────────────────────────────────────────
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const map = await accessStore.getAccessMap();
|
||||
ok(res, { grants: map.grants, updatedAt: map.updatedAt });
|
||||
}, 'network-access-get-map'));
|
||||
|
||||
// ─── PUT / — whole-grid atomic save ──────────────────────────────────────
|
||||
router.put('/', asyncHandler(async (req, res) => {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw new ValidationError('body must be an object with a grants array', 'grants');
|
||||
}
|
||||
const result = await accessStore.saveAccessMap(body);
|
||||
if (!result.ok) {
|
||||
throw new ValidationError(result.reason || 'invalid_access_map', 'grants');
|
||||
}
|
||||
log.info && log.info('network-access', 'whole-grid save', {
|
||||
count: result.map.grants.length, by: req.user && req.user.email,
|
||||
});
|
||||
ok(res, { grants: result.map.grants, updatedAt: result.map.updatedAt });
|
||||
}, 'network-access-put-map'));
|
||||
|
||||
// ─── POST /grant — upsert one grant ──────────────────────────────────────
|
||||
router.post('/grant', asyncHandler(async (req, res) => {
|
||||
const principal = _parsePrincipalBody(req.body);
|
||||
const resource = _parseResource(req.body);
|
||||
const { allowed } = req.body || {};
|
||||
if (typeof allowed !== 'boolean') {
|
||||
throw new ValidationError('allowed must be a boolean', 'allowed');
|
||||
}
|
||||
const result = await accessStore.setGrant(principal, resource, allowed);
|
||||
if (!result.ok) {
|
||||
throw new ValidationError(result.reason || 'invalid_grant', 'grant');
|
||||
}
|
||||
created(res, { grant: { principal, resource, allowed }, updatedAt: result.map.updatedAt });
|
||||
}, 'network-access-set-grant'));
|
||||
|
||||
// ─── DELETE /grant — remove one grant ────────────────────────────────────
|
||||
router.delete('/grant', asyncHandler(async (req, res) => {
|
||||
// Accept the grant from the JSON body or from the query string so
|
||||
// curl-style deletes work without a body parser dependency.
|
||||
const body = { ...(req.body || {}), ...req.query };
|
||||
const principal = _parsePrincipalBody(body);
|
||||
const resource = _parseResource(body);
|
||||
const result = await accessStore.removeGrant(principal, resource);
|
||||
if (!result.ok) {
|
||||
throw new ValidationError(result.reason || 'invalid_grant', 'grant');
|
||||
}
|
||||
if (!result.removed) {
|
||||
// Idempotent semantics could also be 200; we 404 so the admin UI can
|
||||
// distinguish a real remove from a no-op (stale grid).
|
||||
throw new NotFoundError('grant not found');
|
||||
}
|
||||
ok(res, { removed: true, updatedAt: result.map.updatedAt });
|
||||
}, 'network-access-remove-grant'));
|
||||
|
||||
// ─── GET /effective/:principalType/:principalId ──────────────────────────
|
||||
router.get('/effective/:principalType/:principalId', asyncHandler(async (req, res) => {
|
||||
const { principalType, principalId } = req.params;
|
||||
if (!PRINCIPAL_TYPES.includes(principalType)) {
|
||||
throw new ValidationError('principalType must be "person" or "group"', 'principalType');
|
||||
}
|
||||
const principal = { type: principalType, id: principalId };
|
||||
if (principalType === 'person' && typeof req.query.groups === 'string'
|
||||
&& req.query.groups.length > 0) {
|
||||
principal.groups = req.query.groups.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
const result = await accessStore.effectiveAccess(principal);
|
||||
if (!result.ok) {
|
||||
throw new ValidationError(result.reason || 'invalid_principal', 'principal');
|
||||
}
|
||||
ok(res, { principal: { type: principalType, id: principalId }, resources: result.resources });
|
||||
}, 'network-access-effective'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -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