Опирается на правила: AUTH-16, AUTH-17, AUTH-18 из Auth Patterns → раздел 7. PII и секреты.

Важно знать

  • PII не в slog-атрибутах (даже slog.Debug) — ни email, ни phone, ни fullName; только customer_id.
  • PII не в error.Error() — строка ошибки попадает в логи и может уйти в detail.
  • Edge-renderer (httperr) не пишет err.Error() в detail — только предопределённое сообщение по коду через sanitize.
  • Kafka-событие содержит только id — PII подгружает потребитель точечным запросом к source-сервису.
  • Секреты (client_secret, пароль БД, ключи) — только из env через envconfig, никогда в коде или git.
  • Структурный тип ошибки хранит только id агрегата, не его PII-поля.
  • slog.String("email", email) — прямая утечка; правильная форма — slog.String("customer_id", id).
  • Если secret оказался в git-истории — ротировать немедленно, даже после git reset.

PII (Personally Identifiable Information) — email, телефон, ФИО, адрес, паспорт, IP — это данные, которые регулирующие органы считают защищаемыми. Утечка через лог, через response, через Kafka — инцидент соответствия требованиям. В Go стек риска формируется в трёх точках: slog-атрибуты, строки ошибок (error.Error()), и edge-renderer (httperr). Правила AUTH-16..18 закрывают все три.

PII не в slog-атрибутах

AUTH-16 — запрет на всех уровнях логирования.

// adapters/in/http/handler/create_order.go

// ПЛОХО — PII напрямую в атрибуте
slog.InfoContext(ctx, "order created",
    slog.String("customer_email", cmd.CustomerEmail),   // ← утечка
    slog.String("order_id", order.ID),
)

// ХОРОШО — только id
slog.InfoContext(ctx, "order created",
    slog.String("customer_id", order.CustomerID),
    slog.String("order_id", order.ID),
)

Если нужен email в диагностических логах — маскирование, не raw-значение:

// core/pii/mask.go
package pii

import "strings"

func MaskEmail(email string) string {
    at := strings.IndexByte(email, '@')
    if at < 1 {
        return "***"
    }
    return string(email[0]) + "***@" + email[at+1:]
}

func MaskPhone(phone string) string {
    if len(phone) < 4 {
        return "***"
    }
    return "***" + phone[len(phone)-4:]
}
slog.DebugContext(ctx, "email verification sent",
    slog.String("customer_id", customer.ID),
    slog.String("email_mask", pii.MaskEmail(customer.Email)),   // u***@example.com
)

MaskEmail / MaskPhone живут в core/pii/ — доступны всем слоям без import-цикла.

Отдельная опасность — строковой метод агрегата:

// core/customer/customer.go

type Customer struct {
    ID       string
    Email    string
    Phone    string
    FullName string
}

// String намеренно не включает PII — попадёт в slog при %v
func (c Customer) String() string {
    return "Customer{id=" + c.ID + "}"
}

Если Customer передать в slog.Any("customer", c) и у него нет LogValue(), slog вызовет fmt.Sprintf("%+v", c) — все поля утекут. Реализуйте slog.LogValuer:

func (c Customer) LogValue() slog.Value {
    return slog.GroupValue(
        slog.String("id", c.ID),
    )
}

PII не в error.Error()

AUTH-16 + AUTH-18: строка ошибки может уйти в лог, в err.Error() вызывающего, и в detail через sanitize-нарушение.

// core/customer/errors.go

// ПЛОХО — PII в строке ошибки
type CustomerNotFoundError struct {
    Email string
}
func (e *CustomerNotFoundError) Error() string {
    return fmt.Sprintf("customer not found: email=%s", e.Email)   // ← утечка
}

// ХОРОШО — только id агрегата
type CustomerNotFoundError struct {
    CustomerID string
}
func (e *CustomerNotFoundError) Error() string {
    return fmt.Sprintf("customer not found: id=%s", e.CustomerID)
}

Это же правило на типы ошибок домена заказов:

// core/order/errors.go

