GenerateSaaS

Audit Logs

Record entity changes and security events with @repo/audit and the admin audit view.

@repo/audit records who did what to which entity - admin actions, billing changes, and auth security events - into the audit_logs table. It has no config flag: audit logging is always-on infrastructure, gated only by read access (platform and org admins) and a retention window.

What you get

  • Three write paths: request-scoped, system, and a low-level insert that never throws.
  • Type-checked actions: pass an AUDIT_ACTIONS constant, never a raw string.
  • Two read paths: GET /admin/audit-logs (all logs, adminGuard) and GET /audit (caller's org only, orgAdminGuard).
  • Entity enrichment: rows resolve to live user/org names, so the UI shows readable labels even after the record changes.
  • Automatic retention: a weekly job prunes rows past the cutoff.

Recording an entry

In a request handler, build a request-scoped logger that pre-fills actor, IP, and user-agent, then call .log(action, entityType, entityId, options?).

import { AUDIT_ACTIONS, createAuditContext } from "@repo/audit";

const auditCtx = createAuditContext(c.req.raw, session); // actorType from session role
await auditCtx.log(AUDIT_ACTIONS.USER_DELETED, "user", userId);
FunctionUse whenactorType
createAuditContext(req, session).log(...)Inside a request handler"admin" or "user" (from session role)
auditSystem(action, entityType, entityId, metadata?)Background jobs and webhooks (no request)"system"
audit(entry)Lowest-level insert; never throws (failures are logged)from entry

Actions and metadata

AUDIT_ACTIONS (in packages/audit/src/types.ts) is the single source of truth for what can be audited.

GroupExample constants
AdminUSER_BANNED, USER_UNBANNED, USER_DELETED, USER_ROLE_CHANGED, ORG_DELETED
BillingPLAN_SET, PLAN_CLEARED, CREDITS_ADDED, CREDITS_REMOVED, CREDITS_SET, AUTO_TOPUP_TRIGGERED, PRODUCT_QUANTITY_SET
Auth securityLOGIN_SUCCESS, PASSWORD_CHANGED, TWO_FACTOR_ENABLED, TWO_FACTOR_DISABLED
Org managementMEMBER_INVITED, MEMBER_ROLE_CHANGED, MEMBER_REMOVED
API keysAPI_KEY_CREATED, API_KEY_REVOKED, API_KEY_USED

entityType is "user" | "organization"; actorType is "user" | "admin" | "system" | "api_key". The metadata JSON column is free-form, but typed shapes are exported for structured detail.

ShapeFields
BillingChangeMetadatabefore/after (plan, credits), reason, source
BanMetadatareason, expiresAt
RoleChangeMetadatabefore, after
MemberMetadatamemberId, memberEmail, role

Reading logs

Two routes read the table. Both paginate and enrich rows via enrichLogsWithEntityDetails (adds entityName, entityImage, entityEmail from the live user/org).

RouteFileGuardScopeFiltersSort
GET /admin/audit-logspackages/api/src/routes/internal/admin/audit-logs.tsadminGuard (platform admin role)All logsaction, entityType, entityId, actorId, startDate/endDatetimestamp, action, or entityType (sortOrder asc/desc)
GET /auditpackages/api/src/routes/internal/audit.tsorgAdminGuard (org owner/admin)Caller's orgId onlyaction, entityTypetimestamp desc (fixed)

Five indexes back these queries - four composite (entity, org, action, actor, each paired with timestamp) plus a standalone audit_logs_timestamp_idx.

Retention

A weekly maintenance job deletes rows older than cacheConfig.retentionDays.auditLogs (default 90 days); adjust it in packages/config/src/cache.ts. See Background jobs for the scheduler.

Frequently asked questions

Why is there no feature flag to disable audit logging? It is compliance and security infrastructure, expected always-on. The only knobs are who can read logs (platform admins see all via adminGuard; org owners/admins see their own org via orgAdminGuard) and the retention window.

Do I need to wrap audit() calls in try/catch? No. All write paths route through audit(), which catches and logs failures so a logging error never breaks the caller's flow.

Why do old entries still show correct names after a user is renamed or deleted? The audit row stores only IDs; the view runs enrichLogsWithEntityDetails at read time to attach the current display name, image, and email.

On this page