File size: 3,258 Bytes
3bbe317 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | /**
* MedOS audit log.
*
* Append-only record of security-relevant actions. Used for forensic
* review ("who changed the SMTP password yesterday?"), incident response,
* and compliance evidence. NOT for billing or analytics — keep the schema
* narrow and the cardinality bounded.
*
* Design rules:
* - Failures NEVER throw. A broken audit log must not break the request.
* - No PHI / PII in `meta`. Use ids, counts, durations, status codes only.
* - Add new actions to AuditAction explicitly; do not accept arbitrary
* strings (catches typos and prevents log explosion).
*/
import { getDb, genId } from './db';
export type AuditAction =
// auth lifecycle
| 'login'
| 'login_failed'
| 'logout'
| 'register'
| 'verify_email'
| 'password_reset_request'
| 'password_reset'
| 'password_change'
| 'delete_account'
// admin
| 'admin_login'
| 'admin_action'
| 'admin_user_delete'
| 'admin_user_reset_password'
| 'admin_config_update'
| 'token_rotate'
// user data
| 'chat'
| 'scan'
| 'health_data_write'
| 'health_data_delete'
| 'settings_update'
| 'export_data';
export interface AuditEntry {
userId?: string | null;
action: AuditAction;
ip?: string | null;
meta?: Record<string, any>;
}
/**
* Write an audit entry. Synchronous SQLite write — cheap on local FS.
* Wrapped in try/catch so a logging failure can never propagate.
*/
export function auditLog(entry: AuditEntry): void {
try {
const db = getDb();
db.prepare(
`INSERT INTO audit_log (id, user_id, action, ip, meta)
VALUES (?, ?, ?, ?, ?)`,
).run(
genId(),
entry.userId || null,
entry.action,
entry.ip || null,
JSON.stringify(entry.meta || {}),
);
} catch (e: any) {
console.error('[Audit] write failed:', e?.message);
}
}
/**
* Page through the audit log for the admin UI. Filter by user, action,
* and time range; default page size 50, hard cap 500.
*/
export function queryAudit(opts: {
userId?: string;
action?: AuditAction;
since?: string; // ISO
limit?: number;
offset?: number;
}): Array<{
id: string;
userId: string | null;
action: string;
ip: string | null;
meta: any;
createdAt: string;
}> {
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
const offset = Math.max(opts.offset ?? 0, 0);
const where: string[] = [];
const params: any[] = [];
if (opts.userId) {
where.push('user_id = ?');
params.push(opts.userId);
}
if (opts.action) {
where.push('action = ?');
params.push(opts.action);
}
if (opts.since) {
where.push('created_at >= ?');
params.push(opts.since);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const db = getDb();
const rows = db
.prepare(
`SELECT id, user_id, action, ip, meta, created_at
FROM audit_log
${whereSql}
ORDER BY created_at DESC
LIMIT ? OFFSET ?`,
)
.all(...params, limit, offset) as any[];
return rows.map((r) => ({
id: r.id,
userId: r.user_id,
action: r.action,
ip: r.ip,
meta: (() => {
try {
return JSON.parse(r.meta);
} catch {
return {};
}
})(),
createdAt: r.created_at,
}));
}
|