type OrderNotFoundError struct {
    OrderID string   // OK — id, не PII
}
func (e *OrderNotFoundError) Error() string {
    return fmt.Sprintf("order not found: id=%s", e.OrderID)
}

type OrderNotCancellableError struct {
    OrderID string
    Status  string
}
func (e *OrderNotCancellableError) Error() string {
    return fmt.Sprintf("order %s cannot be cancelled in status %s", e.OrderID, e.Status)
}

OrderID, Status — не PII; PII — поля заказчика. Структура ошибки никогда не хранит customerEmail, customerPhone.

Edge-renderer: sanitize вместо err.Error()

AUTH-18httperr.Write не пишет err.Error() в detail напрямую.

// adapters/in/http/httperr/render.go
package httperr

import (
    "errors"
    "net/http"

    "github.com/org/svc/core/apperr"
)

type problemDetail struct {
    Type   string `json:"type"`
    Title  string `json:"title"`
    Status int    `json:"status"`
    Detail string `json:"detail,omitempty"`
    Code   string `json:"code,omitempty"`
}

func Write(w http.ResponseWriter, r *http.Request, err error) {
    pd := toProblem(err)
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(pd.Status)
    _ = json.NewEncoder(w).Encode(pd)
}

func toProblem(err error) problemDetail {
    var authErr *apperr.AuthError
    if errors.As(err, &authErr) {
        return problemDetail{
            Type:   "urn:auth:unauthenticated",
            Title:  "Authentication required",
            Status: http.StatusUnauthorized,
        }
    }
    var forbErr *apperr.ForbiddenError
    if errors.As(err, &forbErr) {
        return problemDetail{
            Type:   "urn:auth:forbidden",
            Title:  "Access denied",
            Status: http.StatusForbidden,
        }
    }
    var domErr *apperr.DomainError
    if errors.As(err, &domErr) {
        return problemDetail{
            Type:   "urn:domain:" + domErr.Code,
            Title:  "Domain rule violated",
            Status: http.StatusUnprocessableEntity,
            Detail: domErr.UserMessage,   // AUTH-18: заранее заданное, не err.Error()
            Code:   domErr.Code,
        }
    }
    // AUTH-18: технические ошибки — только общая фраза + traceId
    return problemDetail{
        Type:   "urn:internal",
        Title:  "Internal server error",
        Status: http.StatusInternalServerError,
        Detail: sanitize(err),
    }
}

func sanitize(err error) string {
    // AUTH-18: не раскрываем внутренние детали
    return "an unexpected error occurred"
}

DomainError.UserMessage — поле, которое пишет разработчик при объявлении ошибки, не производная от err.Error():

// core/apperr/domain.go

type DomainError struct {
    Code        string
    UserMessage string
}
func (e *DomainError) Error() string { return "domain: " + e.Code }

// core/order/errors.go

var ErrOrderNotFound = &apperr.DomainError{
    Code:        "ORDER_NOT_FOUND",
    UserMessage: "Order with given id not found",
}
var ErrOrderNotCancellable = &apperr.DomainError{
    Code:        "ORDER_NOT_CANCELLABLE",
    UserMessage: "Order in current status cannot be cancelled",
}

PII не в Kafka-событиях

AUTH-16: Kafka — широковещательный канал; все зарегистрированные consumer-группы получат payload.

// adapters/out/kafka/event/order_confirmed.go

// ПЛОХО — PII в событии
type OrderConfirmedEvent struct {
    OrderID         string  `json:"order_id"`
    CustomerEmail   string  `json:"customer_email"`   // ← все consumers видят
    CustomerPhone   string  `json:"customer_phone"`   // ← утечка
    TotalAmountKop  int64   `json:"total_amount_kop"`
}

// ХОРОШО — только id; PII подгружает тот, кому нужно
type OrderConfirmedEvent struct {
    OrderID        string `json:"order_id"`
    CustomerID     string `json:"customer_id"`
    TotalAmountKop int64  `json:"total_amount_kop"`
}

