← назад к разделу

Администратор в системе — особый пользователь: он может читать и изменять чужие данные, минуя обычные проверки владения. Это нужно для поддержки, операционных задач, проверок соответствия. Но именно это делает admin-аккаунт мишенью: если кто-то получит к нему доступ, без журнала аудита вы вообще не узнаете, что было сделано.

Решение простое: каждое действие администратора пишется в отдельную таблицу — кто, когда, что сделал и с каким объектом. Этот журнал нельзя изменить или удалить — только добавить новую запись.

Зачем отдельная таблица, а не просто логи

Обычные логи приложения (slog, zap и т.п.) пишутся для диагностики: понять, что пошло не так, найти ошибку. Они хранятся временно, могут ротироваться, не дают гарантий целостности.

Журнал аудита — другое. Это compliance-запись: юридически значимый след того, кто и что делал с данными. Она должна:

  • храниться неизменно — никаких UPDATE и DELETE;
  • быть атомарна с бизнес-операцией — если запись не сохранилась, операция не считается выполненной;
  • содержать конкретные поля: кто сделал, когда, что именно, с каким объектом.

Сообщение в лог-файле ни одному из этих требований не удовлетворяет.

Таблица аудита

Одна таблица на сервис или отдельная на каждый тип агрегата — оба варианта рабочие. Вариант с отдельной таблицей (например, order_audit_log) удобен, когда нужны типизированные колонки.

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;

Строка REVOKE UPDATE, DELETE — ключевая. Приложение физически не сможет изменить или удалить запись, даже если в коде допустить ошибку. Append-only гарантирован на уровне базы.

Поля таблицы:

  • actor_id — кто выполнил действие (идентификатор пользователя из токена);
  • action — что именно произошло, строкой вроде order.cancel или order.read.admin-override;
  • aggregate_id — с каким объектом;
  • occurred_at — когда;
  • metadata — дополнительный контекст в формате JSON (например, причина отмены).

Интерфейс audit.Logger

В Go нет механизма аспектно-ориентированного программирования (AOP), который позволил бы «прицепить» запись аудита к методу снаружи. Поэтому вызов явный — внутри каждого обработчика, где это нужно.

Чтобы не привязывать бизнес-логику к PostgreSQL напрямую, вводят интерфейс-порт:

// 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
}

Интерфейс живёт в core/audit/ — это часть бизнес-ядра. Он ничего не знает ни о PostgreSQL, ни о sqlc. Обработчик принимает audit.Logger через конструктор, а в тестах вместо настоящей базы подставляют заглушку.

Вызов в обработчике

Пример: администратор отменяет чужой заказ.

// 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,
        },
    })
}

Запись аудита идёт после repo.Save и до return — в той же транзакции базы данных. Если audit.Log вернёт ошибку, транзакция откатится вместе с бизнес-операцией.

Атомарность с бизнес-операцией

Главное требование: запись в журнал аудита и бизнес-изменение должны либо оба сохраниться, либо оба откатиться. Если сначала зафиксировать заказ, а потом записать аудит — между этими двумя моментами может произойти сбой, и журнал окажется неполным.

Транзакцию открывает middleware при входящем запросе:

// 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)
        }
    })
}

Адаптер аудита берёт транзакцию из контекста через sqlcgen.New(tx) — и INSERT в order_audit_log, и INSERT/UPDATE бизнес-данных попадают в одну транзакцию:

// 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,
    })
}

Когда администратор только читает

Запись аудита нужна не только для изменяющих операций. Если администратор читает данные пользователя (заказ, контактную информацию), это тоже событие, значимое с точки зрения защиты персональных данных. Доступ к чужим данным нужно фиксировать.

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
}

Частые ошибки

Запись после фиксации транзакции. Если вызвать audit.Log уже после tx.Commit — аудит окажется вне транзакции. При сбое между commit и audit получается фиксация без следа.

Персональные данные в Metadata. В поле metadata пишут только идентификаторы — customer_id, order_id. Имена, email, телефоны — нет. Кто хочет посмотреть детали, подгружает их по идентификатору.

slog вместо журнала. Строка в лог-файле не заменяет запись в таблицу. Логи — для диагностики, таблица аудита — для compliance.

Аудит только для удалений. Читающие операции с обходом проверки владения тоже требуют записи.

Коротко

  • Журнал аудита — отдельная таблица с полями actor_id, occurred_at, action, aggregate_id, metadata.
  • REVOKE UPDATE, DELETE на уровне базы гарантирует, что записи нельзя изменить.
  • В Go нет AOP — вызов audit.Log явный, внутри каждого обработчика.
  • Запись в журнал и бизнес-операция должны быть в одной транзакции: либо оба сохранились, либо оба откатились.
  • audit.Logger — интерфейс в бизнес-ядре; PostgreSQL-адаптер подключается снаружи.
  • Логи приложения (slog) и журнал аудита — разные вещи с разными гарантиями.
  • Администратор, читающий чужие данные, тоже фиксируется в журнале.

Что почитать дальше

  • ABAC: владение ресурсом — как работает проверка владения и почему admin её обходит.
  • JWT валидация — откуда берётся principal.Sub, который пишется в actor_id.
  • Ролевая модель (RBAC) — как RequireRoles("admin") защищает группу маршрутов до вызова обработчика.