Администратор в системе — особый пользователь: он может читать и изменять чужие данные, минуя обычные проверки владения. Это нужно для поддержки, операционных задач, проверок соответствия. Но именно это делает 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")защищает группу маршрутов до вызова обработчика.