feat(audit): admin audit log

Audit log
- Add audit_log table (migration + schema) with index on created_at.
- Add auditLog service (writeAudit, getClientIp) and record events for backups
  (create, restore, upload-restore, delete, auto-settings), admin actions
  (users, OIDC, invites, system update, demo baseline, bag tracking, packing
  template delete, addons), and auth (app settings, MFA enable/disable).
- Add GET /api/admin/audit-log with pagination; fix invite insert row id lookup.
- Add AuditLogPanel and Admin tab; adminApi.auditLog.
- Add admin.tabs.audit and admin.audit.* strings in all locale files.
Note: Rebase feature branches so new DB migrations stay after existing ones
  (e.g. file_links) when merging upstream.
This commit is contained in:
fgbona
2026-03-29 19:39:05 -03:00
parent 6444b2b4ce
commit d04629605e
18 changed files with 548 additions and 18 deletions

View File

@@ -321,6 +321,20 @@ function runMigrations(db: Database.Database): void {
UNIQUE(file_id, place_id)
)`);
},
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
`);
},
];
if (currentVersion < migrations.length) {

View File

@@ -380,6 +380,17 @@ function createTables(db: Database.Database): void {
UNIQUE(assignment_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
`);
}

View File

@@ -7,6 +7,7 @@ import fs from 'fs';
import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
const router = express.Router();
@@ -52,6 +53,14 @@ router.post('/users', (req: Request, res: Response) => {
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(result.lastInsertRowid);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.user_create',
resource: String(result.lastInsertRowid),
ip: getClientIp(req),
details: { username: username.trim(), email: email.trim(), role: role || 'user' },
});
res.status(201).json({ user });
});
@@ -90,6 +99,19 @@ router.put('/users/:id', (req: Request, res: Response) => {
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(req.params.id);
const authReq = req as AuthRequest;
const changed: string[] = [];
if (username) changed.push('username');
if (email) changed.push('email');
if (role) changed.push('role');
if (password) changed.push('password');
writeAudit({
userId: authReq.user.id,
action: 'admin.user_update',
resource: String(req.params.id),
ip: getClientIp(req),
details: { fields: changed },
});
res.json({ user: updated });
});
@@ -103,6 +125,12 @@ router.delete('/users/:id', (req: Request, res: Response) => {
if (!user) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
writeAudit({
userId: authReq.user.id,
action: 'admin.user_delete',
resource: String(req.params.id),
ip: getClientIp(req),
});
res.json({ success: true });
});
@@ -115,6 +143,48 @@ router.get('/stats', (_req: Request, res: Response) => {
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
});
router.get('/audit-log', (req: Request, res: Response) => {
const limitRaw = parseInt(String(req.query.limit || '100'), 10);
const offsetRaw = parseInt(String(req.query.offset || '0'), 10);
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
type Row = {
id: number;
created_at: string;
user_id: number | null;
username: string | null;
user_email: string | null;
action: string;
resource: string | null;
details: string | null;
ip: string | null;
};
const rows = db.prepare(`
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.id DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as Row[];
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
res.json({
entries: rows.map((r) => {
let details: Record<string, unknown> | null = null;
if (r.details) {
try {
details = JSON.parse(r.details) as Record<string, unknown>;
} catch {
details = { _parse_error: true };
}
}
return { ...r, details };
}),
total,
limit,
offset,
});
});
router.get('/oidc', (_req: Request, res: Response) => {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
const secret = get('oidc_client_secret');
@@ -135,16 +205,25 @@ router.put('/oidc', (req: Request, res: Response) => {
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
set('oidc_display_name', display_name);
set('oidc_only', oidc_only ? 'true' : 'false');
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.oidc_update',
ip: getClientIp(req),
details: { oidc_only: !!oidc_only, issuer_set: !!issuer },
});
res.json({ success: true });
});
router.post('/save-demo-baseline', (_req: Request, res: Response) => {
router.post('/save-demo-baseline', (req: Request, res: Response) => {
if (process.env.DEMO_MODE !== 'true') {
return res.status(404).json({ error: 'Not found' });
}
try {
const { saveBaseline } = require('../demo/demo-reset');
saveBaseline();
const authReq = req as AuthRequest;
writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' });
} catch (err: unknown) {
console.error(err);
@@ -201,7 +280,7 @@ router.get('/version-check', async (_req: Request, res: Response) => {
}
});
router.post('/update', async (_req: Request, res: Response) => {
router.post('/update', async (req: Request, res: Response) => {
const rootDir = path.resolve(__dirname, '../../..');
const serverDir = path.resolve(__dirname, '../..');
const clientDir = path.join(rootDir, 'client');
@@ -224,6 +303,13 @@ router.post('/update', async (_req: Request, res: Response) => {
const { version: newVersion } = require('../../package.json');
steps.push({ step: 'version', version: newVersion });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.system_update',
resource: newVersion,
ip: getClientIp(req),
});
res.json({ success: true, steps, restarting: true });
setTimeout(() => {
@@ -260,24 +346,39 @@ router.post('/invites', (req: Request, res: Response) => {
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
: null;
db.prepare(
const ins = db.prepare(
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
).run(token, uses, expiresAt, authReq.user.id);
const inviteId = Number(ins.lastInsertRowid);
const invite = db.prepare(`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
WHERE i.id = last_insert_rowid()
`).get();
WHERE i.id = ?
`).get(inviteId);
writeAudit({
userId: authReq.user.id,
action: 'admin.invite_create',
resource: String(inviteId),
ip: getClientIp(req),
details: { max_uses: uses, expires_in_days: expires_in_days ?? null },
});
res.status(201).json({ invite });
});
router.delete('/invites/:id', (_req: Request, res: Response) => {
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(_req.params.id);
router.delete('/invites/:id', (req: Request, res: Response) => {
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id);
if (!invite) return res.status(404).json({ error: 'Invite not found' });
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(_req.params.id);
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.invite_delete',
resource: String(req.params.id),
ip: getClientIp(req),
});
res.json({ success: true });
});
@@ -291,6 +392,13 @@ router.get('/bag-tracking', (_req: Request, res: Response) => {
router.put('/bag-tracking', (req: Request, res: Response) => {
const { enabled } = req.body;
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.bag_tracking',
ip: getClientIp(req),
details: { enabled: !!enabled },
});
res.json({ enabled: !!enabled });
});
@@ -337,10 +445,19 @@ router.put('/packing-templates/:id', (req: Request, res: Response) => {
res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) });
});
router.delete('/packing-templates/:id', (_req: Request, res: Response) => {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id);
router.delete('/packing-templates/:id', (req: Request, res: Response) => {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
if (!template) return res.status(404).json({ error: 'Template not found' });
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(_req.params.id);
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id);
const authReq = req as AuthRequest;
const t = template as { name?: string };
writeAudit({
userId: authReq.user.id,
action: 'admin.packing_template_delete',
resource: String(req.params.id),
ip: getClientIp(req),
details: { name: t.name },
});
res.json({ success: true });
});
@@ -408,6 +525,14 @@ router.put('/addons/:id', (req: Request, res: Response) => {
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon;
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.addon_update',
resource: String(req.params.id),
ip: getClientIp(req),
details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined },
});
res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
});

View File

@@ -13,6 +13,7 @@ import { authenticate, demoUploadBlock } from '../middleware/auth';
import { JWT_SECRET } from '../config';
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
import { AuthRequest, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
authenticator.options = { window: 1 };
@@ -518,6 +519,15 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
if (allowed_file_types !== undefined) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
}
writeAudit({
userId: authReq.user.id,
action: 'settings.app_update',
ip: getClientIp(req),
details: {
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
allowed_file_types_changed: allowed_file_types !== undefined,
},
});
res.json({ success: true });
});
@@ -673,6 +683,7 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => {
authReq.user.id
);
mfaSetupPending.delete(authReq.user.id);
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: true });
});
@@ -702,6 +713,7 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
authReq.user.id
);
mfaSetupPending.delete(authReq.user.id);
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: false });
});

View File

@@ -7,6 +7,10 @@ import fs from 'fs';
import { authenticate, adminOnly } from '../middleware/auth';
import * as scheduler from '../scheduler';
import { db, closeDb, reinitialize } from '../db/database';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string };
const router = express.Router();
@@ -103,6 +107,14 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re
});
const stat = fs.statSync(outputPath);
const authReq = _req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.create',
resource: filename,
ip: getClientIp(_req),
details: { size: stat.size },
});
res.json({
success: true,
backup: {
@@ -134,7 +146,7 @@ router.get('/download/:filename', (req: Request, res: Response) => {
res.download(filePath, filename);
});
async function restoreFromZip(zipPath: string, res: Response) {
async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
try {
await fs.createReadStream(zipPath)
@@ -174,6 +186,14 @@ async function restoreFromZip(zipPath: string, res: Response) {
fs.rmSync(extractDir, { recursive: true, force: true });
if (audit) {
writeAudit({
userId: audit.userId,
action: audit.source,
resource: audit.label,
ip: audit.ip,
});
}
res.json({ success: true });
} catch (err: unknown) {
console.error('Restore error:', err);
@@ -191,7 +211,13 @@ router.post('/restore/:filename', async (req: Request, res: Response) => {
if (!fs.existsSync(zipPath)) {
return res.status(404).json({ error: 'Backup not found' });
}
await restoreFromZip(zipPath, res);
const authReq = req as AuthRequest;
await restoreFromZip(zipPath, res, {
userId: authReq.user.id,
ip: getClientIp(req),
source: 'backup.restore',
label: filename,
});
});
const uploadTmp = multer({
@@ -206,7 +232,14 @@ const uploadTmp = multer({
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const zipPath = req.file.path;
await restoreFromZip(zipPath, res);
const authReq = req as AuthRequest;
const origName = req.file.originalname || 'upload.zip';
await restoreFromZip(zipPath, res, {
userId: authReq.user.id,
ip: getClientIp(req),
source: 'backup.upload_restore',
label: origName,
});
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
});
@@ -248,6 +281,13 @@ router.put('/auto-settings', (req: Request, res: Response) => {
const settings = parseAutoBackupBody((req.body || {}) as Record<string, unknown>);
scheduler.saveSettings(settings);
scheduler.start();
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.auto_settings',
ip: getClientIp(req),
details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days },
});
res.json({ settings });
} catch (err: unknown) {
console.error('[backup] PUT auto-settings:', err);
@@ -272,6 +312,13 @@ router.delete('/:filename', (req: Request, res: Response) => {
}
fs.unlinkSync(filePath);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.delete',
resource: filename,
ip: getClientIp(req),
});
res.json({ success: true });
});

View File

@@ -0,0 +1,30 @@
import { Request } from 'express';
import { db } from '../db/database';
export function getClientIp(req: Request): string | null {
const xff = req.headers['x-forwarded-for'];
if (typeof xff === 'string') {
const first = xff.split(',')[0]?.trim();
return first || null;
}
if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null;
return req.socket?.remoteAddress || null;
}
/** Best-effort; never throws — failures are logged only. */
export function writeAudit(entry: {
userId: number | null;
action: string;
resource?: string | null;
details?: Record<string, unknown>;
ip?: string | null;
}): void {
try {
const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
db.prepare(
`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`
).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null);
} catch (e) {
console.error('[audit] write failed:', e instanceof Error ? e.message : e);
}
}