Опирается на правила:
AUTH-13…AUTH-14из Auth Patterns → раздел 5. Service-to-service.
Важно знать
- Два способа межсервисной аутентификации: mTLS (рекомендуется) и Client Credentials Flow.
- mTLS — двусторонний TLS через Service Mesh (Istio, Linkerd); identity закодирована в SPIFFE-сертификате.
- Client Credentials Flow —
grant_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 без Authorization | AUTH-14 | oauth2.TokenSource или mTLS |
| Анонимный inter-service трафик | AUTH-14 | zero trust |
Hard-coded client_secret в коде | AUTH-17 | envconfig из env / Vault |
Один широкий scope service на все операции | AUTH-13 | scope per operation |
| Ручное хранение и обновление токена | AUTH-13 | oauth2.ReuseTokenSource |
| mTLS только для публичных endpoint-ов | AUTH-13 | zero trust внутри кластера тоже |
client_secret в config.go как строковый литерал | AUTH-17 | envconfig:"...,required" |
| Bearer прикреплён в handler-е, не в клиенте | AUTH-14 | TokenSource в 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-командах.