Skip to main content

Audit Trail

Design RFC

The structured audit_logs + actor-from-JWT discipline described here is part of the planned auth stack. Today's Omnitron emits operator activity into the daemon log only; the typed audit table and the helpers below are reference designs.

Every authorisation-relevant mutation lands in the platform's audit_logs table. The trail is the platform's accountability substrate — without it, post-incident reconstruction is impossible on a closed, anonymous platform.

Shape

interface NewAuditLog {
actorId?: string; // user.id (always from JWT — never from request body)
actorType: 'user' | 'organization' | 'system' | 'admin';
action: string; // dot-separated event name
targetType?: string; // 'user' | 'employee' | 'organization' | 'shop' | …
targetId?: string;
details?: Record<string, unknown>;
userAgent?: string;
}

Two invariants the platform protects mechanically:

  1. actorId is always taken from the verified JWT (actorId() helper in @yourorg/auth-utils). It is NEVER read from a request DTO. This closes the actor-spoofing class of bugs (forge the DTO, get someone else's name on your action).
  2. The audit_logs table is in the STRICT RLS tier (requireContext: true, allowUnfilteredQueries: false). A raw sql\SELECT …`without an authenticated context refuses to return rows — the only legitimate writers are service-layer code paths running under a real JWT or the'system'` escape hatch (migrations, workers).

What gets logged

SurfaceAction prefix
User suspend / ban / role-changeadmin.user.statusChanged, admin.user.roleChanged
Role CRUD (platform)admin.role.created, admin.role.updated, admin.role.deleted
Per-user platform-permission overridesadmin.user.permissionGranted, admin.user.permissionRevoked, admin.user.permissionsReplaced
Per-employee org-permission overridesorg.employee.permissionGranted, org.employee.permissionRevoked, org.employee.permissionsReplaced
Force-transfer org ownership (admin recovery)admin.org.ownership.forceTransferred
Org lifecycle (approve / suspend / ban / verify)org.approved, org.suspended, org.banned, org.verificationUpdated
Shop moderationshop.approved, shop.suspended, shop.banned, commerce.shop.*
Disputesdispute.moderatorAssigned, dispute.resolved
Auth (privileged actions on a user)auth.password.reset, auth.totp.disabled, auth.pgp.disabled, auth.sessions.revoked

Every admin-side RPC method that mutates a privileged resource either writes through AuditLogRepository.log() directly or delegates to a service that does (the per-permission services in Per-user overrides are the canonical template).

Recovery + actor capture invariant

The admin recovery surface (OrganizationsService.forceTransferOwnership) requires the operator to supply a reason (minimum 8 characters) and captures it into the audit-log alongside the recovering-admin's id:

{
actorId, // recovering admin (from JWT)
actorType: 'admin',
action: 'admin.org.ownership.forceTransferred',
targetType: 'organization',
targetId: orgId,
details: { fromUserId, toUserId, reason },
}

Same pattern applies to other recovery-style actions; the reason is enforced at the RPC-zod schema level so it never becomes optional by convention.

Read surface

The admin UI reads via the Audit RPC service:

  • /admin/audit — global feed, filterable by action, targetType, targetId, date range.
  • Per-user/per-org audit tabs — same query, scoped to the entity.

The read path itself is gated on admin.audit.view and double-protected by the STRICT RLS policy on audit_logs:

audit_logs: {
policies: [
allow('read', (ctx) => hasPermission(ctx.auth.permissions ?? [], 'admin.audit.view'), { name: 'adminAuditRead' }),
deny(['create', 'update', 'delete'], () => false, { name: 'systemOnlyWrite' }),
],
skipFor: ['system'],
defaultDeny: true,
}

create is denied for every role except 'system' so audit entries can only be written through the authorised service-layer path — never via a raw client-issued INSERT.

Redaction

Free-form details blobs are written as-is. Two rules:

  1. Never include passwords, secrets, or tokens. The conventional shape is { field: 'password', changed: true } — record that the field changed, not its value.
  2. For free-form text fields (e.g. reason), the value stays as the operator typed it. The audit log is internal to the platform's administrators.

A redaction sweep over historical entries is a separate batch job — see the security checklist for the procedure.

See also

  • Permissionsadmin.audit.view and the registry it sits in.
  • RLS bridge — STRICT tier guarantees on audit_logs.
  • Mental model — how audit ties the four layers together.