← Back to the section

An administrator is a special user in the system: they can read and modify other people's data, bypassing the usual ownership checks. This is needed for support, operational tasks, and compliance reviews. But it's exactly this that makes the admin account a target: if someone gains access to it, without an audit journal you won't know at all what was done.

The solution is simple: every administrator action is written into a separate table — who, when, what they did, and with which object. This journal cannot be modified or deleted — only a new record can be added.

Why a separate table and not just logs

Ordinary application logs (slog, zap, and so on) are written for diagnostics: to understand what went wrong, to find an error. They are stored temporarily, may be rotated, and give no integrity guarantees.

An audit journal is different. It's a compliance record: a legally significant trail of who did what with the data. It must:

  • be stored immutably — no UPDATE and no DELETE;
  • be atomic with the business operation — if the record isn't saved, the operation is not considered completed;
  • contain specific fields: who did it, when, what exactly, and with which object.

A message in a log file satisfies none of these requirements.

The audit table

One table per service or a separate one for each aggregate type — both options work. The separate-table variant (for example, order_audit_log) is convenient when you need typed columns.

CREATE TABLE order_audit_log (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    actor_id     text        NOT NULL,
    action       text        NOT NULL,
    aggregate_id text        NOT NULL,
    occurred_at  timestamptz NOT NULL DEFAULT now(),
    metadata     jsonb       NOT NULL DEFAULT '{}'
);

CREATE INDEX ix_order_audit_actor    ON order_audit_log(actor_id, occurred_at DESC);
CREATE INDEX ix_order_audit_aggregate ON order_audit_log(aggregate_id);

REVOKE UPDATE, DELETE ON order_audit_log FROM app_role;

The REVOKE UPDATE, DELETE line is key. The application physically cannot modify or delete a record, even if a bug slips into the code. Append-only is guaranteed at the database level.

Table fields:

  • actor_id — who performed the action (the user identifier from the token);
  • action — what exactly happened, as a string like order.cancel or order.read.admin-override;
  • aggregate_id — with which object;
  • occurred_at — when;
  • metadata — additional context in JSON format (for example, the cancellation reason).

The audit.Logger interface

Go has no aspect-oriented programming (AOP) mechanism that would let you "attach" an audit record to a method from the outside. So the call is explicit — inside every handler where it's needed.

To avoid tying the business logic directly to PostgreSQL, an interface port is introduced:

// core/audit/audit.go
package audit

import (
    "context"
    "time"
)

type LogEntry struct {
    ActorID     string
    OccurredAt  time.Time
    Action      string
    AggregateID string
    Metadata    map[string]any
}

type Logger interface {
    Log(ctx context.Context, e LogEntry) error
}

The interface lives in core/audit/ — it's part of the business core. It knows nothing about PostgreSQL or sqlc. The handler accepts an audit.Logger through its constructor, and in tests a stub is substituted for the real database.

The call in the handler

Example: an administrator cancels someone else's order.

// core/order/handler/admin_cancel_order.go
type AdminCancelOrderHandler struct {
    repo  order.Repository
    audit audit.Logger
}

func NewAdminCancelOrderHandler(repo order.Repository, a audit.Logger) *AdminCancelOrderHandler {
    return &AdminCancelOrderHandler{repo: repo, audit: a}
}

func (h *AdminCancelOrderHandler) Handle(ctx context.Context, cmd AdminCancelOrderCommand) error {
    principal := security.PrincipalFrom(ctx)

    ord, err := h.repo.ByID(ctx, cmd.OrderID)
    if err != nil {
        return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
    }
    if err := ord.Cancel(cmd.Reason); err != nil {
        return err
    }
    if err := h.repo.Save(ctx, ord); err != nil {
        return fmt.Errorf("save order %s: %w", cmd.OrderID, err)
    }
    return h.audit.Log(ctx, audit.LogEntry{
        ActorID:     principal.Sub,
        OccurredAt:  time.Now().UTC(),
        Action:      "order.cancel",
        AggregateID: ord.ID,
        Metadata: map[string]any{
            "reason":         cmd.Reason,
            "owner_customer": ord.CustomerID,
        },
    })
}

The audit record goes after repo.Save and before the return — in the same database transaction. If audit.Log returns an error, the transaction rolls back together with the business operation.

