GenerateSaaS

Audit Logs

Record admin, billing, and security events to the audit_logs table with @repo/audit, and read them from the admin and org audit views.

@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.

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?, clientIp?).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

Every write path routes through audit(), so a logging failure can never break the caller's flow - no try/catch needed at the call site.

Actions and metadata

AUDIT_ACTIONS (packages/audit/src/types.ts) is the single source of truth for what can be audited - pass a constant, never a raw string.

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

An admin acting through impersonation is recorded twice over: the actor stays the impersonated user, and impersonated_by carries the admin's id.

Reading logs

Two routes read the table. Both paginate and enrich rows via enrichLogsWithEntityDetails, which attaches the current entityName, entityImage, and entityEmail from the live user or org - so an entry still shows a readable label after a rename.

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)

Six indexes back these queries: four composite (entity, org, action, actor, each paired with timestamp), a standalone audit_logs_timestamp_idx, and a partial audit_logs_impersonated_by_idx (WHERE impersonated_by IS NOT NULL) that keeps the impersonation review query cheap.

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.

On this page