Audit Logs
Admin-visible trail of every CRUD mutation across the platform with before/after snapshots
Audit Logs
Audit logs answer the question "who changed what, and what did the data look like before and after?" Every mutation performed by an admin, instructor, or system actor is captured here with the context needed to reconstruct what happened.
Where to Find Them
Sign in as an admin with audit.read permission and go to Admin → Audit → Audit Logs (/admin/audit-logs).
What Each Row Captures
Every audit log row stores the actor, the resource, the operation, and (when available) the data on either side of the change.
| Field | Type | Notes |
|---|---|---|
id | UUID | Primary key |
tenant_id | UUID | NOT NULL — every row is tenant-scoped |
actor_id | UUID | The user that performed the action (nullable for system actors) |
actor_type | string | One of: user, admin, system (default user) |
action | string | created, updated, deleted, or any custom verb published by a service |
resource_type | string | course, user, role, enrollment, quiz, category, tag, setting, api_call, etc. |
resource_id | string | UUID or other identifier of the affected resource |
module | string | The originating service: auth, learning, quiz, billing, notification, engagement, ecommerce, tenant, etc. |
description | text | Human-readable summary, set by the publishing service |
before_value | JSONB | Pre-mutation snapshot (typically present for updated/deleted) |
after_value | JSONB | Post-mutation snapshot (typically present for created/updated) |
ip_address | INET | Client IP captured at publish time |
user_agent | text | Client user agent at publish time |
metadata | JSONB | Free-form context (endpoint, method, status code, correlation IDs, etc.) |
created_at | timestamptz | Server timestamp at insert (or the Timestamp field on the source event if set) |
Actor enrichment.
actor_idis stored as a bare UUID. After the page is fetched,loadActorsForAuditLogs(audit-service/api/handlers/audit_log_handler.go) collects every non-nilActorIDacross the result set and issues a singleUserAssociationService.GetUsersByIDscall to the RBAC database — so a page of 50 rows costs one lookup. Matches are attached to each row asactor(name, email, avatar). Rows whose actor was hard-deleted fromedushade_authstill surface; only theactorblock stays empty.
What the UI Shows
The list view (src/components/admin/audit/audit-logs-table.tsx) renders these columns:
| Column | Behavior |
|---|---|
| Timestamp | Human-formatted, sortable |
| Actor | Name + email if the actor was loaded from RBAC; otherwise actor_type + truncated actor_id |
| Action | Coloured badge — green for create, blue for update, red for delete, gray otherwise |
| Resource | Capitalised resource_type |
| Resource ID | First 8 characters in monospace (full ID is in the detail drawer) |
| Module | Capitalised module (or - if unset) |
| Description | Truncated, muted text |
Click any row to open the detail drawer (audit-log-detail.tsx) which expands every field — including the full before_value, after_value, and metadata JSON blocks pretty-printed inside scrollable code panels.
Built-in Filters
The toolbar exposes three quick filters (the underlying API supports more — see Filters & Search):
| Filter | Options |
|---|---|
| Action | create, update, delete |
| Resource Type | course, user, role, enrollment, quiz, category, tag, setting |
| Module | auth, learning, quiz, billing, notification, engagement, ecommerce, tenant |
These dropdowns are a curated UI subset, not an exhaustive list of what the backend emits. Services publish other values — for example
tag_association,category_association,subscription_plan,purchase,refund,installment_payment,manual_payment,scholarship,file,multipart_upload,media,directory, and the genericapi_callproduced by Audit Middleware. Modules also includemisc,migration, andbooking. The API'sresource_types[]andmodules[]query params accept any string, so you can filter by those values directly even when the UI dropdown does not list them — see Filters & Search.
A bulk Export button is available in the toolbar — see Export.
API
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/admin/audit/audit-logs | Paginated list with filters |
| GET | /v1/admin/audit/audit-logs/:id | Single audit log by ID |
| GET | /v1/admin/audit/audit-logs/export | JSON file download |
All three require a valid JWT and the audit.read permission. All three are scoped to the caller's tenant by audit-service/middlewares — the tenant ID comes from the request context and is used as the leading WHERE clause.
Pagination Defaults
- Default page size: 50 rows
- Default sort:
created_at DESC sort_byaccepts any column name; if it doesn't already contain a., the aliasal.is automatically prepended
See API Reference for the full request/response shape.
How a Row Gets Created
Audit log rows are never inserted by application code directly. They are always the product of an event published to the audit.events topic and persisted by the AuditEventSubscriber in the audit-service. There are two main publishing paths:
- Explicit publishes — A handler builds an
AuditEventPayloadand callsevents.PublishAuditEvent(...)after a mutation succeeds. Used when you want full control overbefore_value,after_value, anddescription. - Auto-publishes via middleware — Mounting
events.AuditMiddleware(publisher, "<module>")on an admin route group automatically publishes a row for every successful POST/PUT/PATCH/DELETE. Resource is recorded asapi_callandendpointgoes into bothresource_idandmetadata.endpoint.
See Event Ingestion for the payload contract and Audit Middleware for the drop-in.
Indexes & Query Behaviour
The audit_logs table is indexed for the access patterns you actually use:
| Index | Purpose |
|---|---|
idx_audit_logs_tenant_id | Tenant-leading scans |
idx_audit_logs_actor_id | "What did user X do?" |
idx_audit_logs_resource (resource_type, resource_id) | "What happened to this row?" |
idx_audit_logs_action | "Show me all deletes" |
idx_audit_logs_module | Per-module filtering |
idx_audit_logs_created_at | Time-based scans |
idx_audit_logs_tenant_created (tenant_id, created_at DESC) | The composite index that powers the default sort + tenant filter — the hot path for the UI |
Filtering by a non-tenant column on its own (without a date range) on a multi-tenant table can fall back to a sequential scan; the UI never does this because tenant scoping is mandatory.
Troubleshooting
| Issue | Solution |
|---|---|
| Page is empty for a fresh tenant | Audit logs accumulate as admins act — no logs is the expected initial state |
| Actor column shows only an ID | The user was hard-deleted from edushade_auth; the row is preserved but actor enrichment cannot resolve |
| Mutation happened but no audit row appeared | Check the publishing service's logs — Redis may have been down at publish time, or the service may not be wired with AuditMiddleware / a manual publish |
| Detail drawer JSON is huge | The before_value/after_value snapshots are full payloads — large objects will render large blocks; this is intentional |
| Cannot see Audit Logs in the sidebar | You lack the audit.read permission; ask an admin to grant it |

