The audit_logs collection in MongoDB is the canonical record of "who did what, when." Every
workspace mutation that crosses an RBAC boundary should produce an entry.
interface AuditLog {
_id: ObjectId;
eventType: string; // e.g. "webinars.bulk-invite", "user_role_changed", "rate_limit_exceeded"
userId?: string | ObjectId; // Clerk userId for human actions; ObjectId for system actions
ip: string; // from x-forwarded-for, or "webhook" / "system"
userAgent: string;
resource?: string; // e.g. "webinar:matters-2026-spring", "collection:whitepaper_leads"
action?: string; // typically mirrors eventType for query convenience
result: "success" | "failure";
metadata?: Record<string, any>; // shape-specific
timestamp: Date; // when the event happened
}
Defined in types/auth.ts; collection accessed via getDb().collection<AuditLog>("audit_logs").
auditMutation(opts, req) from @/lib/security/api-guards — the ergonomic helper used by
workspace mutation routes.logAuditEvent(event) from @/lib/security/audit — the underlying primitive.Failures are swallowed. Audit logging is best-effort and must never break the handler — if Mongo's down, we don't want every mutation to 500 because the audit write timed out.
These auto-trigger a console alert in sendSecurityAlert(). Future: wire to Slack + email.
| Event | Source |
|---|---|
multiple_failed_logins | login flow (TODO) |
suspicious_activity | future anomaly detector |
permission_escalation_attempt | /api/admin/users PUT when an unauthorized actor tries to grant org:super_admin |
user_role_changed | /api/admin/users PUT on every role change |
super_admin_action | any action by a super_admin (TODO: emit globally) |
rate_limit_exceeded | every enforceRateLimit breach |
honeypot_triggered | forms with honeypot fields |
low_recaptcha_score | reCAPTCHA below threshold |
recaptcha_verification_failed | reCAPTCHA token verification failed |
access_denied | proxy.ts rejections, Clerk webhook purges |
resource_not_found | 404s on RBAC-gated routes |
system_error | unhandled exceptions in critical paths |
The full list is CRITICAL_EVENTS in lib/security/audit.ts.
import { getAuditLogs } from "@/lib/security/audit";
const events = await getAuditLogs({
userId: "user_2abc…",
limit: 50,
});
const breaches = await getAuditLogs({
eventType: "rate_limit_exceeded",
startDate: new Date(Date.now() - 24 * 60 * 60 * 1000),
});
There's no resource filter helper today; query directly:
const db = await getDb();
const events = await db
.collection<AuditLog>("audit_logs")
.find({ resource: "webinar:matters-2026-spring" })
.sort({ timestamp: -1 })
.limit(100)
.toArray();
If you find yourself wanting this query often, add a resource parameter to getAuditLogs.
No retention runs automatically. A cleanupOldAuditLogs() helper exists in
lib/security/audit.ts (deletes entries older than 90 days), but nothing schedules it, so the
collection grows unbounded in practice. Recommendation for production:
timestamp with a 365-day expiry for most event types.permission_escalation_attempt and user_role_changed to cold storage indefinitely
(legal / compliance).db.audit_logs.createIndex(
{ timestamp: 1 },
{ expireAfterSeconds: 365 * 24 * 60 * 60 }
);
The audit_logs collection is append-only in the happy path. Indexes worth having:
{ userId: 1, timestamp: -1 } — user history queries.{ eventType: 1, timestamp: -1 } — alert dashboards.{ resource: 1, timestamp: -1 } — per-resource audit trail.timestamp (see retention above).Today no indexes are created automatically. Tracked in PROD_CHECKLIST.md.
Currently the audit log is read by the workspace /system module (super_admin only). If a
new role needs read access, scope it tightly — these logs contain PII (registrant emails,
WordPress page slugs that may reveal product features pre-launch, etc.).