Notification-сервис, которому нужен email для отправки письма, делает точечный запрос:

// (в notification-сервисе) adapters/out/customer/client.go

func (c *Client) GetContactInfo(ctx context.Context, customerID string) (ContactInfo, error) {
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
        c.baseURL+"/customers/"+customerID+"/contact", nil)
    // AUTH-14: Bearer из Client Credentials
    tok, _ := c.tokenSrc.Token()
    req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
    // ... decode ContactInfo{Email, Phone}
}

Customer-сервис логирует этот вызов и пишет в свой audit — точечный доступ к PII виден и управляем.

Секреты не в git

AUTH-17: секреты читаются через github.com/kelseyhightower/envconfig из env / Vault; ни в config.go, ни в *.yml-файлах под git.

// cmd/app/config.go
package main

import "github.com/kelseyhightower/envconfig"

type Config struct {
    DB struct {
        DSN      string `envconfig:"DB_DSN,required"`
        Password string `envconfig:"DB_PASSWORD,required"`
    }
    S2S struct {
        ClientID     string `envconfig:"S2S_CLIENT_ID,required"`
        ClientSecret string `envconfig:"S2S_CLIENT_SECRET,required"`   // AUTH-17
        TokenURL     string `envconfig:"S2S_TOKEN_URL,required"`
    }
    JWT struct {
        JWKSUri  string `envconfig:"JWKS_URI,required"`
        Issuer   string `envconfig:"JWT_ISSUER,required"`
        Audience string `envconfig:"JWT_AUDIENCE,required"`
    }
}

func loadConfig() (Config, error) {
    var cfg Config
    if err := envconfig.Process("", &cfg); err != nil {
        return Config{}, fmt.Errorf("config: %w", err)
    }
    return cfg, nil
}

.gitignore — минимальный набор:

.env
.env.local
*.pem
*.key
*-secret.yml
application-prod.yml

Если secret оказался в истории — ротировать немедленно. git reset или git filter-repo не убирает значение из fork'ов, CI-кэша и у атакующего, который уже клонировал репозиторий.

Для локальной разработки — .env файл (в .gitignore) + godotenv при старте:

// cmd/app/main.go — только в dev-режиме

import "github.com/joho/godotenv"

func main() {
    _ = godotenv.Load()   // молча игнорирует отсутствие .env в prod
    cfg, err := loadConfig()
    // ...
}

В prod — переменные окружения через Kubernetes secretKeyRef или Vault Agent Injector.

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

АнтипаттернПравилоЧто взамен
slog.String("email", email) в любом слоеAUTH-16slog.String("customer_id", id) или pii.MaskEmail
PII в error.Error() (поле структуры ошибки)AUTH-16только id агрегата в строке ошибки
err.Error() в problem.detail через rendererAUTH-18domErr.UserMessage или "an unexpected error occurred"
PII-поля в Kafka-событии (customer_email, phone)AUTH-16только customer_id; PII → точечный запрос
ClientSecret: "secret123" в config.goAUTH-17envconfig:"S2S_CLIENT_SECRET,required"
DB_PASSWORD=prod-pass в файле под gitAUTH-17env из Kubernetes secretKeyRef / Vault
slog.Any("customer", c) без LogValue() на агрегатеAUTH-16реализовать slog.LogValuer
stackTrace или внутренний cause в detailAUTH-18только traceId для cross-ref

Куда дальше

  • Аудит admin-команд — как писать в *_audit_log без PII в plain-полях.
  • JWT validation — AuthError и маппинг на 401 без раскрытия причины.
  • Владение ресурсом (ABAC) — ForbiddenError без PII в строке.
  • Service-to-service — oauth2.TokenSource и mTLS; AUTH-14.
  • Хранение токенов на клиенте — HttpOnly cookie, rotation.
  • Где живёт проверка — разграничение gateway / BFF / domain-handler.
  • RBAC и роли — RequireRoles middleware на chi-группах.
  • Идемпотентность — Idempotency-Key на money-командах.