Audit log

Organization history, transaction guarantees, and adding your own domain events.

Organization settings links to Audit log at /dashboard/organization-settings/audit-log. Owners and admins have the organization-scoped audit:view permission by default. Members do not. The server checks membership, active organization, and permission for list, detail, and actor-filter requests. This history is separate from the platform support console's privileged action records; internal support reasons and tickets are never included.

Platform admins can also open Admin panel → Organizations → an organization → Audit log. This uses the same audit records, schemas, query service, filters, pagination, and detail drawer. Only authorization differs: the tenant endpoints require organization membership and audit:view; the support endpoints require a fresh platform-admin session. Investigating another organization does not switch the active organization or require membership in the target organization. The two API namespaces have separate query-cache keys and discard inactive audit queries so privileged responses are not reused by the tenant view.

Default coverage

Action identifierRecorded change
invitation.sentA new invitation is issued through the invite-member procedure.
invitation.acceptedAcceptance and membership creation complete together.
invitation.canceledA pending invitation is canceled.
member.removedA member is removed or leaves through the organization procedures.
member.role_changedA member's persisted role changes, including automatic owner promotion during account deletion.
organization.settings_changedOrganization name or logo changes; the server adapter also supports slug changes.
audit.exportedAn owner or admin downloads the log. Details hold the export format and the filters applied.

Only completed database changes are recorded. Rejected operations and unchanged role/name/logo submissions produce no success event. Changing a value and later changing it back produces two events. Invitation actions have an idempotency key scoped to the organization, action, and invitation; resending the same pending invitation does not produce another issuance event.

“Invitation sent” means issued, not delivered to an inbox. Email dispatch happens after commit. A delivery failure can leave a valid invitation and its issuance event; retrying dispatch does not duplicate that event.

There is no historical backfill. Organization deletion, initial owner creation, member deletion caused by account-deletion cascades, invitation expiry, metadata, raw database writes, and arbitrary direct auth.api server calls are not audited by this feature. Deleting an organization cascades its audit rows, so a deleted-org event cannot survive on this recorder. Checkout, cancel, resume, organization creation, and refund or dispute revocation are recorded here. Do not use the low-level Better Auth calls for new tenant mutations: extend the audited adapters instead.

Export

The audit log page has an Export menu that downloads the log as CSV or NDJSON (format=json, one JSON object per line). The export honors the filters currently set on the page (action, actor, and date range). It streams from GET /api/organizations/{organizationId}/audit-log/export?format=csv|json, which accepts the optional query parameters action, actorId, from, and to (YYYY-MM-DD, in UTC). A plain <a download> link is enough to call it.

Columns are id, createdAt, action, actorKind, actorId, actorName, targetType, targetId, targetName, and details. Names are resolved at export time. CSV cells that start with a formula character are prefixed with a quote so spreadsheets do not execute them.

Plan flag. Export is gated by the auditExport billing feature flag. The shipped catalog turns it on for Enterprise only. To enable it on any other plan, set auditExport: true in that plan's createBillingFeatureFlags call in packages/payments/src/catalog/declaration.ts. No other change is needed. The menu shows an upgrade prompt on plans without the flag, a retry action if plan details fail to load, and a disabled state while billing is in a blocking configuration error. See Billing for how flags are declared.

Who can export. The caller must be a member of the organization with audit:view (owners and admins by default). If the organization requires two-factor authentication, the caller must have it enabled, the same rule oRPC procedures enforce. The route returns 401 without a session, 403 for a non-member, missing permission, missing plan flag, or a failed two-factor check, and 409 while billing is blocked. The route also returns 403 for cross-site requests: when present, Sec-Fetch-Site must be same-origin or none and Origin must match the app origin.

Audit event. Each successful export records one audit.exported event whose details are { format, filters }. The exported file contents are not stored. The detail drawer shows the format and the filters used.

Rate limit. The audit.export abuse policy allows 5 exports per minute per user and 10 per minute per organization. Over the limit the route returns 429 with a Retry-After header. Adjust the numbers in packages/shared/src/abuse/policies.ts.

Transactions and supported write paths

The recording service requires an explicit Prisma transaction. Organization adapters bind Better Auth to that transaction with withAuthTransaction, then record the event before commit. Locks serialize invitation transitions and member/settings changes so previous values reflect the change that actually occurred. An audit insert failure rolls the business write back.

Better Auth 1.6.22's organization after-hooks do not independently provide that guarantee. In particular, its invitation status write is outside its internal membership-creation transaction. SyntaxKit's outer transaction contains both. This behavior is exercised through the real Better Auth API in integration tests; rerun those tests when upgrading Better Auth.

