HyprBox docs GitHub ↗

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 GET is 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 — BackupRun rows 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.
  • userId references a since-deleted user → the FK has onDelete: SetNull, so old audit rows keep their resource info but the userId becomes null. The metadata { email } field on login.failure rows still tells you who tried.
  • metadata is 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

  1. Add the literal to the AuditAction union in apps/api/src/lib/audit.ts.
  2. 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.
  3. Document the row in the table at the top of this file.