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,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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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