Files
dashcaddy/dashcaddy-api/src/security/audit-logger.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

256 lines
10 KiB
JavaScript

const path = require('path');
const StateManager = require('../managers/state-manager');
const crypto = require('crypto');
const platformPaths = require('../../platform-paths');
const AUDIT_LOG_FILE = process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json');
const MAX_ENTRIES = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
// Route path → readable action mapping
const ACTION_MAP = {
'POST /api/v1/services/update': 'service.reorder',
'POST /api/v1/services': 'service.create',
'PUT /api/v1/services': 'service.update',
'DELETE /api/v1/services/': 'service.delete',
'POST /api/v1/site': 'caddy.add-site',
'POST /api/v1/site/external': 'caddy.add-external',
'DELETE /api/v1/site/': 'caddy.remove-site',
'POST /api/v1/caddy/reload': 'caddy.reload',
'POST /api/v1/dns/record': 'dns.add-record',
'DELETE /api/v1/dns/record': 'dns.delete-record',
'POST /api/v1/dns/credentials': 'dns.save-credentials',
'DELETE /api/v1/dns/credentials': 'dns.delete-credentials',
'POST /api/v1/dns/refresh-token': 'dns.refresh-token',
'POST /api/v1/dns/update': 'dns.update-server',
'POST /api/v1/containers/': 'container.action',
'DELETE /api/v1/containers/': 'container.delete',
'POST /api/v1/apps/deploy': 'container.deploy',
'DELETE /api/v1/apps/': 'container.undeploy',
'POST /api/v1/backups/execute': 'backup.execute',
'POST /api/v1/backups/restore/': 'backup.restore',
'POST /api/v1/backups/config': 'backup.config',
'POST /api/v1/config': 'config.update',
'DELETE /api/v1/config': 'config.reset',
'POST /api/v1/notifications/config': 'config.notifications',
'POST /api/v1/totp/setup': 'auth.totp-setup',
'POST /api/v1/totp/verify-setup': 'auth.totp-activate',
'POST /api/v1/totp/disable': 'auth.totp-disable',
'POST /api/v1/totp/config': 'auth.totp-config',
'POST /api/v1/credentials/rotate-key': 'config.rotate-key',
'POST /api/v1/updates/update/': 'container.update',
'POST /api/v1/updates/rollback/': 'container.rollback',
'POST /api/v1/updates/auto-update/': 'container.auto-update',
'POST /api/v1/updates/check': 'container.check-updates',
'POST /api/v1/health-checks/': 'config.health-check',
'DELETE /api/v1/health-checks/': 'config.health-check-delete',
'POST /api/v1/monitoring/alerts/': 'config.monitoring-alert',
'DELETE /api/v1/monitoring/alerts/': 'config.monitoring-alert-delete',
'POST /api/v1/arr/smart-connect': 'service.arr-connect',
'POST /api/v1/arr/credentials': 'config.arr-credentials',
'DELETE /api/v1/arr/credentials/': 'config.arr-credentials-delete',
'POST /api/v1/logo': 'config.logo-upload',
'DELETE /api/v1/logo': 'config.logo-delete',
'POST /api/v1/favicon': 'config.favicon-upload',
'DELETE /api/v1/favicon': 'config.favicon-delete',
'POST /api/v1/tailscale/config': 'config.tailscale',
'POST /api/v1/tailscale/protect-service': 'config.tailscale-protect',
// SECURITY [DC-028]: Credential-exposure events get named actions so the
// audit log can answer "who hit /auth/gate/plex at 03:00 with what outcome?".
'GET /api/v1/auth/gate': 'auth.credential-injection',
'GET /api/v1/auth/app-token': 'auth.app-token-issue',
'POST /api/v1/auth/keys': 'auth.api-key-generate',
'DELETE /api/v1/auth/keys': 'auth.api-key-revoke',
'POST /api/v1/auth/jwt': 'auth.jwt-mint',
};
// Paths to skip logging (noisy or internal)
const SKIP_PATHS = [
'/api/v1/totp/verify',
'/api/v1/totp/check-session',
// SECURITY [DC-028]: /auth/gate and /auth/app-token are NOT skipped —
// they expose credentials (Basic Auth, X-Api-Key, upstream service tokens)
// so we MUST log every hit. Previously these were in SKIP_PATHS which
// silently dropped credential-exposure events from the audit log.
'/api/v1/audit-logs',
'/api/v1/health',
'/health',
'/api/v1/notifications/test',
'/api/v1/notifications/health-check',
];
class AuditLogger {
constructor() {
this.stateManager = new StateManager(AUDIT_LOG_FILE);
}
resolveAction(method, urlPath) {
const key = `${method} ${urlPath}`;
// Exact match first
if (ACTION_MAP[key]) return ACTION_MAP[key];
// Prefix match (for parameterized routes like /api/services/:id)
for (const [pattern, action] of Object.entries(ACTION_MAP)) {
if (key.startsWith(pattern)) return action;
}
// Fallback: derive from path
const parts = urlPath.replace('/api/v1/', '').split('/');
const category = parts[0] || 'unknown';
return `${category}.${method.toLowerCase()}`;
}
extractResource(urlPath) {
// Pull a meaningful resource identifier from the URL path
const parts = urlPath.replace('/api/v1/', '').split('/');
if (parts.length >= 2) return parts.slice(1).join('/');
return parts[0] || '';
}
shouldSkip(method, urlPath) {
// SECURITY [DC-028]: Auth endpoints that expose credentials are
// logged even though they're GETs. /auth/gate and /auth/app-token
// return Basic Auth headers and upstream service tokens — these
// events MUST be auditable. Other GETs remain skipped (probes,
// dashboards, status checks flood the log).
if (urlPath.startsWith('/api/v1/auth/gate') || urlPath.startsWith('/api/v1/auth/app-token')) {
return false; // log it
}
if (method === 'GET') return true;
for (const skip of SKIP_PATHS) {
if (urlPath.startsWith(skip)) return true;
}
return false;
}
/**
* Map a (action, outcome) pair to a severity level for the security event store.
* Most actions are 'info', but security-sensitive ones get escalated.
*/
resolveSeverity(action, outcome) {
// Failed auth + sensitive actions are warnings at minimum
if (outcome === 'failure' || outcome === 'denied' || outcome === 'error') {
if (action?.startsWith('auth.')) return 'warn';
if (action?.includes('credential')) return 'warn';
if (action?.includes('delete') || action?.includes('disable')) return 'warn';
return 'notice';
}
// Successful sensitive actions (key generation, TOTP setup, config changes)
if (action?.startsWith('auth.totp-') || action?.includes('rotate-key')) return 'notice';
if (action?.includes('delete') || action?.includes('disable')) return 'notice';
if (action?.startsWith('config.')) return 'notice';
return 'info';
}
async log({ action, resource, details, outcome, ip }) {
try {
const entry = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
ip: ip || '',
action: action || '',
resource: resource || '',
details: details || {},
outcome: outcome || 'unknown'
};
await this.stateManager.update(entries => {
entries.unshift(entry);
if (entries.length > MAX_ENTRIES) {
entries.length = MAX_ENTRIES;
}
return entries;
});
// ALSO emit to the unified security event store so security events from
// the API show up alongside Caddy access logs, fail2ban events, and any
// future remote-agent events in one timeline. This is best-effort —
// failure here MUST NOT block the audit log write.
try {
const { getStore } = require('./event-store');
const store = getStore();
const severity = this.resolveSeverity(action, outcome);
const hostname = require('os').hostname();
store.append({
source_host: hostname,
source_type: 'api',
actor: ip || null,
target: resource || null,
action: action || 'unknown',
outcome: outcome || 'unknown',
severity,
message: `${action} ${outcome} on ${resource}`.trim(),
metadata: {
method: details?.body && Object.keys(details.body)[0] ? '(see audit-log)' : undefined,
audit_id: entry.id,
},
});
} catch (e) {
// Non-fatal — security store is a best-effort mirror
console.error('[AuditLogger] Security event emit failed:', e.message);
}
} catch (e) {
console.error('[AuditLogger] Failed to write entry:', e.message);
}
}
async query({ limit = 50, offset = 0, action } = {}) {
try {
let entries = await this.stateManager.read();
if (action) {
entries = entries.filter(e => e.action && e.action.startsWith(action));
}
return entries.slice(offset, offset + limit);
} catch (e) {
console.error('[AuditLogger] Failed to read:', e.message);
return [];
}
}
async clear() {
await this.stateManager.write([]);
}
middleware() {
return (req, res, next) => {
if (this.shouldSkip(req.method, req.path)) return next();
const originalJson = res.json.bind(res);
res.json = (data) => {
// Log asynchronously — don't block the response
const ip = req.ip || req.socket?.remoteAddress || '';
const action = this.resolveAction(req.method, req.path);
const resource = this.extractResource(req.path);
const outcome = data && data.success === false ? 'failure' : 'success';
// Sanitize details — don't log passwords or tokens
const details = {};
if (req.params && Object.keys(req.params).length) details.params = req.params;
if (req.body) {
const safe = { ...req.body };
for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) {
if (safe[key]) safe[key] = '***';
}
details.body = safe;
}
// DC-048: attribute the audit entry to the authenticated user when
// a session belongs to a known user record. Tag with id + role +
// email (or null for the TOTP-attributed "system" operator). When
// req.user is absent (legacy session, no auth), omit the fields
// entirely so existing log readers don't break.
if (req.user && req.user.id) {
details.userId = req.user.id;
details.userRole = req.user.role || null;
if (req.user.email) details.userEmail = req.user.email;
if (req.user.viaProvider) details.viaProvider = req.user.viaProvider;
}
this.log({ action, resource, details, outcome, ip }).catch(() => {});
return originalJson(data);
};
next();
};
}
}
module.exports = new AuditLogger();