P7 Private Network (DC-140..DC-154 + judge fixes) #1

Merged
sami7777 merged 56 commits from p7-wave1 into main 2026-10-04 11:04:25 +02:00
4 changed files with 1264 additions and 0 deletions
Showing only changes of commit a78d0ef857 - Show all commits

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 });
});
});
+153
View File
@@ -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,
};