349 lines
12 KiB
JavaScript
349 lines
12 KiB
JavaScript
/**
|
|
* 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 };
|