Опирается на правила: AUTH-13AUTH-14 из Auth Patterns → раздел 5. Service-to-service.

Важно знать

  • Два способа межсервисной аутентификации: mTLS (рекомендуется) и Client Credentials Flow.
  • mTLS — двусторонний TLS через Service Mesh (Istio, Linkerd); identity закодирована в SPIFFE-сертификате.
  • Client Credentials Flowgrant_type=client_credentials; сервис получает access_token от IdP с scope=service:operation; в Go — через clientcredentials.Config из golang.org/x/oauth2.
  • Анонимный inter-service трафик — критическое нарушение AUTH-14. Любой *http.Client без mTLS-cert или Authorization: Bearer — на ревью.
  • oauth2.ReuseTokenSource кеширует токен до expiry - delta; ручное обновление запрещено.
  • scope — по операции (service:product:read, service:inventory:reserve), не одно широкое service.
  • client_secret читается из env через envconfig; в git не попадает никогда.
  • Все outbound-клиенты живут в adapters/out/*; *http.DefaultClient без auth не используется.

Межсервисные вызовы внутри кластера кажутся безопасными — internal network, VPC. Это иллюзия: скомпрометированный pod, lateral movement, insider — реальные сценарии. UCP-правило: zero trust, каждый вызов аутентифицирован.

Способ 1: mTLS

AUTH-13: двусторонний TLS через Service Mesh.

order-service pod → istio-proxy sidecar → mTLS encrypted → istio-proxy → product-service pod
                    (cert: order-service-prod)              (verifies cert CN = order-service-prod)

Istio автоматически раздаёт каждому pod-у SPIFFE-сертификат, шифрует TLS 1.2+ и верифицирует client-cert на приёмной стороне. Go-приложение не пишет auth-код — sidecar обрабатывает mTLS до приложения.

Если sidecar недоступен (dev/test-среда без Service Mesh), mTLS можно реализовать напрямую через tls.LoadX509KeyPair:

// adapters/out/product/client.go

func NewMTLSClient(certFile, keyFile, caFile string) (*http.Client, error) {
    cert, err := tls.LoadX509KeyPair(certFile, keyFile)
    if err != nil {
        return nil, fmt.Errorf("load client cert: %w", err)
    }
    caCert, err := os.ReadFile(caFile)
    if err != nil {
        return nil, fmt.Errorf("read ca cert: %w", err)
    }
    pool := x509.NewCertPool()
    pool.AppendCertsFromPEM(caCert)

    tlsCfg := &tls.Config{
        Certificates: []tls.Certificate{cert},
        RootCAs:      pool,
        MinVersion:   tls.VersionTLS12,
    }
    return &http.Client{
        Transport: &http.Transport{TLSClientConfig: tlsCfg},
        Timeout:   10 * time.Second,
    }, nil
}

Путь до сертификатов монтируется из Secret через envconfig — никогда не хардкодится.

// cmd/app/config.go
type Config struct {
    MTLS struct {
        CertFile string `envconfig:"MTLS_CERT_FILE,required"`
        KeyFile  string `envconfig:"MTLS_KEY_FILE,required"`
        CAFile   string `envconfig:"MTLS_CA_FILE,required"`
    }
    ProductSvcURL string `envconfig:"PRODUCT_SVC_URL,required"`
}

Преимущества mTLS:

  • Identity встроена в транспорт — нечего «забыть» добавить в заголовки.
  • Rotation автоматическая — Istio обновляет сертификаты каждые 24 часа.
  • Одинаково работает для Go, Java, Python сервисов в кластере.

Недостатки:

  • Требует Service Mesh инфраструктуры.
  • В dev без Istio нужна явная TLS-конфигурация или заглушки.

Способ 2: Client Credentials Flow

AUTH-13: OAuth2-стандарт для machine-to-machine.

order-service → IdP: POST /oauth/token
                     grant_type=client_credentials
                     client_id=order-service-prod
                     client_secret=$ORDER_SVC_SECRET
                     scope=service:product:read

← IdP: { "access_token": "...", "expires_in": 3600 }

order-service → product-service:
                GET /products/abc
                Authorization: Bearer <token>

product-service: JWTValidator.Validate() → scope=service:product:read → OK

В Go — clientcredentials.Config из golang.org/x/oauth2/clientcredentials. TokenSource кеширует токен и обновляет его до истечения.

// cmd/app/wire.go
import "golang.org/x/oauth2/clientcredentials"

ccCfg := clientcredentials.Config{
    ClientID:     cfg.S2S.ClientID,
    ClientSecret: cfg.S2S.ClientSecret,   // AUTH-17: из env, не в git
    TokenURL:     cfg.S2S.TokenURL,
    Scopes:       []string{"service:product:read"},
}
tokenSrc := oauth2.ReuseTokenSource(nil, ccCfg.TokenSource(ctx))
productClient := product.NewClient(tokenSrc, cfg.ProductSvcURL)

oauth2.ReuseTokenSource добавляет кеш поверх базового TokenSource: повторный вызов .Token() возвращает кешированный токен до expiry - delta. Ручное хранение и обновление токена — запрещено.

// adapters/out/product/client.go
package product

import (
    "context"
    "fmt"
    "net/http"

    "golang.org/x/oauth2"
    "order-service/core/apperr"
)

type Product struct {
    ID    string
    Name  string
    Price int64
}

type Client struct {
    http      *http.Client
    tokenSrc  oauth2.TokenSource
    baseURL   string
}

func NewClient(tokenSrc oauth2.TokenSource, baseURL string) *Client {
    return &Client{
        http:     &http.Client{Timeout: 10 * time.Second},
        tokenSrc: tokenSrc,
        baseURL:  baseURL,
    }
}

func (c *Client) GetProduct(ctx context.Context, productID string) (Product, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        c.baseURL+"/products/"+productID, nil)
    if err != nil {
        return Product{}, fmt.Errorf("build request: %w", err)
    }

    tok, err := c.tokenSrc.Token()   // AUTH-14: Bearer обязателен
    if err != nil {
        return Product{}, &apperr.GatewayError{System: "product-svc", Op: "token", Err: err}
    }
    req.Header.Set("Authorization", "Bearer "+tok.AccessToken)

    resp, err := c.http.Do(req)
    if err != nil {
        return Product{}, &apperr.GatewayError{System: "product-svc", Op: "get-product", Err: err}
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return Product{}, &apperr.GatewayError{
            System: "product-svc",
            Op:     "get-product",
            Err:    fmt.Errorf("unexpected status %d", resp.StatusCode),
        }
    }

    var p Product
    if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
        return Product{}, fmt.Errorf("decode product: %w", err)
    }
    return p, nil
}

Scope per operation

AUTH-13: scope задаётся по операции, не одним широким значением.

// adapters/out/inventory/client.go — отдельный клиент, отдельный scope
ccInventory := clientcredentials.Config{
    ClientID:     cfg.S2S.ClientID,
    ClientSecret: cfg.S2S.ClientSecret,
    TokenURL:     cfg.S2S.TokenURL,
    Scopes:       []string{"service:inventory:reserve"},   // не "service"
}

// adapters/out/customer/client.go
ccCustomer := clientcredentials.Config{
    Scopes: []string{"service:customer:read"},
}

Разный scope → order-service может резервировать инвентарь, но не может его списывать. Blast-radius при компрометации токена ограничен операцией.

Конфигурация из env

AUTH-17: client_secret только из переменных окружения или Vault.

// cmd/app/config.go
type S2SConfig struct {
    ClientID     string `envconfig:"S2S_CLIENT_ID,required"`
    ClientSecret string `envconfig:"S2S_CLIENT_SECRET,required"`
    TokenURL     string `envconfig:"S2S_TOKEN_URL,required"`
}

envconfig возвращает ошибку при старте, если обязательная переменная отсутствует. Секрет не логируется даже при ошибке — slog не включает поля Config целиком.

Запрет анонимного трафика

AUTH-14: *http.DefaultClient или *http.Client без Authorization — критическое нарушение.

// НАРУШЕНИЕ AUTH-14 — анонимный вызов к customer-service
func (c *CustomerClient) GetCustomer(ctx context.Context, id string) (Customer, error) {
    resp, err := http.Get(c.baseURL + "/customers/" + id)   // DefaultClient без auth
    // ...
}

Скомпрометированный pod вызывает любой сервис в сети. Корректно — TokenSource как в примере выше, либо mTLS через tls.Config.

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

АнтипаттернПравилоЧто взамен
http.DefaultClient без AuthorizationAUTH-14oauth2.TokenSource или mTLS
Анонимный inter-service трафикAUTH-14zero trust
Hard-coded client_secret в кодеAUTH-17envconfig из env / Vault
Один широкий scope service на все операцииAUTH-13scope per operation
Ручное хранение и обновление токенаAUTH-13oauth2.ReuseTokenSource
mTLS только для публичных endpoint-овAUTH-13zero trust внутри кластера тоже
client_secret в config.go как строковый литералAUTH-17envconfig:"...,required"
Bearer прикреплён в handler-е, не в клиентеAUTH-14TokenSource в adapters/out/*

Куда дальше

  • JWT validation — product-service валидирует токен от order-service.
  • PII и секреты — client_secret через Vault, PII не в логах.
  • Где какая проверка — роль system для S2S-вызовов.
  • ABAC: владение ресурсом — что делает сервис после аутентификации входящего запроса.
  • Аудит admin-команд — audit.Logger для state-changing операций.
  • RBAC: роли — роль system в RequireRoles.
  • Хранение токенов — HttpOnly cookie для BFF.
  • Идемпотентность — Idempotency-Key на money-командах.