Audit Log

Every authorization-relevant event in Ragz — not just changes, but the denials too — is written to a single append-only audit trail. It's the evidence layer behind every permission check described in RBAC.

What's recorded

Each event captures:

FieldMeaning
org_idThe organization the event belongs to (null for platform-level events like org creation).
actor_idWho did it (null for unauthenticated/system events, e.g. an invitation being accepted).
actionThe dotted action — e.g. workspace.members.manage, or a permission string like documents.acl.bypass for a denied route check.
target_type / target_idWhat the action was performed on — organization, role_template, route, etc.
resultsuccess or denied — a CHECK constraint at the database level; there is no third value.
reason_codeWhy a denial happened, e.g. missing_action.
request_id, source_ip, auth_method, credential_idRequest-correlation and provenance fields, auto-filled from the ambient request context where available.

Successes are written for the meaningful admin/governance actions — org and workspace changes, invitations, role-template lifecycle events, and so on. Denials are written for every single 403 raised by the central require_action authorization check, before the exception is even raised — so a pattern of failed access attempts is visible in the log even though nothing about the underlying resource ever changed.

Append-only, enforced by the database — not just the application

audit_events has BEFORE UPDATE and BEFORE DELETE triggers in Postgres that unconditionally raise an exception, for every role including the table owner. No application code path updates or deletes an audit row, and the database itself refuses to let one — an UPDATE or DELETE against audit_events fails at the database layer regardless of who or what issues it. The only way to remove that guarantee is an explicit, conspicuous migration that drops the triggers — never an ordinary application write.

Who can see it

Reading and exporting the audit log are their own permissions — audit.read and audit.export — and they are carve-outs: unlike almost everything else, org admin does not auto-hold them. An admin has to be explicitly assigned a role template that grants them (the seeded Audit Reader template covers this — see Roles & Permissions). This is a deliberate separation-of-duties choice (NIST AC-5): running IAM for an org shouldn't automatically mean being able to read that org's compliance trail.

Visibility is also org-scoped for everyone except superadmin: a caller with audit.read only ever sees their own organization's events — any org_id filter they pass is ignored and overridden server-side, never trusted from the request. Superadmin retains the platform-wide view across every organization.

Where to view it

Admin → Audit, gated on audit.read. The page lists events newest-first with filters (action, actor, date range) and keyset pagination for scrolling through history without expensive offsets. An Export CSV action — gated on the separate audit.export permission — streams the full filtered result set (the API itself streams NDJSON; the admin page's export renders it to a downloadable CSV) so an auditor can pull evidence without paging through the UI by hand.