/** * Network groups service — DC-144. * * DashCaddy-side groups (spec §2): a group is just an id/name used in * person.groups[] and in access grants. Groups are a UI-side concept ONLY — * they NEVER map to Tailscale ACL groups (free TS plans cap groups at 3; * compiled ACLs reference users directly, see DC-148). Group ids are * DashCaddy-local slug strings and are validated as such. * * Because groups are not a separate entity store, the set of groups is * DERIVED: the union of every id appearing in person.groups[] plus a * display-names map persisted at data/network-groups.json * ({ groups: [{ id, name }] }). 'Admin' is the well-known group (matches * the people-store bootstrap admin) and can never be renamed or deleted. * * renameGroup(fromId, { id: toId, name }) re-points every reference — * person.groups[] entries AND access grants with principal * { type: 'group', id: fromId } — by orchestrating the people store and * the access store through their public APIs (listPeople/updatePerson, * getAccessMap/saveAccessMap). deleteGroup(id) cascades the same way: * the group is removed from every person.groups[] entry and every grant * referencing it is dropped. * * Storage: same atomic JSON pattern as the other network stores * (fsync'd tmp+rename via the shared atomic-write util). */ 'use strict'; const path = require('path'); const fs = require('fs'); const platformPaths = require('../../platform-paths'); const { atomicWriteJSON } = require('../utils/atomic-write'); const ADMIN_GROUP = 'Admin'; // must match network-people-store.ADMIN_GROUP const SLUG_RE = /^[a-z0-9-]+$/; /** * Operational error thrown by the groups service. Carries an HTTP * statusCode so route error handlers can map it without instanceof * coupling (same convention as LastAdminError). */ class GroupError extends Error { constructor(message, statusCode = 400, code = 'DC_GROUP_ERROR') { super(message); this.name = 'GroupError'; this.statusCode = statusCode; this.code = code; this.isOperational = true; } } 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.renameSync(filePath, `${filePath}.corrupt-${Date.now()}`); } catch (_) { /* best-effort */ } return fallback; } } function _normalizeNames(data) { const out = new Map(); if (data && Array.isArray(data.groups)) { for (const g of data.groups) { if (g && typeof g === 'object' && typeof g.id === 'string' && g.id.length > 0) { out.set(g.id, typeof g.name === 'string' && g.name.length > 0 ? g.name : g.id); } } } return out; } function createGroupsService(opts = {}) { 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 peopleStore = opts.peopleStore; const accessStore = opts.accessStore; if (!peopleStore || typeof peopleStore.listPeople !== 'function') { throw new Error('createGroupsService requires a peopleStore'); } if (!accessStore || typeof accessStore.getAccessMap !== 'function') { throw new Error('createGroupsService requires an accessStore'); } const file = path.join(dataDir, 'network-groups.json'); // Serialized mutation mutex — renames/deletes touch multiple stores and // must not interleave with each other. let _mutex = Promise.resolve(); function _enqueue(fn) { const next = _mutex.then(fn, fn); _mutex = next.catch(() => {}); return next; } function _loadNames() { return _normalizeNames(_readJSON(file, { groups: [] })); } function _saveNames(names) { const groups = [...names.entries()] .map(([id, name]) => ({ id, name })) .sort((a, b) => a.id.localeCompare(b.id)); atomicWriteJSON(file, { groups }); } /** * Derived group set: union of ids in person.groups[], ids in the names * map, ids referenced by group grants, plus the well-known 'Admin'. * Returns Map sorted by id. */ async function _derived() { const names = _loadNames(); const people = await peopleStore.listPeople(); const map = new Map(); // id -> memberCount map.set(ADMIN_GROUP, 0); for (const p of people) { for (const gid of Array.isArray(p.groups) ? p.groups : []) { if (typeof gid !== 'string' || gid.length === 0) continue; map.set(gid, (map.get(gid) || 0) + 1); } } for (const id of names.keys()) { if (!map.has(id)) map.set(id, 0); } const access = await accessStore.getAccessMap(); for (const g of access.grants) { if (g.principal && g.principal.type === 'group' && !map.has(g.principal.id)) { map.set(g.principal.id, 0); } } const out = new Map(); for (const id of [...map.keys()].sort((a, b) => a.localeCompare(b))) { out.set(id, { id, name: names.get(id) || id, memberCount: map.get(id) }); } return out; } /** * List all groups (derived), sorted by id: [{ id, name, memberCount }]. */ function listGroups() { return _enqueue(async () => [...(await _derived()).values()]); } /** * One group by id, or null. */ async function getGroup(id) { const all = await listGroups(); return all.find(g => g.id === id) || null; } /** * Create a group ({ id, name }). Validation: id must be a DashCaddy-local * slug (/^[a-z0-9-]+$/, lowercase — the well-known 'Admin' id is rejected * as a duplicate of the always-present group), unique across the derived * set; name is a non-empty string (≤ 200 chars). The group becomes * visible immediately even with zero members because its id is persisted * in the names map. */ function createGroup(input = {}) { return _enqueue(async () => { const { id, name } = input; // Well-known group is checked BEFORE the slug gate — its id ('Admin') // is intentionally not a lowercase slug; creating it means duplicate. if (id === ADMIN_GROUP) { throw new GroupError(`group '${ADMIN_GROUP}' already exists (well-known group)`, 409, 'DC_GROUP_EXISTS'); } if (typeof id !== 'string' || !SLUG_RE.test(id)) { throw new GroupError( `group id must match ${SLUG_RE.toString()} (DashCaddy-local slug; never a Tailscale ACL group)`, 400, 'DC_GROUP_INVALID_ID'); } if (typeof name !== 'string' || name.trim().length === 0 || name.length > 200) { throw new GroupError('group name is required (1-200 chars)', 400, 'DC_GROUP_INVALID_NAME'); } const derived = await _derived(); if (derived.has(id)) { throw new GroupError(`group '${id}' already exists`, 409, 'DC_GROUP_EXISTS'); } const names = _loadNames(); names.set(id, name.trim()); _saveNames(names); log.info && log.info('network-groups', 'group created', { id }); return { id, name: name.trim(), memberCount: 0 }; }); } /** * Re-point every reference from one group id to another: each * person.groups[] entry and each access grant with principal * { type: 'group', id: fromId } — driven through the stores' public * APIs. Returns { peopleUpdated, grantsUpdated }. */ async function _repointReferences(fromId, toId) { let peopleUpdated = 0; let grantsUpdated = 0; // Cascade: re-point person.groups[] … const people = await peopleStore.listPeople(); for (const p of people) { if ((p.groups || []).includes(fromId)) { await peopleStore.updatePerson(p.id, { groups: p.groups.map(g => (g === fromId ? toId : g)), }); peopleUpdated += 1; } } // … and group-principal grant references. const access = await accessStore.getAccessMap(); const grants = access.grants.map(g => ( g.principal && g.principal.type === 'group' && g.principal.id === fromId ? { ...g, principal: { type: 'group', id: toId } } : g )); if (grants.some((g, i) => g !== access.grants[i])) { const res = await accessStore.saveAccessMap({ grants }); if (!res.ok) { throw new GroupError(res.reason || 'access map rejected the rename cascade', 500, 'DC_GROUP_CASCADE_FAILED'); } grantsUpdated = access.grants.filter( g => g.principal && g.principal.type === 'group' && g.principal.id === fromId ).length; } return { peopleUpdated, grantsUpdated }; } /** * Validate the rename patch for a group that exists in `derived`. * Returns the resolved { toId, newName } or throws GroupError. */ function _validateRenamePatch(fromId, patch, derived) { const toId = patch.id !== undefined ? patch.id : fromId; const newName = patch.name !== undefined ? patch.name : null; if (toId !== fromId) { if (toId === ADMIN_GROUP) { throw new GroupError(`group id '${ADMIN_GROUP}' is reserved`, 409, 'DC_GROUP_PROTECTED'); } if (typeof toId !== 'string' || !SLUG_RE.test(toId)) { throw new GroupError( `group id must match ${SLUG_RE.toString()} (DashCaddy-local slug; never a Tailscale ACL group)`, 400, 'DC_GROUP_INVALID_ID'); } if (derived.has(toId)) { throw new GroupError(`group '${toId}' already exists`, 409, 'DC_GROUP_EXISTS'); } } if (newName !== null && (typeof newName !== 'string' || newName.trim().length === 0 || newName.length > 200)) { throw new GroupError('group name must be 1-200 chars', 400, 'DC_GROUP_INVALID_NAME'); } return { toId, newName }; } /** * Rename a group. patch: { id?: newId, name?: newName }. * * - 'Admin' is well-known: rename refused (409). * - Changing the id re-points EVERY reference: each person.groups[] entry * and each access grant with principal { type:'group', id: fromId } * (via the stores' public APIs — peopleStore.updatePerson, * accessStore.saveAccessMap). * - Changing only the display name touches the names map alone. * Returns the updated group { id, name, memberCount }. */ function renameGroup(fromId, patch = {}) { return _enqueue(async () => { if (fromId === ADMIN_GROUP) { throw new GroupError(`group '${ADMIN_GROUP}' is well-known and cannot be renamed`, 409, 'DC_GROUP_PROTECTED'); } const derived = await _derived(); if (!derived.has(fromId)) { throw new GroupError(`group '${fromId}' not found`, 404, 'DC_GROUP_NOT_FOUND'); } const { toId, newName } = _validateRenamePatch(fromId, patch, derived); const memberCount = derived.get(fromId).memberCount; const refs = toId !== fromId ? await _repointReferences(fromId, toId) : { peopleUpdated: 0, grantsUpdated: 0 }; // Names map: move/rename the entry (create one if the group only // ever existed implicitly via person.groups[]). const names = _loadNames(); names.delete(fromId); names.set(toId, newName !== null ? newName.trim() : (names.get(toId) || toId)); _saveNames(names); log.info && log.info('network-groups', 'group renamed', { fromId, toId, ...refs, }); return { id: toId, name: names.get(toId), memberCount, ...refs }; }); } /** * Delete a group and cascade: REMOVE the id from every person.groups[] * entry and DROP every access grant whose principal is * { type: 'group', id } — one orchestrating function driving the stores' * public APIs. 'Admin' is protected (409). Returns * { deleted: true, id, removedFromPeople, removedGrants }. */ function deleteGroup(id) { return _enqueue(async () => { if (id === ADMIN_GROUP) { throw new GroupError(`group '${ADMIN_GROUP}' is well-known and cannot be deleted`, 409, 'DC_GROUP_PROTECTED'); } const derived = await _derived(); if (!derived.has(id)) { throw new GroupError(`group '${id}' not found`, 404, 'DC_GROUP_NOT_FOUND'); } let removedFromPeople = 0; let removedGrants = 0; // Cascade 1: strip the group from every person.groups[]. const people = await peopleStore.listPeople(); for (const p of people) { if ((p.groups || []).includes(id)) { const next = p.groups.filter(g => g !== id); await peopleStore.updatePerson(p.id, { groups: next }); removedFromPeople += 1; } } // Cascade 2: drop every group grant referencing it. const access = await accessStore.getAccessMap(); const kept = []; for (const g of access.grants) { if (g.principal && g.principal.type === 'group' && g.principal.id === id) { removedGrants += 1; } else { kept.push(g); } } if (removedGrants > 0) { const res = await accessStore.saveAccessMap({ grants: kept }); if (!res.ok) throw new GroupError(res.reason || 'access map rejected the delete cascade', 500, 'DC_GROUP_CASCADE_FAILED'); } // Finally remove the names-map entry (if any). const names = _loadNames(); names.delete(id); _saveNames(names); log.info && log.info('network-groups', 'group deleted', { id, removedFromPeople, removedGrants }); return { deleted: true, id, removedFromPeople, removedGrants }; }); } return { listGroups, getGroup, createGroup, renameGroup, deleteGroup, ADMIN_GROUP, }; } module.exports = { createGroupsService, GroupError, ADMIN_GROUP };