Orbit
05 · Integrations

Audit log

Append-only ledger at two scopes — tenant and app-wide. Entries are projected from the domain event bus so services never write audit rows directly.

Orbit ships a first-class audit log bounded context in apps/api/src/audit/. It exposes two entry types that share one interface — a tenant-scoped log read by workspace admins, and an app-wide log read by platform admins. Entries are materialised by a post-commit projector subscribed to the domain event bus, so services do not touch audit rows and cannot forget to log.

Note

Paid feature. The audit log ships with the paid track. Scaffold with --audit-log=yes (the default when any paid feature is on) to keep it, or --audit-log=no to strip the bounded context, its repositories, the two Prisma models, and the workspace.audit_log.* / team.audit_log.view permissions.

Two scopes, one shape

EntryScopeWho reads it
AppAuditEntryApp-widePlatform admins (user.role === "admin"). Moderation-class events: bans, impersonations, cross-tenant admin operations.
WorkspaceAuditEntryPer workspace (optionally narrowed by team)Members with workspace.audit_log.view, or team.audit_log.view when teamId is set. Lifecycle of the workspace's own aggregates — members, roles, teams, billing.

Both entry types extend one AuditEntry interface in audit/domain/audit-entry.ts, so filters, pagination, and cursors behave identically across the two.

How entries get written

The audit log is a projection, not a side effect services opt into. When a domain event is dispatched after a successful commit, the projector in audit/application/audit-projector.ts maps it to an audit row via audit-event-mapper.ts and writes through the matching repository. Services stay free of audit concerns.

a service changes state — nothing audit-shaped in sight

await uow.run(async (tx) => {
const workspace = await tx.workspaces.findById(workspaceId);
workspace.rename("Acme Rocket Division");
await tx.workspaces.save(workspace);
tx.events.add(new WorkspaceRenamed(workspaceId, actor));
});

The WorkspaceRenamed event is queued on the Unit of Work, dispatched post-commit, and translated by the projector into a WorkspaceAuditEntry row with action = "workspace.renamed" and the relevant target + metadata.

Note

Post-commit only. The projector runs after the transaction commits, not inside it. A rolled-back transaction produces no audit entry — by design. Audit rows never disagree with business state.

Metadata sanitisation

Domain events carry whatever payload the aggregate needs. Before an entry is persisted, payloads pass through audit/application/sanitize-metadata.ts, which strips known PII-flavoured keys (tokens, secrets, raw credentials) and truncates oversized blobs. Unit-tested in sanitize-metadata.test.ts.

Permissions

PermissionScopeGrants
workspace.audit_log.viewWorkspaceRead the workspace's audit stream with filters and pagination.
workspace.audit_log.exportWorkspaceExport audit entries (CSV/JSON) for compliance review.
team.audit_log.viewTeam (requires the teams feature)Read workspace entries narrowed by teamId. Granted to team admins by default.

Declared once in packages/shared/src/permissions.ts, guarded server-side by requirePermission(...) and requireTeamPermission(...), and surfaced on the client via useCan() / useCanTeam().

Query surface

Two application services are registered in the composition root: ListAppAuditService and ListWorkspaceAuditService. Both accept the same AuditFilter shape and return a forward-only cursor page.

AuditFilter (audit/domain/audit-entry.ts)

export interface AuditFilter {
actorUserId?: UserId;
action?: string | readonly string[];
from?: Date;
to?: Date;
cursor?: string; // opaque, forward-only
limit?: number; // clamped by the repository
}

Storage

  • Two tables, app_audit_entries and workspace_audit_entries, with composite indexes on (scope_id, occurred_at) plus per-action and per-actor indexes for common queries.
  • Two adapters per table — Prisma and Drizzle — so the audit log works on either ORM track with no service code changes.
  • Pagination uses an opaque cursor built from (occurredAt, id) — see audit/infrastructure/cursor.ts.

Turning it off

Passing --audit-log=no at scaffold time removes:

  • The entire apps/api/src/audit/ bounded context.
  • The AppAuditEntry and WorkspaceAuditEntry models in the Prisma schema (and their Drizzle mirrors on the Drizzle track).
  • The appAudit and workspaceAudit repositories on the Unit of Work, plus the projector wiring in composition.ts.
  • The workspace.audit_log.view, workspace.audit_log.export, and team.audit_log.view permissions, including the system-role seeds that grant them.
  • The integration test suite at apps/api/src/__tests__/audit.integration.test.ts.
Note

Domain events keep firing. Stripping the audit log does not remove any events from the domain layer — it just removes the projector that was listening. Other projectors (realtime, mailer, billing reconciliation) are untouched.