301 lines
10 KiB
JavaScript
301 lines
10 KiB
JavaScript
/**
|
|
* People store — DC-141.
|
|
*
|
|
* Canonical store of Person records for the DashCaddy Private Network
|
|
* (spec §2): a human with an identity at the network provider
|
|
* (Tailscale user id or WireGuard pubkey identity), DashCaddy-side
|
|
* groups, a lifecycle status, and devices.
|
|
*
|
|
* Storage: data/network-people.json — atomic durable writes via the
|
|
* canonical shared atomic-write util (DC-099/DC-100): fsync'd tmp+rename.
|
|
* An in-memory cache fronts the file; a serialized write mutex keeps
|
|
* read-modify-write cycles atomic. On corrupt JSON the bad file is moved
|
|
* aside to network-people.json.bak (best-effort) and the store restarts
|
|
* empty rather than crash-looping.
|
|
*
|
|
* Person shape (spec §2):
|
|
* { id, displayName, providerUserId, groups[], status,
|
|
* devices[], createdAt, protectedAt }
|
|
* status ∈ 'active' | 'blocked' | 'removed'
|
|
* protectedAt: nullable ISO string — set when the person becomes the
|
|
* bootstrap admin (first person) or is otherwise admin-protected.
|
|
*
|
|
* Last-admin guard: assertNotLastAdmin(personId) throws LastAdminError
|
|
* when the person is the ONLY active member of group 'Admin'. Exported
|
|
* for reuse by DC-143 (block/unblock/remove) and DC-148 (ACL compiler).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
const platformPaths = require('../../platform-paths');
|
|
const { atomicWriteJSON } = require('../utils/atomic-write');
|
|
|
|
const ADMIN_GROUP = 'Admin';
|
|
const STATUSES = new Set(['active', 'blocked', 'removed']);
|
|
|
|
/**
|
|
* Thrown by assertNotLastAdmin() when removing/blocking the person would
|
|
* leave the tailnet with zero active admins (lockout guard, spec §4/§5).
|
|
*/
|
|
class LastAdminError extends Error {
|
|
constructor(personId) {
|
|
super(`refusing to remove the last active admin (${personId})`);
|
|
this.name = 'LastAdminError';
|
|
this.code = 'DC_LAST_ADMIN';
|
|
this.personId = personId;
|
|
this.statusCode = 409;
|
|
this.isOperational = true;
|
|
}
|
|
}
|
|
|
|
function _nowIso() { return new Date().toISOString(); }
|
|
|
|
function _readJSON(filePath, fallback) {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
return JSON.parse(raw);
|
|
} catch (err) {
|
|
if (err && err.code === 'ENOENT') return fallback;
|
|
// Corrupt file: quarantine best-effort, restart from fallback.
|
|
try {
|
|
fs.copyFileSync(filePath, `${filePath}.bak`);
|
|
fs.renameSync(filePath, `${filePath}.corrupt-${Date.now()}`);
|
|
} catch (_) { /* best-effort quarantine; read fails again → fallback */ }
|
|
try { fs.readFileSync(filePath, 'utf8'); } catch (_) { return fallback; }
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function _defaultData() { return { people: {} }; }
|
|
|
|
function _normalizePerson(p) {
|
|
return {
|
|
id: p.id,
|
|
displayName: p.displayName || '',
|
|
providerUserId: p.providerUserId || '',
|
|
groups: Array.isArray(p.groups) ? p.groups : [],
|
|
status: STATUSES.has(p.status) ? p.status : 'active',
|
|
devices: Array.isArray(p.devices) ? p.devices : [],
|
|
createdAt: p.createdAt || _nowIso(),
|
|
protectedAt: typeof p.protectedAt === 'string' ? p.protectedAt : null,
|
|
};
|
|
}
|
|
|
|
function createPeopleStore(opts = {}) {
|
|
// Same defensive resolver as invite-store / user-store — universal-deps
|
|
// test proxies can return function-typed values for property access.
|
|
const candidates = [
|
|
opts.dataDir,
|
|
opts.platformPaths && opts.platformPaths.dataDir,
|
|
platformPaths && platformPaths.dataDir,
|
|
];
|
|
const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0)
|
|
|| require('os').tmpdir(); // eslint-disable-line global-require
|
|
const log = opts.log || { info() {}, warn() {}, error() {} };
|
|
|
|
const file = path.join(dataDir, 'network-people.json');
|
|
|
|
// In-memory cache: populated on first load, invalidated on every save so
|
|
// a second store instance on the same file (or an external edit) is
|
|
// re-read rather than served stale.
|
|
let _cache = null;
|
|
|
|
// Serialized write mutex (invite-store pattern) — every mutation runs
|
|
// load→modify→save inside _enqueue so concurrent callers cannot lose
|
|
// writes (last-writer-wins whole-file clobber).
|
|
let _mutex = Promise.resolve();
|
|
function _enqueue(fn) {
|
|
const next = _mutex.then(fn, fn);
|
|
_mutex = next.catch(() => {});
|
|
return next;
|
|
}
|
|
|
|
function _load() {
|
|
if (_cache) return _cache;
|
|
const data = _readJSON(file, _defaultData());
|
|
if (!data.people || typeof data.people !== 'object' || Array.isArray(data.people)) {
|
|
data.people = {};
|
|
}
|
|
for (const id of Object.keys(data.people)) {
|
|
const p = data.people[id];
|
|
if (!p || typeof p !== 'object') { delete data.people[id]; continue; }
|
|
data.people[id] = _normalizePerson(p);
|
|
}
|
|
_cache = data;
|
|
return data;
|
|
}
|
|
|
|
function _save(data) {
|
|
atomicWriteJSON(file, data);
|
|
_cache = data;
|
|
}
|
|
|
|
/**
|
|
* All people, active-status first then alphabetical, as plain objects.
|
|
*/
|
|
function listPeople() {
|
|
return _enqueue(() => Object.values(_load().people).map(p => ({ ...p, devices: [...p.devices], groups: [...p.groups] })));
|
|
}
|
|
|
|
/**
|
|
* Person by id, or null.
|
|
*/
|
|
function getPerson(id) {
|
|
return _enqueue(() => {
|
|
const p = _load().people[id];
|
|
return p ? { ...p, devices: [...p.devices], groups: [...p.groups] } : null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find by provider identity (Tailscale user id / WG pubkey). Returns the
|
|
* person or null. Used by the sync endpoint to match provider users to
|
|
* local records.
|
|
*/
|
|
function findByProviderUserId(providerUserId) {
|
|
return _enqueue(() => {
|
|
if (typeof providerUserId !== 'string' || providerUserId.length === 0) return null;
|
|
const people = _load().people;
|
|
for (const id of Object.keys(people)) {
|
|
if (people[id].providerUserId === providerUserId) {
|
|
const p = people[id];
|
|
return { ...p, devices: [...p.devices], groups: [...p.groups] };
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create or update a person keyed by id (id optional on create — a
|
|
* randomUUID is minted when absent). The FIRST person ever stored in an
|
|
* empty store is auto-promoted: groups ['Admin'] and protectedAt set
|
|
* (bootstrap admin, spec §2). Merging never drops protectedAt.
|
|
*
|
|
* Returns the stored person.
|
|
*/
|
|
function upsertPerson(input = {}) {
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
const existing = input.id ? data.people[input.id] : null;
|
|
const isFirst = Object.keys(data.people).length === 0;
|
|
|
|
const id = existing ? existing.id : (input.id || crypto.randomUUID());
|
|
function _resolveGroups() {
|
|
if (Array.isArray(input.groups)) return [...input.groups];
|
|
if (existing) return [...existing.groups];
|
|
return isFirst ? [ADMIN_GROUP] : [];
|
|
}
|
|
function _resolveDevices() {
|
|
if (Array.isArray(input.devices)) return [...input.devices];
|
|
if (existing) return [...existing.devices];
|
|
return [];
|
|
}
|
|
function _resolveProtectedAt() {
|
|
if (input.protectedAt !== undefined) return input.protectedAt;
|
|
if (existing && existing.protectedAt) return existing.protectedAt;
|
|
return isFirst ? _nowIso() : null;
|
|
}
|
|
const person = _normalizePerson({
|
|
...existing,
|
|
...input,
|
|
id,
|
|
// preserve guarded/lifecycle fields unless explicitly provided
|
|
groups: _resolveGroups(),
|
|
devices: _resolveDevices(),
|
|
createdAt: existing ? existing.createdAt : (input.createdAt || _nowIso()),
|
|
protectedAt: _resolveProtectedAt(),
|
|
});
|
|
data.people[id] = person;
|
|
_save(data);
|
|
|
|
log.info && log.info('people', existing ? 'person updated' : 'person created', { id });
|
|
return { ...person, devices: [...person.devices], groups: [...person.groups] };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Patch a person by id. Returns the updated person or null if missing.
|
|
* `id` is immutable. `protectedAt` can only be set, never cleared, by a
|
|
* patch (admin protection outlives profile edits).
|
|
*/
|
|
function updatePerson(id, patch = {}) {
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
const existing = data.people[id];
|
|
if (!existing) return null;
|
|
const { id: _ignored, ...rest } = patch; // id immutable
|
|
const merged = _normalizePerson({
|
|
...existing,
|
|
...rest,
|
|
id,
|
|
groups: Array.isArray(patch.groups) ? [...patch.groups] : existing.groups,
|
|
devices: Array.isArray(patch.devices) ? [...patch.devices] : existing.devices,
|
|
createdAt: existing.createdAt,
|
|
protectedAt: patch.protectedAt !== undefined ? patch.protectedAt : existing.protectedAt,
|
|
});
|
|
data.people[id] = merged;
|
|
_save(data);
|
|
return { ...merged, devices: [...merged.devices], groups: [...merged.groups] };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete a person by id. Returns true if deleted, false if not found.
|
|
*/
|
|
function deletePerson(id) {
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
if (!data.people[id]) return false;
|
|
delete data.people[id];
|
|
_save(data);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Active members of a group (status === 'active').
|
|
*/
|
|
function _activeMembersOf(data, group) {
|
|
return Object.values(data.people).filter(p => p.groups.includes(group) && p.status === 'active');
|
|
}
|
|
|
|
/**
|
|
* Last-admin lockout guard (spec §4/§5): throws LastAdminError when
|
|
* `personId` is the only ACTIVE member of the 'Admin' group. Blocking or
|
|
* removing such a person would leave the network adminless, so DC-143
|
|
* (block/remove) and DC-148 (ACL compiler) must call this first.
|
|
*
|
|
* Non-admins and admins with at least one other active admin pass freely.
|
|
* Unknown ids pass (nothing to protect).
|
|
*/
|
|
function assertNotLastAdmin(personId) {
|
|
return _enqueue(() => {
|
|
const data = _load();
|
|
const person = data.people[personId];
|
|
if (!person) return;
|
|
if (!person.groups.includes(ADMIN_GROUP)) return;
|
|
const activeAdmins = _activeMembersOf(data, ADMIN_GROUP);
|
|
if (activeAdmins.length === 1 && activeAdmins[0].id === personId) {
|
|
throw new LastAdminError(personId);
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
listPeople,
|
|
getPerson,
|
|
findByProviderUserId,
|
|
upsertPerson,
|
|
updatePerson,
|
|
deletePerson,
|
|
assertNotLastAdmin,
|
|
ADMIN_GROUP,
|
|
};
|
|
}
|
|
|
|
module.exports = { createPeopleStore, LastAdminError, ADMIN_GROUP };
|