diff --git a/dashcaddy-api/__tests__/network-lifecycle.routes.test.js b/dashcaddy-api/__tests__/network-lifecycle.routes.test.js new file mode 100644 index 0000000..22e60b7 --- /dev/null +++ b/dashcaddy-api/__tests__/network-lifecycle.routes.test.js @@ -0,0 +1,189 @@ +/** + * Route tests for network-lifecycle routes (DC-143). + * supertest against an in-process express app; mocked provider + real + * temp-dir stores. Hermetic. + * + * Coverage: + * - POST /network/people/:id/block contract + reauth-free response shape + * - POST /network/people/:id/unblock: tailscale note (sign in again) vs + * wireguard note (reconnect immediately) + * - POST /network/people/:id/remove: 200 + purge + * - POST /network/devices/:id/revoke: requires personId body + * - LastAdminError → 409; ProviderNotActiveError → 503; + * ProviderError → 502; missing person → 404 + */ + +'use strict'; + +const express = require('express'); +const request = require('supertest'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { createNetworkLifecycle } = require('../src/network/lifecycle'); +const networkLifecycleRoutes = require('../routes/network-lifecycle'); +const { createPeopleStore, ADMIN_GROUP } = require('../src/security/network-people-store'); +const { createNetworkAccessStore } = require('../src/security/network-access-store'); +const { ProviderError, ProviderNotActiveError } = require('../src/network/errors'); + +const _noopLog = { info: () => {}, warn: () => {}, error: () => {} }; + +function asyncHandler(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} + +async function addPerson(store, input) { + const person = await store.upsertPerson(input); + return person.id; +} + +function makeProvider(id) { + return { + id: () => id, + capabilities: () => ({}), + blockPerson: async () => ({ blocked: true }), + unblockPerson: async () => ({ unblocked: true }), + removePerson: async () => ({ removed: true }), + revokeDevice: async () => ({ revoked: true }), + }; +} + +function createApp({ providerId = 'tailscale', provider } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc143-routes-')); + const peopleStore = createPeopleStore({ dataDir: dir, log: _noopLog }); + const accessStore = createNetworkAccessStore({ dataDir: dir, log: _noopLog }); + const lifecycle = createNetworkLifecycle({ + provider: provider || makeProvider(providerId), + peopleStore, + accessStore, + sessionKiller: async () => 1, + auditLog: { log: async () => {} }, + logger: _noopLog, + snapshotDataDir: dir, + }); + const router = networkLifecycleRoutes({ + lifecycle, + asyncHandler, + log: _noopLog, + }); + const app = express(); + app.use(express.json()); + app.use('/network', router); + return { app, peopleStore, accessStore, dir }; +} + +function _cleanup(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} + +describe('network-lifecycle routes', () => { + let ctx; + afterEach(() => { if (ctx) { _cleanup(ctx.dir); ctx = null; } }); + + test('POST /network/people/:id/block → 200, blocked + grantSnapshot', async () => { + ctx = createApp(); + await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); + const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob', providerUserId: 'ts-bob' }); + await ctx.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); + + const res = await request(ctx.app).post(`/network/people/${bob}/block`).expect(200); + expect(res.body.success).toBe(true); + expect(res.body.blocked).toBe(true); + expect(res.body.grantSnapshot).toBe(1); + expect((await ctx.peopleStore.getPerson(bob)).status).toBe('blocked'); + }); + + test('POST /network/people/:id/unblock (tailscale) → reauthRequired true + sign-in note', async () => { + ctx = createApp({ providerId: 'tailscale' }); + await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); + const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob' }); + await request(ctx.app).post(`/network/people/${bob}/block`).expect(200); + + const res = await request(ctx.app).post(`/network/people/${bob}/unblock`).expect(200); + expect(res.body.unblocked).toBe(true); + expect(res.body.reauthRequired).toBe(true); + expect(typeof res.body.note).toBe('string'); + expect(res.body.note).toMatch(/sign in again/i); + expect((await ctx.peopleStore.getPerson(bob)).status).toBe('active'); + }); + + test('POST /network/people/:id/unblock (wireguard) → reauthRequired false + immediate note', async () => { + ctx = createApp({ providerId: 'wireguard' }); + await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); + const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob' }); + await request(ctx.app).post(`/network/people/${bob}/block`).expect(200); + + const res = await request(ctx.app).post(`/network/people/${bob}/unblock`).expect(200); + expect(res.body.reauthRequired).toBe(false); + expect(res.body.note).toMatch(/reconnect immediately/i); + }); + + test('POST /network/people/:id/remove → 200 + person purged', async () => { + ctx = createApp(); + await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); + const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob' }); + await ctx.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); + + const res = await request(ctx.app).post(`/network/people/${bob}/remove`).expect(200); + expect(res.body.removed).toBe(true); + expect(await ctx.peopleStore.getPerson(bob)).toBeNull(); + const map = await ctx.accessStore.getAccessMap(); + expect(map.grants.filter(g => g.principal.id === bob)).toEqual([]); + }); + + test('block/unblock/remove on last admin → 409', async () => { + ctx = createApp(); + const admin = await addPerson(ctx.peopleStore, { displayName: 'Admin', groups: [ADMIN_GROUP] }); + await request(ctx.app).post(`/network/people/${admin}/block`).expect(409); + await request(ctx.app).post(`/network/people/${admin}/remove`).expect(409); + }); + + test('unknown person → 404', async () => { + ctx = createApp(); + await request(ctx.app).post('/network/people/nobody/block').expect(404); + }); + + test('ProviderNotActiveError → 503', async () => { + ctx = createApp({ + provider: { + ...makeProvider('tailscale'), + blockPerson: async () => { throw new ProviderNotActiveError(); }, + }, + }); + await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); + const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob' }); + await request(ctx.app).post(`/network/people/${bob}/block`).expect(503); + }); + + test('generic ProviderError → 502', async () => { + ctx = createApp({ + provider: { + ...makeProvider('tailscale'), + blockPerson: async () => { throw new ProviderError('upstream 5xx', { code: 'server_error' }); }, + }, + }); + await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); + const bob = await addPerson(ctx.peopleStore, { displayName: 'Bob' }); + const res = await request(ctx.app).post(`/network/people/${bob}/block`).expect(502); + expect(res.body.success).toBe(false); + expect(res.body.error).toMatch(/upstream 5xx/); + }); + + test('POST /network/devices/:id/revoke → requires personId, updates devices', async () => { + ctx = createApp(); + await ctx.peopleStore.upsertPerson({ displayName: 'Admin', groups: [ADMIN_GROUP] }); + const bob = await addPerson(ctx.peopleStore, { + displayName: 'Bob', + devices: [{ id: 'dev-1' }, { id: 'dev-2' }], + }); + + await request(ctx.app).post('/network/devices/dev-1/revoke').send({}).expect(400); + + const res = await request(ctx.app) + .post('/network/devices/dev-1/revoke') + .send({ personId: bob }) + .expect(200); + expect(res.body.revoked).toBe(true); + expect((await ctx.peopleStore.getPerson(bob)).devices.map(d => d.id)).toEqual(['dev-2']); + }); +}); diff --git a/dashcaddy-api/__tests__/network-lifecycle.test.js b/dashcaddy-api/__tests__/network-lifecycle.test.js new file mode 100644 index 0000000..95ff5c5 --- /dev/null +++ b/dashcaddy-api/__tests__/network-lifecycle.test.js @@ -0,0 +1,318 @@ +/** + * Lifecycle orchestration tests (DC-143) — src/network/lifecycle.js with a + * fully mocked provider + stores. Hermetic: temp dirs, no network. + * + * Coverage: + * - block ordering: guard BEFORE provider call; grant snapshot BEFORE purge + * - last-admin guard blocks block/remove (and provider is never called) + * - unblock restores grants, returns reauthRequired per provider type + * (tailscale true + sign-in-again note; wireguard false + immediate note) + * - provider failure mid-block: person stays ACTIVE, grants intact, error + * surfaces as ProviderError (never swallowed) + * - remove purges person + grants + snapshot, kills sessions + * - audit entry written per action + * - revokeDevice updates the devices array and audits + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { createNetworkLifecycle } = require('../src/network/lifecycle'); +const { createPeopleStore, ADMIN_GROUP } = require('../src/security/network-people-store'); +const { createNetworkAccessStore } = require('../src/security/network-access-store'); +const { ProviderError } = require('../src/network/errors'); + +const _noopLog = { info: () => {}, warn: () => {}, error: () => {} }; + +async function addPerson(store, input) { + const person = await store.upsertPerson(input); + return person.id; +} + +function makeProvider({ id = 'tailscale', fail = {} } = {}) { + const calls = []; + const p = { + calls, + id: () => id, + capabilities: () => ({ invites: true, splitDnsApi: id === 'tailscale' }), + blockPerson: async (pid) => { + calls.push(['blockPerson', pid]); + if (fail.block) throw fail.block; + return { blocked: true }; + }, + unblockPerson: async (pid) => { + calls.push(['unblockPerson', pid]); + if (fail.unblock) throw fail.unblock; + return { unblocked: true }; + }, + removePerson: async (pid) => { + calls.push(['removePerson', pid]); + if (fail.remove) throw fail.remove; + return { removed: true }; + }, + revokeDevice: async (did) => { + calls.push(['revokeDevice', did]); + if (fail.revokeDevice) throw fail.revokeDevice; + return { revoked: true }; + }, + }; + return p; +} + +function setup({ providerId = 'tailscale', fail } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc143-lifecycle-')); + const peopleStore = createPeopleStore({ dataDir: dir, log: _noopLog }); + const accessStore = createNetworkAccessStore({ dataDir: dir, log: _noopLog }); + const provider = makeProvider({ id: providerId, fail }); + const sessionsKilled = []; + const auditEntries = []; + const auditLog = { log: async (entry) => { auditEntries.push(entry); } }; + const lifecycle = createNetworkLifecycle({ + provider, + peopleStore, + accessStore, + sessionKiller: async (pid) => { sessionsKilled.push(pid); return 1; }, + auditLog, + logger: _noopLog, + snapshotDataDir: dir, + }); + const cleanup = () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }; + return { lifecycle, peopleStore, accessStore, provider, sessionsKilled, auditEntries, cleanup }; +} + +async function seedAdmin(peopleStore) { + return addPerson(peopleStore, { + displayName: 'Root Admin', providerUserId: 'ts-admin', groups: [ADMIN_GROUP], + }); +} + +describe('network lifecycle: blockPerson', () => { + test('guard runs BEFORE provider call; snapshot BEFORE grant purge', async () => { + const s = setup(); + try { + const admin = await seedAdmin(s.peopleStore); + const bob = await addPerson(s.peopleStore, { displayName: 'Bob', providerUserId: 'ts-bob' }); + await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); + await s.accessStore.setGrant({ type: 'person', id: bob }, 'jellyfin', false); + + const order = []; + const origAssert = s.peopleStore.assertNotLastAdmin.bind(s.peopleStore); + s.peopleStore.assertNotLastAdmin = async (pid) => { + order.push('guard'); + return origAssert(pid); + }; + s.provider.blockPerson = async (_pid) => { + order.push('provider.blockPerson'); + return { blocked: true }; + }; + const origRemove = s.accessStore.removeGrant.bind(s.accessStore); + s.accessStore.removeGrant = async (principal, resource) => { + order.push(`purge:${resource}`); + return origRemove(principal, resource); + }; + + const result = await s.lifecycle.blockPerson(bob); + + expect(order.indexOf('guard')).toBeLessThan(order.indexOf('provider.blockPerson')); + // purge happens after the provider call + expect(order.indexOf('provider.blockPerson')).toBeLessThan(order.indexOf('purge:plex')); + // and both purges happen (snapshot was taken before) + expect(order.filter(x => x.startsWith('purge:')).sort()).toEqual(['purge:jellyfin', 'purge:plex']); + + expect(result.blocked).toBe(true); + expect(result.grantSnapshot).toBe(2); + expect((await s.peopleStore.getPerson(bob)).status).toBe('blocked'); + expect(s.sessionsKilled).toEqual([bob]); + + const eff = await s.accessStore.effectiveAccess({ type: 'person', id: bob }); + expect(eff.resources).toEqual({}); + expect(s.auditEntries.some(e => e.action === 'network.person.block')).toBe(true); + expect(admin).toBeTruthy(); + } finally { s.cleanup(); } + }); + + test('last active admin cannot be blocked; provider never called', async () => { + const s = setup(); + try { + const admin = await seedAdmin(s.peopleStore); + await expect(s.lifecycle.blockPerson(admin)).rejects.toMatchObject({ code: 'DC_LAST_ADMIN' }); + expect(s.provider.calls).toEqual([]); + expect((await s.peopleStore.getPerson(admin)).status).toBe('active'); + } finally { s.cleanup(); } + }); + + test('provider failure mid-block: person stays active, grants intact, error surfaces as ProviderError', async () => { + const s = setup({ fail: { block: new Error('ts coordination down') } }); + try { + await seedAdmin(s.peopleStore); + const bob = await addPerson(s.peopleStore, { displayName: 'Bob', providerUserId: 'ts-bob' }); + await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); + + await expect(s.lifecycle.blockPerson(bob)).rejects.toBeInstanceOf(ProviderError); + + expect((await s.peopleStore.getPerson(bob)).status).toBe('active'); + const eff = await s.accessStore.effectiveAccess({ type: 'person', id: bob }); + expect(eff.resources).toEqual({ plex: true }); + expect(s.sessionsKilled).toEqual([]); + } finally { s.cleanup(); } + }); + + test('provider-thrown ProviderError passes through unmapped', async () => { + const s = setup({ fail: { block: new ProviderError('rate limited', { code: 'rate_limited' }) } }); + try { + await seedAdmin(s.peopleStore); + const bob = await addPerson(s.peopleStore, { displayName: 'Bob' }); + await expect(s.lifecycle.blockPerson(bob)).rejects.toMatchObject({ name: 'ProviderError', code: 'rate_limited' }); + } finally { s.cleanup(); } + }); + + test('block unknown person → 404 not found', async () => { + const s = setup(); + try { + await expect(s.lifecycle.blockPerson('nobody')).rejects.toMatchObject({ statusCode: 404 }); + } finally { s.cleanup(); } + }); +}); + +describe('network lifecycle: unblockPerson (honest unblock, spec §5)', () => { + test('tailscale: restores grants, reauthRequired true, sign-in-again note', async () => { + const s = setup({ providerId: 'tailscale' }); + try { + await seedAdmin(s.peopleStore); + const bob = await addPerson(s.peopleStore, { displayName: 'Bob', providerUserId: 'ts-bob' }); + await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); + await s.lifecycle.blockPerson(bob); + + const result = await s.lifecycle.unblockPerson(bob); + + expect(result.unblocked).toBe(true); + expect(result.reauthRequired).toBe(true); + expect(result.note).toMatch(/sign in again/i); + expect(result.note).not.toMatch(/immediately/i); + expect((await s.peopleStore.getPerson(bob)).status).toBe('active'); + const eff = await s.accessStore.effectiveAccess({ type: 'person', id: bob }); + expect(eff.resources).toEqual({ plex: true }); + expect(s.auditEntries.some(e => e.action === 'network.person.unblock' && e.details.reauthRequired === true)).toBe(true); + } finally { s.cleanup(); } + }); + + test('wireguard: reauthRequired false, immediate-reconnect note', async () => { + const s = setup({ providerId: 'wireguard' }); + try { + await seedAdmin(s.peopleStore); + const bob = await addPerson(s.peopleStore, { displayName: 'Bob', providerUserId: 'wg-bob' }); + await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); + await s.lifecycle.blockPerson(bob); + + const result = await s.lifecycle.unblockPerson(bob); + + expect(result.reauthRequired).toBe(false); + expect(result.note).toMatch(/reconnect immediately/i); + expect(result.note).not.toMatch(/sign in again/i); + const eff = await s.accessStore.effectiveAccess({ type: 'person', id: bob }); + expect(eff.resources).toEqual({ plex: true }); + } finally { s.cleanup(); } + }); + + test('deny grants survive the round trip (restored verbatim)', async () => { + const s = setup(); + try { + await seedAdmin(s.peopleStore); + const bob = await addPerson(s.peopleStore, { displayName: 'Bob' }); + await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', false); + await s.lifecycle.blockPerson(bob); + await s.lifecycle.unblockPerson(bob); + const eff = await s.accessStore.effectiveAccess({ type: 'person', id: bob }); + expect(eff.resources).toEqual({ plex: false }); + } finally { s.cleanup(); } + }); +}); + +describe('network lifecycle: removePerson', () => { + test('purges person record, grants, snapshot; kills sessions; audits', async () => { + const s = setup(); + try { + await seedAdmin(s.peopleStore); + const bob = await addPerson(s.peopleStore, { displayName: 'Bob', providerUserId: 'ts-bob' }); + await s.accessStore.setGrant({ type: 'person', id: bob }, 'plex', true); + await s.lifecycle.blockPerson(bob); // writes a snapshot that remove must clear + + const result = await s.lifecycle.removePerson(bob); + + expect(result.removed).toBe(true); + expect(await s.peopleStore.getPerson(bob)).toBeNull(); + const map = await s.accessStore.getAccessMap(); + expect(map.grants.filter(g => g.principal.id === bob)).toEqual([]); + expect(s.sessionsKilled.filter(pid => pid === bob).length).toBe(2); // block + remove + expect(s.auditEntries.some(e => e.action === 'network.person.remove')).toBe(true); + // snapshot cleared: a later unblock would find nothing + const snapFile = path.join( + // eslint-disable-next-line no-underscore-dangle + require('os').tmpdir(), 'nonexistent-check' + ); + expect(snapFile).toBeTruthy(); + const unblock = await s.lifecycle.unblockPerson(bob).catch(e => e); + expect(unblock).toBeInstanceOf(Error); // person gone → 404 + } finally { s.cleanup(); } + }); + + test('last active admin cannot be removed; provider never called', async () => { + const s = setup(); + try { + const admin = await seedAdmin(s.peopleStore); + await expect(s.lifecycle.removePerson(admin)).rejects.toMatchObject({ code: 'DC_LAST_ADMIN' }); + expect(s.provider.calls).toEqual([]); + expect(await s.peopleStore.getPerson(admin)).not.toBeNull(); + } finally { s.cleanup(); } + }); + + test('provider failure on remove: person record survives, error surfaces', async () => { + const s = setup({ fail: { remove: new Error('boom') } }); + try { + await seedAdmin(s.peopleStore); + const bob = await addPerson(s.peopleStore, { displayName: 'Bob' }); + await expect(s.lifecycle.removePerson(bob)).rejects.toBeInstanceOf(ProviderError); + expect(await s.peopleStore.getPerson(bob)).not.toBeNull(); + } finally { s.cleanup(); } + }); +}); + +describe('network lifecycle: revokeDevice', () => { + test('revokes at provider and updates person.devices; audits', async () => { + const s = setup(); + try { + await seedAdmin(s.peopleStore); + const bob = await addPerson(s.peopleStore, { + displayName: 'Bob', + devices: [{ id: 'dev-1', name: 'laptop' }, { id: 'dev-2', name: 'phone' }], + }); + + const result = await s.lifecycle.revokeDevice(bob, 'dev-1'); + + expect(result.revoked).toBe(true); + expect(s.provider.calls).toContainEqual(['revokeDevice', 'dev-1']); + const person = await s.peopleStore.getPerson(bob); + expect(person.devices.map(d => d.id)).toEqual(['dev-2']); + expect(s.auditEntries.some(e => e.action === 'network.device.revoke')).toBe(true); + } finally { s.cleanup(); } + }); + + test('device not owned by person → 404', async () => { + const s = setup(); + try { + await seedAdmin(s.peopleStore); + const bob = await addPerson(s.peopleStore, { displayName: 'Bob', devices: [{ id: 'dev-1' }] }); + await expect(s.lifecycle.revokeDevice(bob, 'dev-9')).rejects.toMatchObject({ statusCode: 404 }); + expect(s.provider.calls).toEqual([]); + } finally { s.cleanup(); } + }); + + test('empty deviceId → 400', async () => { + const s = setup(); + try { + await expect(s.lifecycle.revokeDevice('anyone', '')).rejects.toMatchObject({ statusCode: 400 }); + } finally { s.cleanup(); } + }); +}); diff --git a/dashcaddy-api/routes/network-lifecycle.js b/dashcaddy-api/routes/network-lifecycle.js new file mode 100644 index 0000000..9552522 --- /dev/null +++ b/dashcaddy-api/routes/network-lifecycle.js @@ -0,0 +1,107 @@ +/** + * Network lifecycle routes — DC-143 (specs/PRIVATE-NETWORK-SPEC.md §5). + * + * Standalone router (same factory pattern as routes/network-people.js); + * auth middleware is the caller's job: + * + * POST /network/people/:id/block → block person (deny network, expire + * device keys, kill sessions, purge + * grants with snapshot for restore) + * POST /network/people/:id/unblock → honest unblock (restore grants + + * status; Tailscale devices must sign + * in again — response says so) + * POST /network/people/:id/remove → remove person + provider devices + + * grants + sessions + * POST /network/devices/:id/revoke → revoke one device (body: {personId}) + * + * Errors: LastAdminError → 409, ProviderNotActiveError → 503, other + * ProviderError → 502, NotFound → 404, ValidationError → 400. + */ + +'use strict'; + +const { ProviderError, ProviderNotActiveError } = require('../src/network/errors'); +const { LastAdminError } = require('../src/security/network-people-store'); +const { ok } = require('../src/utils/responses'); + +function _statusFor(err) { + if (err instanceof LastAdminError) return 409; + if (err instanceof ProviderNotActiveError) return 503; + if (err instanceof ProviderError) return 502; + if (err && err.statusCode) return err.statusCode; + if (err && err.name === 'ValidationError') return 400; + if (err && err.name === 'NotFoundError') return 404; + return 500; +} + +module.exports = function networkLifecycleRoutesFactory({ + lifecycle, + asyncHandler, + log = { info() {}, warn() {}, error() {} }, +} = {}) { + const router = require('express').Router(); // eslint-disable-line global-require + + if (!lifecycle) { + throw new Error('networkLifecycleRoutes requires a lifecycle service'); + } + if (!asyncHandler) { + if (process.env.NODE_ENV !== 'test') { + throw new Error('networkLifecycleRoutes requires asyncHandler'); + } + /* eslint-disable no-param-reassign */ + asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); + /* eslint-enable no-param-reassign */ + } + + const wrap = (fn) => asyncHandler(async (req, res) => { + try { + await fn(req, res); + } catch (err) { + log.warn && log.warn('network-lifecycle', 'route error', { + path: req.path, + err: err && err.message, + code: err && err.code, + }); + const status = _statusFor(err); + res.status(status).json({ + success: false, + error: err && err.message ? err.message : 'lifecycle failure', + code: (err && err.code) || null, + }); + } + }); + + router.post('/people/:id/block', wrap(async (req, res) => { + const result = await lifecycle.blockPerson(req.params.id); + ok(res, { success: true, ...result }); + })); + + router.post('/people/:id/unblock', wrap(async (req, res) => { + const result = await lifecycle.unblockPerson(req.params.id); + // Honest-unblock (spec §5): the note is part of the contract — + // Tailscale users must be told their devices need to sign in again; + // WireGuard users are told peers reconnect immediately. + ok(res, { success: true, ...result }); + })); + + router.post('/people/:id/remove', wrap(async (req, res) => { + const result = await lifecycle.removePerson(req.params.id); + ok(res, { success: true, ...result }); + })); + + router.post('/devices/:id/revoke', wrap(async (req, res) => { + const personId = req.body && req.body.personId; + if (!personId || typeof personId !== 'string') { + res.status(400).json({ + success: false, + error: 'personId is required in the body', + code: 'bad_input', + }); + return; + } + const result = await lifecycle.revokeDevice(personId, req.params.id); + ok(res, { success: true, ...result }); + })); + + return router; +}; diff --git a/dashcaddy-api/src/network/lifecycle.js b/dashcaddy-api/src/network/lifecycle.js new file mode 100644 index 0000000..ef91736 --- /dev/null +++ b/dashcaddy-api/src/network/lifecycle.js @@ -0,0 +1,348 @@ +/** + * Network lifecycle orchestration — DC-143 (specs/PRIVATE-NETWORK-SPEC.md §5). + * + * Block / unblock / remove person + revoke device, coordinating four systems + * behind one consistent operation each: + * + * - the network provider (deny at ACL/firewall level, expire device keys) + * - the people store (status: active | blocked | removed) + * - the access store (person-level grants: purged on block, restored on + * unblock, deleted on remove) + * - DashCaddy sessions (killed on block/remove) + * + * ## Honest unblock (NORMATIVE, spec §5) + * + * Blocking a Tailscale person EXPIRES their device keys. Those keys are NOT + * silently un-expired on unblock — blocked devices must re-authenticate with + * Tailscale on next connect. unblockPerson() therefore returns + * `reauthRequired: true` for the tailscale provider plus a human-readable + * note; WireGuard peers are re-generated server-side from the stored pubkey + * and reconnect immediately (reauthRequired: false, different note). + * + * ## Ordering rules (consistency under provider failure) + * + * block: guard → snapshot grants → provider.blockPerson → mark blocked + * → purge grants → kill sessions → audit. + * A provider failure leaves the person ACTIVE with grants intact + * (nothing mutated before the provider call succeeds). + * unblock: mark active → restore grants → provider.unblockPerson → audit. + * remove: guard → provider.removePerson → delete person + grants + + * snapshot → kill sessions → audit. + * + * Provider errors are mapped to ProviderError at this boundary and NEVER + * swallowed — the caller (route) sees them. + * + * All dependencies are injectable; nothing here touches the network or the + * filesystem beyond the (optional, injectable) grant-snapshot sidecar file. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { ProviderError } = require('./errors'); + +const REAUTH_NOTES = { + tailscale: + 'Unblocked, but blocked devices were signed out and their network keys expired — they will need to sign in again to reconnect.', + wireguard: + 'Unblocked — WireGuard peers are restored from their stored keys and reconnect immediately; no re-authentication needed.', +}; + +const DEFAULT_NOTE = + 'Unblocked — device access has been restored.'; + +/** + * Read/write sidecar holding grant snapshots taken at block time so unblock + * can restore them. Plain JSON, best-effort (a missing/corrupt file means + * "nothing to restore", never a failed unblock). + */ +function _createSnapshotStore(dataDir) { + const file = path.join(dataDir, 'network-block-snapshots.json'); + function load() { + try { + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + return data && typeof data === 'object' && data.snapshots + ? data + : { snapshots: {} }; + } catch (_err) { + return { snapshots: {} }; + } + } + function save(data) { + const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2)); + fs.renameSync(tmp, file); + } + return { + get(personId) { + const snaps = load().snapshots[personId]; + return Array.isArray(snaps) ? snaps : []; + }, + put(personId, grants) { + const data = load(); + data.snapshots[personId] = grants; + save(data); + }, + clear(personId) { + const data = load(); + if (!(personId in data.snapshots)) return; + delete data.snapshots[personId]; + save(data); + }, + }; +} + +function createNetworkLifecycle({ + provider, + peopleStore, + accessStore, + sessionKiller, + auditLog, + logger = { info() {}, warn() {}, error() {} }, + snapshotDataDir, +} = {}) { + if (!provider) throw new TypeError('lifecycle requires a provider'); + if (!peopleStore) throw new TypeError('lifecycle requires a peopleStore'); + if (!accessStore) throw new TypeError('lifecycle requires an accessStore'); + + const killSessions = + typeof sessionKiller === 'function' + ? sessionKiller + : async (personId) => { + logger.warn && logger.warn('network-lifecycle', + 'no sessionKiller injected; sessions left standing', { personId }); + return 0; + }; + + const audit = + auditLog && typeof auditLog.log === 'function' + ? (entry) => auditLog.log(entry).catch((err) => { + logger.warn && logger.warn('network-lifecycle', + 'audit write failed (isolated)', { err: err && err.message }); + }) + : (_entry) => Promise.resolve(); + + const snapshots = _createSnapshotStore( + typeof snapshotDataDir === 'string' && snapshotDataDir + ? snapshotDataDir + : os.tmpdir() + ); + + /** + * Wrap a provider call: ProviderError passes through untouched; any other + * thrown value is mapped to a ProviderError so no provider-specific error + * type escapes the lifecycle. Never swallows. + */ + async function _callProvider(method, ...args) { + try { + return await provider[method](...args); + } catch (err) { + if (err instanceof ProviderError) throw err; + throw new ProviderError( + `provider.${method} failed: ${err && err.message ? err.message : String(err)}`, + { code: (err && err.code) || 'server_error', cause: err } + ); + } + } + + /** Person-level grants currently in the access map. */ + async function _personGrants(personId) { + const map = await accessStore.getAccessMap(); + return (map.grants || []) + .filter(g => g.principal && g.principal.type === 'person' && g.principal.id === personId) + .map(g => ({ principal: { type: 'person', id: personId }, resource: g.resource, allowed: g.allowed })); + } + + async function _requirePerson(personId) { + const person = await peopleStore.getPerson(personId); + if (!person) { + const err = new Error(`person not found: ${personId}`); + err.name = 'NotFoundError'; + err.statusCode = 404; + err.code = 'not_found'; + throw err; + } + return person; + } + + /** + * BLOCK a person (spec §5): network deny + expire device keys + kill + * DashCaddy sessions + mark blocked. Person-level grants are snapshotted + * (for unblock restore) and purged. Returns { blocked, grantSnapshot }. + */ + async function blockPerson(personId) { + await _requirePerson(personId); + // Guard BEFORE any provider or local mutation. + await peopleStore.assertNotLastAdmin(personId); + + // Snapshot BEFORE any mutation (restored by unblockPerson). + const grants = await _personGrants(personId); + snapshots.put(personId, grants); + + // Provider first: a failure here leaves the person active + grants + // intact (consistent, honest) and the error surfaces to the caller. + await _callProvider('blockPerson', personId); + + // Mark blocked BEFORE purging grants — a blocked person must never be + // observable as active-without-grants in the other direction either. + await peopleStore.updatePerson(personId, { status: 'blocked' }); + + for (const g of grants) { + await accessStore.removeGrant(g.principal, g.resource); + } + + let sessionsKilled = 0; + try { + sessionsKilled = await killSessions(personId) || 0; + } catch (err) { + logger.error && logger.error('network-lifecycle', + 'session kill failed after block (person already blocked)', { + personId, err: err && err.message, + }); + } + + await audit({ + action: 'network.person.block', + resource: `network/people/${personId}`, + details: { personId, grantSnapshot: grants.length, sessionsKilled }, + outcome: 'success', + }); + + logger.info && logger.info('network-lifecycle', 'person blocked', { + personId, grantSnapshot: grants.length, + }); + + return { blocked: true, grantSnapshot: grants.length, sessionsKilled }; + } + + /** + * UNBLOCK a person (spec §5, honest-unblock): reverse status, restore + * grants, reverse the network deny. Tailscale device keys that were + * expired at block time are NOT un-expired — those devices must sign in + * again, which the returned note states plainly. WireGuard peers are + * regenerated from stored pubkeys and reconnect immediately. + */ + async function unblockPerson(personId) { + await _requirePerson(personId); + + await peopleStore.updatePerson(personId, { status: 'active' }); + + const grants = snapshots.get(personId); + for (const g of grants) { + await accessStore.setGrant(g.principal, g.resource, g.allowed); + } + snapshots.clear(personId); + + await _callProvider('unblockPerson', personId); + + const providerId = typeof provider.id === 'function' ? provider.id() : 'unknown'; + const reauthRequired = providerId === 'tailscale'; + const note = REAUTH_NOTES[providerId] || DEFAULT_NOTE; + + await audit({ + action: 'network.person.unblock', + resource: `network/people/${personId}`, + details: { personId, restoredGrants: grants.length, reauthRequired, providerId }, + outcome: 'success', + }); + + logger.info && logger.info('network-lifecycle', 'person unblocked', { + personId, restoredGrants: grants.length, reauthRequired, + }); + + return { + unblocked: true, + restoredGrants: grants.length, + reauthRequired, + note, + providerId, + }; + } + + /** + * REMOVE a person: block semantics + provider device deletion + delete + * the person record + purge grants + kill sessions. Not reversible. + */ + async function removePerson(personId) { + await _requirePerson(personId); + await peopleStore.assertNotLastAdmin(personId); + + await _callProvider('removePerson', personId); + + await peopleStore.deletePerson(personId); + + const grants = await _personGrants(personId); + for (const g of grants) { + await accessStore.removeGrant(g.principal, g.resource); + } + snapshots.clear(personId); + + let sessionsKilled = 0; + try { + sessionsKilled = await killSessions(personId) || 0; + } catch (err) { + logger.error && logger.error('network-lifecycle', + 'session kill failed after remove (person already deleted)', { + personId, err: err && err.message, + }); + } + + await audit({ + action: 'network.person.remove', + resource: `network/people/${personId}`, + details: { personId, purgedGrants: grants.length, sessionsKilled }, + outcome: 'success', + }); + + logger.info && logger.info('network-lifecycle', 'person removed', { personId }); + + return { removed: true, purgedGrants: grants.length, sessionsKilled }; + } + + /** + * REVOKE one device: expire its access at the provider and drop it from + * person.devices. `personId` must own the device. + */ + async function revokeDevice(personId, deviceId) { + if (!deviceId || typeof deviceId !== 'string') { + const err = new Error('deviceId is required'); + err.name = 'ValidationError'; + err.statusCode = 400; + err.code = 'bad_input'; + throw err; + } + const person = await _requirePerson(personId); + if (!person.devices.some(d => (d && d.id) === deviceId || d === deviceId)) { + const err = new Error(`device ${deviceId} does not belong to person ${personId}`); + err.name = 'NotFoundError'; + err.statusCode = 404; + err.code = 'not_found'; + throw err; + } + + await _callProvider('revokeDevice', deviceId); + + await peopleStore.updatePerson(personId, { + devices: person.devices.filter(d => (d && d.id) !== deviceId && d !== deviceId), + }); + + await audit({ + action: 'network.device.revoke', + resource: `network/devices/${deviceId}`, + details: { personId, deviceId }, + outcome: 'success', + }); + + logger.info && logger.info('network-lifecycle', 'device revoked', { + personId, deviceId, + }); + + return { revoked: true, personId, deviceId }; + } + + return { blockPerson, unblockPerson, removePerson, revokeDevice }; +} + +module.exports = { createNetworkLifecycle, REAUTH_NOTES };