Files
dashcaddy/dashcaddy-api/src/security/network-groups.js

375 lines
14 KiB
JavaScript

/**
* 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<id, {name, memberCount}> 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 };