DC-143: block/unblock/remove person + revoke device orchestration + tests
This commit is contained in:
4 files changed
+962
No files matched your search
@@ -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 };
|
||||
Reference in new issue
Block a user