Atomicity with the business operation

The main requirement: the audit journal record and the business change must either both be saved or both be rolled back. If you first commit the order and then write the audit record — a failure can occur between these two moments, and the journal ends up incomplete.

The transaction is opened by the middleware on the incoming request:

// adapters/in/http/middleware/tx.go
func WithTransaction(pool *pgxpool.Pool, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tx, err := pool.Begin(r.Context())
        if err != nil {
            httperr.Write(w, r, err)
            return
        }
        ctx := context.WithValue(r.Context(), txKey, tx)
        defer func() {
            if p := recover(); p != nil {
                _ = tx.Rollback(ctx)
                panic(p)
            }
        }()
        rw := &responseWriter{ResponseWriter: w}
        next.ServeHTTP(rw, r.WithContext(ctx))
        if rw.status >= 500 {
            _ = tx.Rollback(ctx)
            return
        }
        if err := tx.Commit(ctx); err != nil {
            httperr.Write(w, r, err)
        }
    })
}

The audit adapter takes the transaction from the context via sqlcgen.New(tx) — and both the INSERT into order_audit_log and the INSERT/UPDATE of business data fall into a single transaction:

// adapters/out/postgres/audit/order_audit_adapter.go
func (a *OrderAuditAdapter) Log(ctx context.Context, e audit.LogEntry) error {
    meta, err := json.Marshal(e.Metadata)
    if err != nil {
        return err
    }
    return a.q.InsertOrderAuditLog(ctx, sqlcgen.InsertOrderAuditLogParams{
        ActorID:     e.ActorID,
        Action:      e.Action,
        AggregateID: e.AggregateID,
        OccurredAt:  e.OccurredAt,
        Metadata:    meta,
    })
}

When the administrator only reads

An audit record is needed not only for modifying operations. If an administrator reads a user's data (an order, contact information), that too is an event significant from the standpoint of personal data protection. Access to someone else's data must be recorded.

func (h *GetOrderHandler) Handle(ctx context.Context, cmd GetOrderCommand) (OrderView, error) {
    principal := security.PrincipalFrom(ctx)

    ord, err := h.repo.ByID(ctx, cmd.OrderID)
    if err != nil {
        return OrderView{}, fmt.Errorf("load order %s: %w", cmd.OrderID, err)
    }
    if err := h.policy.CheckOwnership(ord, principal); err != nil {
        return OrderView{}, err
    }
    if isAdmin(principal) {
        _ = h.audit.Log(ctx, audit.LogEntry{
            ActorID:     principal.Sub,
            OccurredAt:  time.Now().UTC(),
            Action:      "order.read.admin-override",
            AggregateID: ord.ID,
            Metadata:    map[string]any{"owner_customer": ord.CustomerID},
        })
    }
    return toView(ord), nil
}

Common mistakes

Writing after committing the transaction. If you call audit.Log after tx.Commit — the audit record ends up outside the transaction. On a failure between the commit and the audit, you get a commit without a trace.

Personal data in Metadata. Only identifiers are written to the metadata field — customer_id, order_id. Names, emails, phone numbers — no. Whoever wants to see the details loads them by identifier.

slog instead of the journal. A line in a log file does not replace a record in the table. Logs are for diagnostics, the audit table is for compliance.

Auditing only deletions. Read operations that bypass the ownership check also require a record.

In short

  • The audit journal is a separate table with the fields actor_id, occurred_at, action, aggregate_id, metadata.
  • REVOKE UPDATE, DELETE at the database level guarantees that records cannot be modified.
  • Go has no AOP — the audit.Log call is explicit, inside every handler.
  • The journal record and the business operation must be in a single transaction: either both saved or both rolled back.
  • audit.Logger is an interface in the business core; the PostgreSQL adapter is plugged in from the outside.
  • Application logs (slog) and the audit journal are different things with different guarantees.
  • An administrator reading someone else's data is also recorded in the journal.
  • ABAC: resource ownership — how the ownership check works and why admin bypasses it.
  • JWT validation — where principal.Sub, which is written into actor_id, comes from.
  • Role model (RBAC) — how RequireRoles("admin") protects a group of routes before the handler is called.