Опирается на правила:
AUTH-15(аудит admin-команд),AUTH-12(admin обходит ABAC → audit обязателен) → раздел 6. Аудит admin-команд.
Важно знать
- Каждая state-changing команда от
adminобязана писать строку в*_audit_log— без исключений.- Поля:
actor_id,occurred_at,action,aggregate_id,metadataJSONB.- Реализация — явный вызов
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 в Handler | AUTH-15 | явный вызов h.audit.Log(...) обязателен |
audit.Log после tx.Commit (async) | AUTH-15 | audit внутри транзакции, до return |
| Audit через отдельный HTTP-запрос к audit-сервису | AUTH-15 | та же pgx-транзакция; outbox для дублирования |
LogEntry без actor_id или occurred_at | AUTH-15 | оба поля обязательны |
slog.Info("admin cancelled order", ...) вместо audit.Log | AUTH-15 | slog = диагностика, *_audit_log = compliance |
UPDATE order_audit_log SET ... | AUTH-15 | append-only; REVOKE UPDATE, DELETE на роль |
PII в Metadata (email, phone) | AUTH-16 | только id; PII подгружает потребитель по id |
| Audit только для destructive actions, не для чтения с override | AUTH-12 | критичные reads тоже логируются |
Куда дальше
- ABAC: владение ресурсом — admin override как триггер audit,
AccessPolicy.CheckOwnership. - PII и секреты — что нельзя писать в
MetadataJSONB. - Идемпотентность — 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попадает в контекст.