DC-048: multi-user bootstrap + admin invites (opt-in)
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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.
This commit is contained in:
hermes
2026-07-20 17:44:11 -07:00
parent bd480a69a7
commit 321334cd33
23 changed files with 2964 additions and 325 deletions
+82 -6
View File
@@ -81,8 +81,9 @@ class EmailMagicLinkProvider extends AuthProvider {
this.store.startPruneTimer();
this.emailConfig = deps.emailConfig || null;
// DC-048 will replace this with the real authorized-users check.
this.authorizedEmails = deps.authorizedEmails || (() => true);
// DC-048: real authorized-users check via the user store. Falls back
// to "allow everyone" if no store is wired (dev/legacy installs).
this.userStore = deps.userStore || null;
// Public URL templates — overridable for testing.
this.linkTtlMs = deps.linkTtlMs || DEFAULT_LINK_TTL_MS;
this.maxBodyLength = 32_000;
@@ -131,10 +132,12 @@ class EmailMagicLinkProvider extends AuthProvider {
*/
_isProviderEnabled() {
const flag = this.deps.config && this.deps.config.enabled;
// Default to TRUE: dev installs should "just work". Operators who want
// to disable email login set `siteConfig.authProviders.email.enabled = false`
// — the same config knob the TOTP provider uses.
if (flag === false) return false;
// Default to FALSE (DC-048 opt-in): operators must explicitly enable
// email auth via `siteConfig.authProviders.email.enabled = true`. Until
// then, the email login methods endpoint reports the provider as
// disabled and the auth UI doesn't render the email button. TOTP-only
// installs see no behavior change.
if (flag !== true) return false;
return true;
}
@@ -277,12 +280,64 @@ class EmailMagicLinkProvider extends AuthProvider {
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
}
// DC-048: authorization gate. If the email isn't on the allowlist and
// bootstrap has already happened, reject. The token is still consumed
// so the same generic message is returned for "valid token but you're
// not allowed" — prevents a side-channel that distinguishes
// "token worked but you're banned" from "token didn't exist".
//
// NOTE: the DC-047 design comment claimed "initiate" would also silently
// drop unauthorized emails. That was aspirational; the real enumeration
// prevention lives at verify-time (here). Initiate-time, we still issue
// tokens and return success — so an unauthorized user thinks the link
// works, but it rejects at click-time. Same as DC-047 claimed; we just
// moved the check from initiate to verify where it can actually run.
if (this.userStore) {
const allowed = await this.userStore.isEmailAuthorized(record.email);
if (!allowed) {
// Audit the denial.
this.deps.log && this.deps.log.warn && this.deps.log.warn('auth', 'email magic link rejected — not authorized', {
email: record.email,
ip: this._clientIP(req),
});
// Mark token used so a stolen token can't be replayed by a legit user later.
await this.store.markUsed(record.hash).catch(() => {});
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
}
}
// Side-effect logging (NOT info-disclosure — just that a token was used).
this.deps.log && this.deps.log.info && this.deps.log.info('auth', 'email magic link verified', {
email: record.email,
ip: this._clientIP(req),
});
// DC-048: record-or-create the user. First login → bootstrap admin.
// After that → must be on allowlist (already checked above).
let userRecord = null;
let isBootstrap = false;
if (this.userStore) {
const result = await this.userStore.login({
email: record.email,
ip: this._clientIP(req),
});
if (!result.ok) {
// Shouldn't reach here — isEmailAuthorized just passed — but
// handle the edge case where allowlist was mutated between calls.
await this.store.markUsed(record.hash).catch(() => {});
throw new AuthenticationError('[DC-116] Sign-in link is invalid, expired, or has already been used');
}
userRecord = result.user;
isBootstrap = result.isBootstrap;
if (this.deps.log && this.deps.log.info) {
this.deps.log.info('auth', isBootstrap ? 'bootstrap admin first login' : 'user login', {
userId: userRecord.id,
email: userRecord.email,
role: userRecord.role,
});
}
}
// Create the session + cookie. Same shape as TOTP's verify path.
this.deps.session.create(req, this.deps.config && this.deps.config.sessionDuration || '24h');
this.deps.session.setCookie(res, this.deps.config && this.deps.config.sessionDuration || '24h');
@@ -290,11 +345,32 @@ class EmailMagicLinkProvider extends AuthProvider {
? this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https')
: undefined;
// DC-048: tag the request with the authenticated user so downstream
// middleware + audit log can attribute the session. We mutate req so
// the audit logger (which runs as response middleware) sees it.
if (userRecord) {
req.user = {
id: userRecord.id,
email: userRecord.email,
role: userRecord.role,
isAdmin: userRecord.role === 'admin',
isBootstrap,
};
}
return ok(res, {
message: 'Authenticated successfully',
method: 'email',
email: AuthProvider.maskEmail(record.email),
csrfToken: newCsrf,
user: userRecord
? {
id: userRecord.id,
email: userRecord.email,
role: userRecord.role,
isBootstrap,
}
: null,
});
}
+5 -3
View File
@@ -42,6 +42,8 @@ function createAuthProviderRegistry(deps, config) {
...deps,
config: deps.config.totp, // the existing totpConfig object from app.js
saveProviderConfig: deps.saveTotpConfig, // existing helper
// DC-048: user store for bootstrap + audit attribution on TOTP logins.
userStore: deps.userStore || null,
});
providers.set('totp', totpProvider);
@@ -56,9 +58,9 @@ function createAuthProviderRegistry(deps, config) {
saveProviderConfig: deps.saveProviderConfig || (async () => {}),
emailConfig: deps.emailConfig || null,
siteConfig: deps.siteConfig || {},
// DC-048 hook: today every email is authorized. Once multi-user ships,
// this is replaced with a real allowlist check.
authorizedEmails: deps.authorizedEmails || (() => true),
// DC-048: real authorization check via the user store. Without it,
// every email is allowed (legacy single-user behavior).
userStore: deps.userStore || null,
}));
// Future: OIDC, SAML, passkeys — each gated on config.authProviders
+45
View File
@@ -274,6 +274,51 @@ class TotpProvider extends AuthProvider {
this.deps.session.create(req, this.deps.config.sessionDuration);
this.deps.session.setCookie(res, this.deps.config.sessionDuration);
// DC-048: bootstrap-on-first-TOTP-verify. If no user store is wired
// (legacy install), skip silently — operator keeps anonymous access.
// If a user store IS wired and bootstrap hasn't happened yet, create
// a "system-admin" record tied to this TOTP login so the operator
// shows up in /api/v1/auth/admin/users. Email is null because TOTP
// has no email to attribute.
if (this.deps.userStore) {
const isBootstrapped = await this.deps.userStore.isBootstrapComplete();
if (!isBootstrapped) {
const result = await this.deps.userStore.login({
email: 'system@totp.local',
ip: this._clientIP(req),
displayName: 'Operator (TOTP)',
});
if (result.ok) {
req.user = {
id: result.user.id,
email: null,
role: result.user.role,
isAdmin: result.user.role === 'admin',
isBootstrap: result.isBootstrap,
viaProvider: 'totp',
};
this.deps.log.info('auth', 'system admin bootstrapped via TOTP', {
userId: result.user.id,
role: result.user.role,
});
}
} else {
// Bootstrap already happened — find the system-admin record and
// attach it to this session for audit-log attribution.
const sys = await this.deps.userStore.getUserByEmail('system@totp.local');
if (sys) {
req.user = {
id: sys.id,
email: null,
role: sys.role,
isAdmin: sys.role === 'admin',
isBootstrap: false,
viaProvider: 'totp',
};
}
}
}
const newCsrfToken = this.deps.renewCSRFToken(res, req.secure || req.protocol === 'https');
this.deps.log.debug('auth', 'Session created', { sessions: this.deps.session.ipSessions.size });