Overview
The Audit Log provides a complete, tamper-resistant record of all significant actions taken within a workspace. It answers the question: "Who did what, and when?"
This is essential for security compliance, debugging, and accountability in multi-admin workspaces.
Scope
Events to Capture (per workspace)
| Category | Action | Who Triggers |
|---|---|---|
| Workspace | Workspace renamed | Owner / Admin |
| Workspace | Workspace deleted | Owner |
| Ownership | Ownership transferred | Owner |
| Members | Member invited | Owner / Admin |
| Members | Invitation revoked | Owner / Admin |
| Members | Member removed | Owner / Admin |
| Members | Member role changed | Owner / Admin |
| Members | Member joined (accepted invite) | Member |
| API Keys | API key created | Owner / Admin |
| API Keys | API key revoked | Owner / Admin |
| Billing | Plan upgraded / downgraded | Owner |
| Security | 2FA enabled for workspace | Owner |
| Settings | Workspace logo changed | Owner / Admin |
Database Schema
Add a new audit_logs table:
CREATE TABLE audit_logs (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
actor_id TEXT NOT NULL REFERENCES users(id), -- who did it
action TEXT NOT NULL, -- e.g., "workspace.rename", "member.invite"
target_type TEXT, -- e.g., "user", "api_key", "workspace"
target_id TEXT, -- the ID of the affected entity
metadata JSONB, -- before/after values, extra context
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMP DEFAULT NOW() NOT NULL
);Drizzle Schema (packages/core-database/src/schema.ts addition)
export const auditLogs = pgTable("audit_logs", {
id: text("id").primaryKey(),
tenantId: text("tenant_id").references(() => tenants.id, { onDelete: "cascade" }).notNull(),
actorId: text("actor_id").references(() => users.id).notNull(),
action: text("action").notNull(), // "workspace.rename" | "member.invite" | etc.
targetType: text("target_type"), // "user" | "api_key" | "tenant" | null
targetId: text("target_id"),
metadata: jsonb("metadata"), // { before: {...}, after: {...} }
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: timestamp("created_at").defaultNow().notNull(),
})Backend API
GET /manager/tenants/audit-log
- Auth:
role_ownerorrole_admin - Query params:
?limit=50&cursor=<last_id>&action=<filter> - Response: paginated list of audit events with actor info joined
Middleware: logAudit(ctx, action, target?, metadata?)
A reusable utility that other route handlers call at the end of their mutations:
await logAudit(db, {
tenantId,
actorId: userId,
action: "workspace.rename",
targetType: "tenant",
targetId: tenantId,
metadata: { before: { name: oldName }, after: { name: newName } },
})Frontend
Route: /dashboard/settings/audit-log
- Full-page table with infinite scroll / pagination
- Filter by: Action category, Date range, Actor
- Each row: Avatar + Actor name, Action label (human-readable), Target identifier, Timestamp (relative + absolute on hover)
- Export to CSV button (calls
GET /audit-log?format=csv)
Implementation Order
- Add
auditLogsto schema + migrate - Create
logAudit()helper utility inpackages/core-typesormanager/src/lib - Instrument key routes:
PATCH /tenants,DELETE /tenants,POST /tenants/transfer, API key routes, team member routes - Add
GET /tenants/audit-logpaginated endpoint - Build the frontend page at
/dashboard/settings/audit-log - Add link card to workspace settings hub
Security Notes
- Only
role_ownerandrole_admincan view the audit log - Audit log records must be append-only — no update or delete endpoints
- On workspace deletion, audit records are cascade-deleted (acceptable — workspace is gone)
- For compliance-sensitive workspaces (future enterprise plan): archive to cold storage before deletion