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 identifier | Recorded change |
|---|---|
invitation.sent | A new invitation is issued through the invite-member procedure. |
invitation.accepted | Acceptance and membership creation complete together. |
invitation.canceled | A pending invitation is canceled. |
member.removed | A member is removed or leaves through the organization procedures. |
member.role_changed | A member's persisted role changes, including automatic owner promotion during account deletion. |
organization.settings_changed | Organization name or logo changes; the server adapter also supports slug changes. |
audit.exported | An 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:
- Add a stable identifier and strict payload schema to
organizationAuditPayloadSchemasinpackages/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.
- 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.
-
Add English and German action labels, descriptions, and detail presentation to
apps/web/components/dashboard/audit-log/audit-log.tsxand bothapps/web/messagescatalogs. Both entry points use this single presentation. Extend target-name resolution inpackages/core/src/organization-audit/queries.tsif needed. Unknown historical actions retain an identifier fallback. List responses omit detail payloads; detail requests load them on demand. -
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-typesNo 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.