Audit log
An append-only record of every sensitive operation that touches the API. Designed so that "who did what, when, from where" is recoverable months later without trawling application logs.
What's logged
| Action | When | resourceType | notes |
|---|---|---|---|
login.success |
/api/auth/login valid creds |
user |
userId set to the user |
login.failure |
/api/auth/login rejected |
(none) | userId = null even if the email exists — keeps stuffing attempts visible without leaking which emails are real |
register |
/api/auth/register |
user |
userId of the freshly-created account |
token.create |
/api/tokens |
token |
metadata { name, pinnedNodeId } |
token.revoke |
DELETE /api/tokens/:id |
token |
resourceId = revoked token id |
job.create |
/api/jobs |
job |
metadata { nodeId, preset } |
job.cancel |
/api/jobs/:id/cancel |
job |
QUEUED jobs cancel locally; RUNNING jobs also log when WS cancel succeeds |
backup.policy.create |
/api/backups |
backupPolicy |
metadata { nodeId, paths } (count, not the paths themselves) |
backup.policy.update |
PATCH /api/backups/:id |
backupPolicy |
metadata { changedKeys } |
backup.policy.delete |
DELETE /api/backups/:id |
backupPolicy |
policy and runs are deleted by cascade; the audit row of the delete remains |
backup.trigger |
POST /api/backups/:id/trigger |
backupPolicy |
metadata { jobId } to correlate with the resulting job |
user.create |
POST /api/users |
user |
admin-provisioned teammate; metadata { email, role } |
user.role.update |
PATCH /api/users/:id/role |
user |
metadata { from, to }; bumps tokenVersion |
user.delete |
DELETE /api/users/:id |
user |
metadata keeps deleted user's { email, role } |
password.change.success |
/api/auth/change-password valid current password |
user |
userId/resourceId set to the user |
password.change.failure |
/api/auth/change-password wrong current password |
user |
userId/resourceId set to the user |
password.reset.request |
/api/auth/forgot-password |
(none) | always logged; userId nullable; metadata { email } |
password.reset.success |
/api/auth/reset-password valid token |
user |
bumps tokenVersion and burns sibling reset tokens |
password.reset.failure |
/api/auth/reset-password invalid/expired/used token |
(none) | userId set only when the token hash exists |
node.config.update |
PATCH /api/nodes/:id/config |
node |
metadata { changedKeys } |
The set is curated. Read endpoints (GET /api/nodes, GET /api/jobs, …)
are NOT logged — the volume would dwarf the actually interesting events and
make queries painful.
Schema
model AuditEvent {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
action String // see table above
resourceType String?
resourceId String?
ip String? // X-Forwarded-For leftmost when trustProxy is on
metadata String @default("{}") // small JSON, never secrets
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([action, createdAt])
@@index([resourceType, resourceId])
}
userId is nullable on purpose — a failed login has no validated user
behind it, but we still want the IP and timestamp.
metadata is a small JSON-encoded string. Callers curate the content; we
deliberately never dump full request bodies (would leak passwords and
secrets straight into the audit table — the opposite of what we want).
API surface
The audit table is exposed through admin-only GET /api/audit with filters
for user, action, resourceType, resourceId, pagination, and CSV export
(?format=csv, capped at 10k rows). Distinct action values are available via
admin-only GET /api/audit/actions. The dashboard page is
/dashboard/admin/audit.
Direct SQL remains useful for emergency/debug access:
-- Recent admin events
SELECT created_at, action, user_id, resource_id, ip, metadata
FROM "AuditEvent"
ORDER BY created_at DESC
LIMIT 200;
-- Brute-force from a single IP
SELECT ip, count(*)
FROM "AuditEvent"
WHERE action = 'login.failure' AND created_at > now() - interval '1 hour'
GROUP BY ip
ORDER BY count(*) DESC;
-- What did user X do in the last 24h?
SELECT created_at, action, resource_type, resource_id
FROM "AuditEvent"
WHERE user_id = 'cmpmwz39a0000ailgikcxk9q7'
AND created_at > now() - interval '1 day'
ORDER BY created_at;
-- What touched this job?
SELECT created_at, action, user_id, ip
FROM "AuditEvent"
WHERE resource_type = 'job' AND resource_id = 'cmp...';
Write semantics
recordAudit(req, input) returns the Prisma promise but production callers
ignore it. The audit write must NOT block the user-facing response —
if the DB is slow on this single insert, we'd rather drop the audit row
than degrade the user's experience.
The function attaches its own .catch so an unawaited rejection becomes
a warn log instead of an UnhandledPromiseRejection.
Tests that need to assert the row landed should await the returned
promise OR use the waitFor() helper in apps/api/test/backups.spec.ts
(40ms poll loop with a 2s deadline) — the fire-and-forget pattern races
with naive findFirst() checks.
Retention
No automatic pruning yet. The table grows unbounded; back-of-envelope a
busy single-operator workspace produces ~50–200 rows/day, so a year is
under 100k rows. Once we cross a few million, add a daily DELETE WHERE created_at < now() - interval '180 days' retention job.
What's NOT in the audit log (yet, on purpose)
- Reads. No
GETis recorded. Adds 90%+ of the volume for marginal forensic value. - Heartbeats. Every node sends one every 5 min; the noise would bury everything.
- Agent /output and /complete on jobs. The Job row itself already
carries
stdout/stderr/ final status; that IS the audit for what the agent did. - Backup runs. Same reasoning —
BackupRunrows are the audit. - Schema migrations. Tracked by Prisma's
_prisma_migrations.
If you need any of these later (compliance, customer demand), the helper
is one line away: recordAudit(req, { action: 'nodes.list', ... }) in
the right handler.
Failure modes
- Audit write fails (DB down, FK constraint, etc.) → warn log, request succeeds anyway. The user is unaffected; the row is lost forever.
userIdreferences a since-deleted user → the FK hasonDelete: SetNull, so old audit rows keep their resource info but the userId becomes null. The metadata{ email }field onlogin.failurerows still tells you who tried.metadatais malformed JSON → it's stored as a string; if a consumer can't parse it, they get the raw string. The writer is the only producer, so this shouldn't happen unless someone bypasses the helper.
Adding a new action
- Add the literal to the
AuditActionunion inapps/api/src/lib/audit.ts. - Call
recordAudit(req, { action: '...', userId, resourceType, resourceId, metadata })from the route handler after the mutation has succeeded — we don't want to log writes that never happened. - Document the row in the table at the top of this file.