Files
dashcaddy/dashcaddy-api/src/security/user-store.js
T
hermes 321334cd33
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-048: multi-user bootstrap + admin invites (opt-in)
Implements the user-store + invite-store + admin routes. The whole
system is opt-in via siteConfig.authProviders.email.enabled = true;
single-user TOTP-only installs see zero behavior change.

Backend:
- src/security/user-store.js: users + allowlist + bootstrap sentinel,
  atomic writes, last-admin protection, defensive dataDir resolver.
- src/security/invite-store.js: single-use tokens (SHA-256 hashed on
  disk), TTL, auto-prune, defensive dataDir resolver.
- routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist,
  /admin/invites (CRUD), public /invites/:token (peek + accept).
- routes/auth/index.js: wires userStore, gates admin router on
  email auth being enabled.
- src/auth/providers/email.js: verify() enforces allowlist, creates
  user record, tags req.user; default-enabled flipped to opt-in.
- src/auth/providers/totp.js: bootstraps system@totp.local admin on
  first verify so current DNS2 operator shows in /admin/users.
- src/security/audit-logger.js: middleware adds userId/userEmail/
  userRole/viaProvider to log details when req.user is tagged.
- PUBLIC_ROUTES + CSRF allowlists updated for invite redemption.

Frontend:
- status/js/admin.js: modal overlay with users list (role-edit,
  delete), invite form (email/role/TTL), copy-link button,
  outstanding-invites list with revoke. Exports window.AdminPanel.
- status/js/core/init.js: calls AdminPanel.attachTrigger so the
  Admin button only appears when /me returns isAdmin=true.

Tests: 35 new tests across 3 files (user-store, invite-store, auth
multistore integration). Full suite: 1298/1298 passing.

Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased]
section gets the DC-048 entry.
2026-07-20 17:44:11 -07:00

407 lines
14 KiB
JavaScript

