Recipes

Audit logging

Every admin action in a Stanza app is recorded in an audit log — who did what, when, and from where. This recipe shows how to add audit logging to your modules.


How it works

The adminaudit module provides a fire-and-forget Log function. It extracts the admin's identity from the JWT in the request context and records the action in the audit_log table.

import "github.com/stanza-go/standalone/module/adminaudit"

adminaudit.Log(db, r, "product.create", "product", "42", "Widget Pro")
ParameterPurpose
dbDatabase connection
rHTTP request (used to extract admin ID and IP)
actionWhat happened — entity.verb format
entity_typeWhat kind of thing was affected
entity_idID of the affected entity
detailsFree-text detail (email, name, description of change)

Action naming convention

Actions follow the entity.verb pattern:

ActionWhen
product.createCreated a new product
product.updateUpdated a product
product.deleteSoft-deleted a product
user.createCreated a new user
user.impersonateGenerated impersonation token
session.revokeRevoked a refresh token
setting.updateChanged an application setting
cron.triggerManually triggered a cron job
job.retryRetried a failed queue job
database.downloadDownloaded the SQLite file

Adding audit logging to a module

Call adminaudit.Log after every successful mutation:

func createHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        // ... validate, insert into database ...

        adminaudit.Log(db, r, "product.create", "product",
            strconv.FormatInt(result.LastInsertID, 10), req.Name)

        http.WriteJSON(w, http.StatusCreated, map[string]any{"product": p})
    }
}

func deleteHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        // ... soft-delete from database ...

        adminaudit.Log(db, r, "product.delete", "product",
            strconv.FormatInt(id, 10), "")

        http.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
    }
}

What gets recorded

Each audit entry captures:

ColumnSource
admin_idExtracted from JWT claims in request context
actionThe entity.verb string you pass
entity_typeWhat was affected
entity_idID of the affected entity
detailsFree-text context
ip_addressFrom X-Forwarded-For header or RemoteAddr
created_atUTC timestamp

Viewing the audit log

The admin panel shows the audit log at /admin/audit with:

  • Paginated, filterable list of all admin actions
  • Filter by action type and admin user
  • Search in details
  • Timestamps and IP addresses

The dashboard at /admin also shows the 10 most recent actions as an activity feed.

API endpoints:

GET /api/admin/audit         — paginated list with filters
GET /api/admin/audit/recent  — last 10 entries (dashboard feed)

Automatic cleanup

The built-in purge-old-audit-log cron job runs daily at 4:00 AM and deletes entries older than 90 days. Adjust the retention period in provideCron if needed.


Tips

  • Fire-and-forget. adminaudit.Log silently ignores errors — audit logging never blocks the primary operation.
  • Log after success. Call Log after the database write succeeds, not before.
  • Use details wisely. Include the most useful context — an email address, a name, or a description of what changed. Keep it short.
  • Admin-only. The audit log tracks admin actions. User-facing actions (login, profile update) have their own patterns — user auth events are tracked via refresh tokens and access logs.
Previous
Automated backups