diff --git a/dashcaddy-api/__tests__/network-access-store.test.js b/dashcaddy-api/__tests__/network-access-store.test.js new file mode 100644 index 0000000..acbd2b7 --- /dev/null +++ b/dashcaddy-api/__tests__/network-access-store.test.js @@ -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([]); + }); +}); diff --git a/dashcaddy-api/__tests__/network-access.routes.test.js b/dashcaddy-api/__tests__/network-access.routes.test.js new file mode 100644 index 0000000..90b3de8 --- /dev/null +++ b/dashcaddy-api/__tests__/network-access.routes.test.js @@ -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 }); + }); +}); diff --git a/dashcaddy-api/routes/network-access.js b/dashcaddy-api/routes/network-access.js new file mode 100644 index 0000000..c0484e7 --- /dev/null +++ b/dashcaddy-api/routes/network-access.js @@ -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; +}; diff --git a/dashcaddy-api/src/security/network-access-store.js b/dashcaddy-api/src/security/network-access-store.js new file mode 100644 index 0000000..066af97 --- /dev/null +++ b/dashcaddy-api/src/security/network-access-store.js @@ -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, +};