The application uses the typed oRPC organization procedures. Every raw Better Auth /api/auth/organization/* HTTP route is disabled (404), reads included, so the organization's two-factor requirement cannot be skipped. The audited writes this affects:

  • /api/auth/organization/invite-member
  • /api/auth/organization/accept-invitation
  • /api/auth/organization/cancel-invitation
  • /api/auth/organization/reject-invitation (rejection is not a supported schema/UI state)
  • /api/auth/organization/update
  • /api/auth/organization/update-member-role
  • /api/auth/organization/remove-member
  • /api/auth/organization/leave

The full list lives in packages/auth/src/server/disabled-paths.ts.

Use createAuditedInvitation, acceptAuditedInvitation, cancelAuditedInvitation, and updateAuditedOrganization from @syntaxkit/auth for the supported server-side invitation/settings operations. Member and leave operations use the authorized core transaction helpers and withAuthTransaction; logo updates use replaceOrganizationLogo. The oRPC procedures remain the complete public boundary, including input validation, permissions, image ownership checks, abuse protection, and invitation delivery.

Identity and retention

Records store actor kind and opaque ID, target type and ID, timestamp, and only explicitly allowed details. Names and emails are resolved from current records; they are not copied into audit snapshots. A removed membership keeps its stable user ID as the target identifier. The UI marks deleted resources and deleted users and retains their technical identifier for correlation. Display names therefore reflect current names, not historical names. Invitation email labels disappear if the invitation is deleted.

The actor kinds user, api_key, job, agent, and system leave room for future server-originated events. API key issue, rotation, and revocation are recorded with a user actor and an api_key target. Authentication itself is not written to the log. Jobs and agents are still not implemented. The account-deletion system actor remains.

Retention is indefinite while the organization exists; no purge scheduler is installed. Deleting an organization cascades to its tenant audit records. Account deletion still deletes sole-member organizations and removes account data under the existing lifecycle rules. Surviving organizations retain opaque audit IDs and allowed organization-setting values, but no actor-name/email snapshots. Choose a retention policy appropriate to your product before adding sensitive domain events.

There are no customer edit/delete endpoints. This is application-level append-only history, not cryptographic tamper resistance or a compliance certification. Database operators can still alter records.

Add a domain event

For example, to record project.archived in your own Project domain:

  1. Add a stable identifier and strict payload schema to organizationAuditPayloadSchemas in packages/shared/src/schemas/organization-audit.ts:
"project.archived": z.object({
  previousArchived: z.literal(false),
  newArchived: z.literal(true),
}).strict(),

The action union and typed recording input derive from this catalog. Do not persist request bodies, credentials, private content, or ORM objects.

  1. In the authorized domain use-case, mutate and record using the same transaction. Lock/recheck the resource before deciding it changed:
import { prisma } from "@syntaxkit/database";
import { recordOrganizationAudit } from "@syntaxkit/core/organization-audit";

await prisma.$transaction(async (tx) => {
  // Assumes your product has defined Project and authorized this actor.
  const changed = await tx.project.updateMany({
    where: { id: projectId, organizationId, archived: false },
    data: { archived: true },
  });
  if (!changed.count) return;
  await recordOrganizationAudit(tx, {
    organizationId,
    action: "project.archived",
    actor: { kind: "user", id: actorUserId },
    target: { type: "project", id: projectId },
    details: { previousArchived: false, newArchived: true },
  });
});

An optional idempotencyKey deduplicates the same operation; reuse it only for retries of that operation. Reusing a key with different event contents throws and rolls back. Do not use a resource ID alone for repeatable changes such as archive/unarchive/archive. Optional correlationId connects related operations; it must be a non-secret identifier, never a session token.

  1. Add English and German action labels, descriptions, and detail presentation to apps/web/components/dashboard/audit-log/audit-log.tsx and both apps/web/messages catalogs. Both entry points use this single presentation. Extend target-name resolution in packages/core/src/organization-audit/queries.ts if needed. Unknown historical actions retain an identifier fallback. List responses omit detail payloads; detail requests load them on demand.

  2. Test the successful mutation, rollback, authorization, no-op/retry behavior, and deleted-resource display. No storage, pagination, permission, or new background-job infrastructure is needed for another event.

Upgrade an existing installation

Deploy the new source and apply migration 20260908094843_organization_audit_trail to each environment using your usual migration process:

pnpm db:migrate:deploy
pnpm db:generate
pnpm check-types

No new credentials or environment variables are required. Update any custom client using the disabled raw Better Auth routes to the typed organization procedures before rollout. Existing installations start with empty history. The generated auth schema's organization back-relation is maintained by scripts/generate-auth-schema.ts; never edit the generated schema by hand.

On this page