Опирается на правила: AUTH-15 (аудит admin-команд), AUTH-12 (admin обходит ABAC → audit обязателен) → раздел 6. Аудит admin-команд.

Важно знать

  • Каждая state-changing команда от admin обязана писать строку в *_audit_log — без исключений.
  • Поля: actor_id, occurred_at, action, aggregate_id, metadata JSONB.
  • Реализация — явный вызов h.audit.Log(...) в Handler; в Go нет AOP, поэтому аспект не применим.
  • Та же pgx-транзакция: бизнес-write + audit commit или оба rollback; audit отдельным запросом после commit — нарушение.
  • audit.Logger — порт-интерфейс в core/audit/; adapter пишет в PostgreSQL через sqlc.
  • Admin обходит ABAC (AUTH-12): проверка владения пропускается, но каждое такое действие в audit_log обязательно.
  • Audit append-only: только INSERT; REVOKE UPDATE, DELETE на роль приложения.
  • slog-логи не заменяют audit: slog — операционная диагностика, *_audit_log — compliance-запись с гарантией целостности.

Admin-роль обходит ABAC и видит любой агрегат. Это необходимо для support, ops, compliance. Но без audit log компрометация admin-аккаунта оставляет невидимые следы. Каждое admin-действие должно быть восстановимо: кто, когда, что сделал.

Порт audit.Logger

Порт живёт в core/audit/ и не знает ни о PostgreSQL, ни о sqlc.

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

Handler принимает audit.Logger через конструктор — зависимость инвертирована, тест подменяет адаптер заглушкой.

Схема *_audit_log

AUTH-15: одна таблица на сервис или per-aggregate. Вариант per-aggregate (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 на уровне БД. Приложение физически не сможет затереть запись.

Явный вызов в Handler

Go не имеет AOP, поэтому audit — явный вызов в конце Handler-а, внутри той же транзакции.

Пример: admin отменяет чужой заказ (AdminCancelOrderHandler).

// core/order/handler/admin_cancel_order.go
package handler

import (
    "context"
    "fmt"
    "time"

    "example.com/svc/core/audit"
    "example.com/svc/core/order"
    "example.com/svc/adapters/in/http/security"
)

type AdminCancelOrderCommand struct {
    OrderID string
    Reason  string
}

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{   // AUTH-15: обязательно
        ActorID:     principal.Sub,
        OccurredAt:  time.Now().UTC(),
        Action:      "order.cancel",
        AggregateID: ord.ID,
        Metadata: map[string]any{
            "reason":          cmd.Reason,
            "owner_customer":  ord.CustomerID,
        },
    })
}

Audit пишется после repo.Save и до return — в той же pgx-транзакции, которая открылась на уровне роутера или UseCase-обёртки.

Транзакционная гарантия

Бизнес-write и audit.Log должны быть атомарны: либо оба commit, либо оба rollback.

// adapters/out/postgres/audit/order_audit_adapter.go
package audit

import (
    "context"
    "encoding/json"

    "example.com/svc/core/audit"
    "example.com/svc/adapters/out/postgres/sqlcgen"
    "github.com/jackc/pgx/v5"
)

type OrderAuditAdapter struct {
    q *sqlcgen.Queries
}

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

sqlcgen.Queries принимает pgx.Tx или *pgxpool.Pool — тот же ctx, который несёт транзакцию. Транзакцию открывает обёртка на уровне Handler или 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) берёт транзакцию из контекста — и бизнес-запись, и audit INSERT попадают в одну транзакцию.

Admin override ABAC

AUTH-12: admin проходит AccessPolicy.CheckOwnership без ошибки, но Handler обязан залогировать. Пример с GetOrderHandler — admin читает чужой заказ:

// core/order/handler/get_order.go
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
}

func isAdmin(p *security.Principal) bool {
    for _, r := range p.Roles {
        if r == "admin" {
            return true
        }
    }
    return false
}

Читающие операции с override также требуют audit — доступ к чужим данным (PII заказа, контактам клиента) это compliance-событие.

Что запрещено

АнтипаттернПравилоЧто взамен
Admin override без audit.Log в HandlerAUTH-15явный вызов h.audit.Log(...) обязателен
audit.Log после tx.Commit (async)AUTH-15audit внутри транзакции, до return
Audit через отдельный HTTP-запрос к audit-сервисуAUTH-15та же pgx-транзакция; outbox для дублирования
LogEntry без actor_id или occurred_atAUTH-15оба поля обязательны
slog.Info("admin cancelled order", ...) вместо audit.LogAUTH-15slog = диагностика, *_audit_log = compliance
UPDATE order_audit_log SET ...AUTH-15append-only; REVOKE UPDATE, DELETE на роль
PII в Metadata (email, phone)AUTH-16только id; PII подгружает потребитель по id
Audit только для destructive actions, не для чтения с overrideAUTH-12критичные reads тоже логируются

Куда дальше

  • ABAC: владение ресурсом — admin override как триггер audit, AccessPolicy.CheckOwnership.
  • PII и секреты — что нельзя писать в Metadata JSONB.
  • Идемпотентность — audit идемпотентен: дубликат команды = один audit-ряд.
  • JWT валидация — откуда берётся principal.Sub в actor_id.
  • Ролевая модель (RBAC) — как RequireRoles("admin") защищает группу роутов до Handler.
  • Где делается проверка — почему audit в Handler, не в chi-middleware.
  • Service-to-service — audit при s2s-командах: actor_id = client_id сервиса.
  • Хранение токенов — HttpOnly cookie в BFF: как actor_id попадает в контекст.