Опирается на правила:
AUTH-16,AUTH-17,AUTH-18из Auth Patterns → раздел 7. PII и секреты.
Важно знать
- PII не в slog-атрибутах (даже
slog.Debug) — ни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-18 — httperr.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-16 | slog.String("customer_id", id) или pii.MaskEmail |
PII в error.Error() (поле структуры ошибки) | AUTH-16 | только id агрегата в строке ошибки |
err.Error() в problem.detail через renderer | AUTH-18 | domErr.UserMessage или "an unexpected error occurred" |
PII-поля в Kafka-событии (customer_email, phone) | AUTH-16 | только customer_id; PII → точечный запрос |
ClientSecret: "secret123" в config.go | AUTH-17 | envconfig:"S2S_CLIENT_SECRET,required" |
DB_PASSWORD=prod-pass в файле под git | AUTH-17 | env из Kubernetes secretKeyRef / Vault |
slog.Any("customer", c) без LogValue() на агрегате | AUTH-16 | реализовать slog.LogValuer |
stackTrace или внутренний cause в detail | AUTH-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 и роли —
RequireRolesmiddleware на chi-группах. - Идемпотентность —
Idempotency-Keyна money-командах.