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:
| Field | Meaning |
|---|---|
org_id | The organization the event belongs to (null for platform-level events like org creation). |
actor_id | Who did it (null for unauthenticated/system events, e.g. an invitation being accepted). |
action | The dotted action — e.g. workspace.members.manage, or a permission string like documents.acl.bypass for a denied route check. |
target_type / target_id | What the action was performed on — organization, role_template, route, etc. |
result | success or denied — a CHECK constraint at the database level; there is no third value. |
reason_code | Why a denial happened, e.g. missing_action. |
request_id, source_ip, auth_method, credential_id | Request-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.