/** * 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 };