/**
* User store — DC-048.
*
* Tracks who is allowed to log in to a DashCaddy instance, and what role each
* authenticated user has. Replaces the "one implicit operator" model that
* DC-046/047 shipped with.
*
* TWO files (both live under platformPaths.dataDir):
*
* data/users.json — every user that has ever authenticated.
* Shape: {
* users: {
* [userId]: {
* id, email, displayName, role,
* createdBy, createdAt,
* lastLoginAt, lastLoginIp, loginCount
* }
* },
* order: [userId, ...]
* }
*
* data/authorized-users.json — the ALLOWLIST. Emails on this list may log in.
* The bootstrap user (first-ever login) is
* implicitly authorized even if the file is
* empty. Shape: { emails: ["a@x.com", ...] }
*
* Bootstrap rule: the FIRST email to ever successfully authenticate is
* automatically granted role "admin" AND implicitly added to the allowlist.
* This is recorded by writing a sentinel file `data/.bootstrapped` with the
* admin email so we never bootstrap twice (e.g. after a restore from backup).
*
* Atomic writes: every persistence op writes to a .tmp file then renames.
* process restart loses nothing in flight because rename is atomic on POSIX.
*
* Concurrency: a single in-process mutex serializes mutating ops. We don't
* need cross-process locks because this API is single-instance by design.
*/
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const platformPaths = require('../../platform-paths');
const ROLES = Object.freeze({
ADMIN: 'admin',
OPERATOR: 'operator',
VIEWER: 'viewer',
});
// All roles recognized by the system. Used for validation only.
const VALID_ROLES = new Set(Object.values(ROLES));
// Email shape — same pragmatic regex as the email provider.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function _nowIso() { return new Date().toISOString(); }
function _isEmail(s) { return typeof s === 'string' && EMAIL_RE.test(s); }
function _defaultUsers() { return { users: {}, order: [] }; }
function _defaultAllowlist() { return { emails: [] }; }
// Coerce a candidate to a writable string dataDir; return null otherwise.
// Used by the factory's resolver to ignore test proxies / function-typed
// values from universal-deps that the `||` short-circuit can't filter.
function _resolveDataDir(opts) {
const candidates = [
opts.dataDir,
opts.platformPaths && opts.platformPaths.dataDir,
platformPaths && platformPaths.dataDir,
];
for (const c of candidates) {
if (typeof c === 'string' && c.length > 0) return c;
}
return require('os').tmpdir();
}
function _atomicWriteJSON(filePath, data) {
const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now();
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(tmp, filePath);
}
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: log and return fallback so the API keeps serving.
// The next mutation will rewrite the file cleanly.
return fallback;
}
}
/**
* Factory. One user-store per process.
*
* @param {Object} [opts]
* @param {string} [opts.dataDir] — override for tests
* @param {Object} [opts.log] — structured logger
*/
function createUserStore(opts = {}) {
// Resolve dataDir defensively — universal-deps test proxies can return
// function-typed values for property access, which `||` won't filter.
const dataDir = _resolveDataDir(opts);
const log = opts.log || { info() {}, warn() {}, error() {} };
const usersFile = path.join(dataDir, 'users.json');
const allowlistFile = path.join(dataDir, 'authorized-users.json');
const bootstrapSentinel = path.join(dataDir, '.bootstrapped');
let _mutex = Promise.resolve();
function _enqueue(fn) {
const next = _mutex.then(fn, fn);
// Swallow errors on the chain so one failure doesn't poison subsequent ops.
_mutex = next.catch(() => {});
return next;
}
function _loadUsers() {
const data = _readJSON(usersFile, _defaultUsers());
if (!data.users || typeof data.users !== 'object') data.users = {};
if (!Array.isArray(data.order)) data.order = Object.keys(data.users);
return data;
}
function _loadAllowlist() {
const data = _readJSON(allowlistFile, _defaultAllowlist());
if (!Array.isArray(data.emails)) data.emails = [];
return data;
}
function _saveUsers(data) { _atomicWriteJSON(usersFile, data); }
function _saveAllowlist(data) { _atomicWriteJSON(allowlistFile, data); }
function _bootstrapDone() {
try { return fs.existsSync(bootstrapSentinel); }
catch { return false; }
}
function _writeBootstrapSentinel(adminEmail) {
_atomicWriteJSON(bootstrapSentinel, {
bootstrappedAt: _nowIso(),
adminEmail: adminEmail.toLowerCase(),
});
}
// ── Public API ─────────────────────────────────────────────────────────
/**
* Authenticate-or-create a user from an email. Implements the DC-048
* bootstrap rule and the authorized-users allowlist.
*
* Returns one of:
* { ok: true, user, role, isBootstrap }
* { ok: false, reason: 'not_authorized' }
*
* Reasons:
* 'not_authorized' — email not in allowlist AND bootstrap already happened.
*
* If bootstrap hasn't happened yet (no users file, no .bootstrapped sentinel),
* the first email that successfully passes shape validation becomes admin
* AND gets added to the allowlist atomically.
*/
function login({ email, ip, displayName, createdBy } = {}) {
return _enqueue(() => {
if (!_isEmail(email)) {
return { ok: false, reason: 'invalid_email' };
}
const normalized = email.toLowerCase().trim();
const users = _loadUsers();
const allowlist = _loadAllowlist();
// Existing user → just bump login counters.
const existing = _findUserByEmail(users, normalized);
if (existing) {
existing.lastLoginAt = _nowIso();
existing.lastLoginIp = ip || '';
existing.loginCount = (existing.loginCount || 0) + 1;
_saveUsers(users);
log.info && log.info('user', 'login existing user', {
userId: existing.id, email: normalized, role: existing.role,
});
return { ok: true, user: existing, role: existing.role, isBootstrap: false };
}
// New email. Allow if (a) bootstrap hasn't happened, or (b) allowlisted.
const bootstrapPending = !_bootstrapDone() && users.order.length === 0;
const onAllowlist = allowlist.emails.includes(normalized);
if (!bootstrapPending && !onAllowlist) {
log.info && log.info('user', 'login denied — not on allowlist', { email: normalized });
return { ok: false, reason: 'not_authorized' };
}
// Bootstrap path: first-ever user becomes admin.
const isBootstrap = bootstrapPending;
const role = isBootstrap ? ROLES.ADMIN : ROLES.OPERATOR;
const newUser = {
id: crypto.randomUUID(),
email: normalized,
displayName: displayName || normalized.split('@')[0],
role,
createdBy: createdBy || (isBootstrap ? 'bootstrap' : 'invite'),
createdAt: _nowIso(),
lastLoginAt: _nowIso(),
lastLoginIp: ip || '',
loginCount: 1,
};
users.users[newUser.id] = newUser;
users.order.unshift(newUser.id);
// If bootstrap: implicitly allowlist + write sentinel.
if (isBootstrap) {
if (!allowlist.emails.includes(normalized)) {
allowlist.emails.push(normalized);
}
_saveAllowlist(allowlist);
_writeBootstrapSentinel(normalized);
}
_saveUsers(users);
log.info && log.info('user', isBootstrap ? 'bootstrap admin created' : 'invited user created', {
userId: newUser.id, email: normalized, role,
});
return { ok: true, user: newUser, role, isBootstrap };
});
}
/**
* Add an email to the allowlist WITHOUT creating a user record. Used when
* admin pre-authorizes someone who hasn't logged in yet.
*
* Returns { ok: true, alreadyExisted: boolean }.
*/
function addToAllowlist(email) {
return _enqueue(() => {
if (!_isEmail(email)) return { ok: false, reason: 'invalid_email' };
const normalized = email.toLowerCase().trim();
const allowlist = _loadAllowlist();
if (allowlist.emails.includes(normalized)) {
return { ok: true, alreadyExisted: true };
}
allowlist.emails.push(normalized);
_saveAllowlist(allowlist);
log.info && log.info('user', 'added to allowlist', { email: normalized });
return { ok: true, alreadyExisted: false };
});
}
/**
* Remove an email from the allowlist. Does NOT delete the user record
* (so the admin can read the login history) — but future logins by that
* email will be rejected unless bootstrap re-runs (which it won't).
*/
function removeFromAllowlist(email) {
return _enqueue(() => {
if (!_isEmail(email)) return { ok: false, reason: 'invalid_email' };
const normalized = email.toLowerCase().trim();
const allowlist = _loadAllowlist();
const idx = allowlist.emails.indexOf(normalized);
if (idx === -1) return { ok: true, alreadyRemoved: true };
allowlist.emails.splice(idx, 1);
_saveAllowlist(allowlist);
log.info && log.info('user', 'removed from allowlist', { email: normalized });
return { ok: true, alreadyRemoved: false };
});
}
/**
* Update an existing user's role. Role must be in VALID_ROLES.
* Returns { ok: true } or { ok: false, reason }.
*/
function setRole(userId, role) {
return _enqueue(() => {
if (!VALID_ROLES.has(role)) return { ok: false, reason: 'invalid_role' };
const users = _loadUsers();
const u = users.users[userId];
if (!u) return { ok: false, reason: 'not_found' };
u.role = role;
_saveUsers(users);
log.info && log.info('user', 'role updated', { userId, role });
return { ok: true };
});
}
/**
* Delete a user record AND remove from allowlist. Cannot delete the last
* admin (you'd lock yourself out). Returns { ok: true } or { ok: false, reason }.
*/
function deleteUser(userId) {
return _enqueue(() => {
const users = _loadUsers();
const u = users.users[userId];
if (!u) return { ok: false, reason: 'not_found' };
// Count remaining admins.
const remainingAdmins = users.order
.map(id => users.users[id])
.filter(x => x && x.role === ROLES.ADMIN && x.id !== userId).length;
if (u.role === ROLES.ADMIN && remainingAdmins === 0) {
return { ok: false, reason: 'last_admin' };
}
delete users.users[userId];
users.order = users.order.filter(id => id !== userId);
// Also remove from allowlist so re-invite is a clean slate.
const allowlist = _loadAllowlist();
const idx = allowlist.emails.indexOf(u.email);
if (idx !== -1) {
allowlist.emails.splice(idx, 1);
_saveAllowlist(allowlist);
}
_saveUsers(users);
log.info && log.info('user', 'user deleted', { userId, email: u.email });
return { ok: true };
});
}
function listUsers() {
return _enqueue(() => {
const users = _loadUsers();
return users.order
.map(id => users.users[id])
.filter(Boolean);
});
}
function listAllowlist() {
return _enqueue(() => {
const allowlist = _loadAllowlist();
return [...allowlist.emails];
});
}
function getUser(userId) {
return _enqueue(() => {
const users = _loadUsers();
return users.users[userId] || null;
});
}
function getUserByEmail(email) {
return _enqueue(() => {
if (!_isEmail(email)) return null;
const users = _loadUsers();
return _findUserByEmail(users, email.toLowerCase().trim()) || null;
});
}
function isBootstrapComplete() {
return _enqueue(() => _bootstrapDone());
}
/**
* Helper for the auth system: given an email, return whether the user
* is allowed to attempt login (allowlist OR bootstrap-pending). Used by
* the email provider's `authorizedEmails()` dependency.
*/
function isEmailAuthorized(email) {
return _enqueue(() => {
if (!_isEmail(email)) return false;
const normalized = email.toLowerCase().trim();
const allowlist = _loadAllowlist();
if (allowlist.emails.includes(normalized)) return true;
const users = _loadUsers();
// Bootstrap path: if no users yet, the first login is implicitly allowed.
return users.order.length === 0 && !_bootstrapDone();
});
}
function _findUserByEmail(users, normalizedEmail) {
for (const id of users.order) {
const u = users.users[id];
if (u && u.email === normalizedEmail) return u;
}
return null;
}
return {
login,
addToAllowlist,
removeFromAllowlist,
setRole,
deleteUser,
listUsers,
listAllowlist,
getUser,
getUserByEmail,
isBootstrapComplete,
isEmailAuthorized,
// Constants for callers
ROLES,
VALID_ROLES,
};
}
module.exports = { createUserStore, ROLES, VALID_ROLES };