Когда order-service обращается к payment-service внутри кластера, кажется, что это безопасно: закрытая сеть, VPC, никто посторонний не зайдёт. Но если один контейнер окажется скомпрометирован через уязвимость в зависимости — атакующий сможет ходить на все соседние сервисы без ограничений, потому что они его «не спрашивают».
Решение — каждый вызов между сервисами должен нести доказательство личности вызывающего. Есть два способа организовать это в Python: mTLS через Service Mesh или Client Credentials Flow с токеном от IdP.
Способ 1: mTLS через Service Mesh
mTLS расшифровывается как mutual TLS — двусторонняя TLS-аутентификация. Обычный HTTPS проверяет только сервер (его сертификат). mTLS проверяет обе стороны: и сервер, и клиент предъявляют сертификаты.
В Kubernetes это реализует Service Mesh — Istio или Linkerd. Каждому поду автоматически выдаётся уникальный сертификат (стандарт SPIFFE). Sidecar-контейнер рядом с вашим приложением перехватывает весь входящий и исходящий трафик, шифрует его и проверяет сертификаты.
Главное преимущество: ваш Python-код не пишет ни строчки для аутентификации. Всё происходит на уровне транспорта, до приложения.
order-service → [istio-proxy sidecar] → зашифрованный mTLS → [istio-proxy] → payment-service
cert: order-service-prod проверяет cert
# adapters/out/http/payment_client.py
import httpx
from app.config import settings
class PaymentClient:
def __init__(self) -> None:
self._client = httpx.AsyncClient(
base_url=settings.payment_service_url,
)
async def charge(self, order_id: str, amount_cents: int) -> dict:
resp = await self._client.post(
"/charge",
json={"order_id": order_id, "amount_cents": amount_cents},
)
resp.raise_for_status()
return resp.json()
Никаких заголовков Authorization добавлять не нужно — sidecar делает это за вас. Принимающий сервис получает идентификатор вызывающего через заголовок X-Forwarded-Client-Cert (Istio) или через политику PeerAuthentication.
Istio автоматически обновляет сертификаты каждые 24 часа — ротация бесплатная. И способ одинаково работает для Java-, Go- и Python-сервисов в одном кластере.
Единственный минус: нужна инфраструктура Service Mesh. В локальной разработке или в окружении без Istio/Linkerd — нужен второй способ.
Способ 2: Client Credentials Flow с authlib
Это стандартный OAuth2-поток для машинных клиентов. Сервис запрашивает у IdP (например, Keycloak) свой собственный access_token — не токен пользователя, а именно машинный — и прикладывает его к каждому запросу.
Схема выглядит так:
order-service → POST /realms/main/protocol/openid-connect/token
grant_type=client_credentials
client_id=order-service-prod
client_secret=$ORDER_SERVICE_CLIENT_SECRET
scope=payment:charge
← IdP: { "access_token": "...", "expires_in": 3600 }
order-service → POST payment-service/charge
Authorization: Bearer <token>
Секреты через pydantic-settings
client_secret нельзя писать прямо в коде. Читаем из переменных окружения через pydantic-settings:
# config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
payment_service_url: str
idp_token_url: str
payment_client_id: str
payment_client_secret: str
model_config = {"env_file": ".env"}
settings = Settings()
OAuth2-клиент с authlib
authlib предоставляет AsyncOAuth2Client — он сам кеширует токен, следит за сроком его жизни и при необходимости запрашивает новый. Вручную ничего реализовывать не нужно:
# adapters/out/http/payment_client.py
from authlib.integrations.httpx_client import AsyncOAuth2Client
from app.config import settings
class PaymentClient:
def __init__(self) -> None:
self._client = AsyncOAuth2Client(
client_id=settings.payment_client_id,
client_secret=settings.payment_client_secret,
token_endpoint=settings.idp_token_url,
grant_type="client_credentials",
scope="payment:charge",
)
async def charge(self, order_id: str, amount_cents: int) -> dict:
await self._client.ensure_active_token()
resp = await self._client.post(
f"{settings.payment_service_url}/charge",
json={"order_id": order_id, "amount_cents": amount_cents},
)
resp.raise_for_status()
return resp.json()
ensure_active_token() проверяет, что токен не просрочен. Если просрочен — запрашивает новый у IdP. Ручное кеширование и refresh не нужны.
Отдельный scope на каждую операцию
Частая ошибка — один токен с широким scope на все операции. Если этот токен утечёт, атакующий получает доступ ко всему. Правильнее — scope per operation:
# adapters/out/http/payment_charge_client.py
class PaymentChargeClient:
def __init__(self) -> None:
self._client = AsyncOAuth2Client(
client_id=settings.payment_client_id,
client_secret=settings.payment_client_secret,
token_endpoint=settings.idp_token_url,
grant_type="client_credentials",
scope="payment:charge", # только charge, не refund
)
# adapters/out/http/inventory_client.py
class InventoryClient:
def __init__(self) -> None:
self._client = AsyncOAuth2Client(
client_id=settings.inventory_client_id,
client_secret=settings.inventory_client_secret,
token_endpoint=settings.idp_token_url,
grant_type="client_credentials",
scope="inventory:reserve", # только reserve
)
Это называется blast-radius containment: если что-то пойдёт не так с одним клиентом, ущерб ограничен только его операциями.
Проверка на принимающей стороне
payment-service получает запрос с токеном и должен понять, что это машинный вызов (не пользователь). Роль system маппится из scope JWT-токена:
def _extract_roles(claims: dict) -> list[str]:
scope = claims.get("scope", "")
if "payment:charge" in scope.split():
return ["system"]
realm = claims.get("realm_access", {})
return realm.get("roles", [])
Endpoint объявляет требуемую роль явно:
# adapters/in/http/payment_router.py
from fastapi import APIRouter, Depends
from adapters.in.http.security import Principal, require_roles
from application.use_cases.charge_payment import ChargePaymentUseCase
router = APIRouter(prefix="/charge")
@router.post("/", status_code=200)
async def charge(
body: ChargeRequest,
principal: Principal = Depends(require_roles("system")),
use_case: ChargePaymentUseCase = Depends(),
) -> ChargeResponse:
return await use_case.execute(body, principal)
Анонимный трафик — частая ошибка
Вот как выглядит проблемный код:
# Неправильно — любой pod в кластере вызовет без ограничений
class ProductClient:
def __init__(self) -> None:
self._client = httpx.AsyncClient(base_url="http://product-service")
async def get_product(self, product_id: str) -> dict:
resp = await self._client.get(f"/products/{product_id}")
resp.raise_for_status()
return resp.json()
Один скомпрометированный контейнер — и атакующий перемещается по всему кластеру. Правильная версия — либо mTLS (sidecar добавляет аутентификацию), либо AsyncOAuth2Client:
# adapters/out/http/product_client.py
from authlib.integrations.httpx_client import AsyncOAuth2Client
from app.config import settings
class ProductClient:
def __init__(self) -> None:
self._client = AsyncOAuth2Client(
client_id=settings.product_client_id,
client_secret=settings.product_client_secret,
token_endpoint=settings.idp_token_url,
grant_type="client_credentials",
scope="product:read",
)
async def get_product(self, product_id: str) -> dict:
await self._client.ensure_active_token()
resp = await self._client.get(
f"{settings.product_service_url}/products/{product_id}"
)
resp.raise_for_status()
return resp.json()
Регистрация клиентов как синглтонов
Клиенты регистрируются в lifespan, а не создаются на каждый запрос. Это позволяет переиспользовать HTTP-соединения и не терять кеш токена:
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from adapters.out.http.payment_client import PaymentClient
from adapters.out.http.inventory_client import InventoryClient
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.payment_client = PaymentClient()
app.state.inventory_client = InventoryClient()
yield
await app.state.payment_client._client.aclose()
await app.state.inventory_client._client.aclose()
app = FastAPI(lifespan=lifespan)
# adapters/in/http/orders_router.py
from fastapi import APIRouter, Depends, Request
router = APIRouter(prefix="/orders")
def get_payment_client(request: Request) -> PaymentClient:
return request.app.state.payment_client
@router.post("/", status_code=201)
async def create_order(
body: CreateOrderRequest,
principal: Principal = Depends(require_roles("customer", "admin")),
payment_client: PaymentClient = Depends(get_payment_client),
use_case: CreateOrderUseCase = Depends(),
) -> OrderResponse:
return await use_case.execute(body, principal, payment_client)
Коротко
- Внутри кластера тоже нужна аутентификация — изолированная сеть не защищает от скомпрометированного контейнера.
- Два способа: mTLS через Service Mesh (код ничего не делает — sidecar берёт на себя) или Client Credentials Flow (сервис получает машинный токен от IdP).
- В Client Credentials Flow используй
authlib.AsyncOAuth2Client— он сам кеширует токен и обновляет его. Ручной refresh не нужен. - Секрет (
client_secret) — только через переменные окружения,pydantic-settings. Не в код, не в git. - Один клиент — один scope per operation (
payment:charge, неpayment:*). Это ограничивает ущерб при компрометации. - Клиенты живут как синглтоны в
app.stateчерезlifespan— чтобы переиспользовать соединения и кеш токена. - Принимающий сервис маппит scope из JWT в роль
systemи явно объявляет её вrequire_roles.
Что почитать дальше
- JWT validation в FastAPI — как принимающий сервис валидирует токен.
- RBAC: маппинг ролей — как scope превращается в роль
system. - PII и секреты — хранение
client_secretчерез env и Vault. - Идемпотентность —
Idempotency-Keyдля денежных межсервисных команд.