M3 implementada e backlog atualizado

This commit is contained in:
Marcos Lohmann
2026-09-14 10:11:29 -03:00
parent f3efcc3414
commit c894c1604b
65 changed files with 4261 additions and 74 deletions

View File

@@ -6,16 +6,23 @@
The Gateway authenticates the *caller*; this module answers a completely
separate question -- which credential the Fabric presents *to the backend*
-- and never answers it by handing the inbound token through unmodified
(README.md §35.6, "Token passthrough indiscriminado"). `service_account`
mode resolves a static, backend-specific credential from an env var at call
time; `token_exchange` (RFC 8693) is a documented, unimplemented extension
(see `BackendConfig`).
(README.md §35.6, "Token passthrough indiscriminado"). Two modes:
`service_account` resolves a static, backend-specific credential from an env
var at call time; `token_exchange` (RFC 8693 via Keycloak's Standard Token
Exchange) is implemented by `TokenExchangeClient` below -- verified
end-to-end against a real Keycloak 26.7.3 instance, 2026-09-14 (see
`downstream/models.py`'s module docstring for the exact IdP-side setup this
requires: the Gateway's own exchange client, a per-backend audience-mapper
client scope).
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
import httpx
from pydantic import BaseModel
from emcp_bus.downstream.models import BackendConfig
@@ -26,14 +33,23 @@ class UnknownBackendError(Exception):
class BackendCredentialUnavailableError(Exception):
"""The configured env var for a backend's service-account token is unset.
"""The configured credential source for a backend could not produce a token
at call time -- a missing env var (`service_account` mode) or a failed
RFC 8693 exchange (`token_exchange` mode).
Distinct from `UnknownBackendError`: the backend *is* configured, but
its credential is missing at runtime -- callers (the Gateway) must
treat this as DENY, not as "fall back to no credential" (README.md §38).
Distinct from `UnknownBackendError`: the backend *is* configured, but its
credential is unavailable right now -- callers (the Gateway) must treat
this as DENY, not as "fall back to no credential" (README.md §38).
"""
class TokenExchangeError(Exception):
"""The IdP rejected a `client_credentials` or RFC 8693 exchange call.
Caught and re-raised as `BackendCredentialUnavailableError` by
`BackendCredentialProvider` -- this type exists so `TokenExchangeClient`
itself stays IdP-shaped, not Gateway-failure-policy-shaped."""
class BackendCredential(BaseModel):
backend: str
url: str
@@ -41,21 +57,125 @@ class BackendCredential(BaseModel):
audience: str
class BackendCredentialProvider:
def __init__(self, backend_configs: list[BackendConfig]) -> None:
self._configs = {config.backend: config for config in backend_configs}
@dataclass
class _CachedExchange:
token: str
expires_at: float
def credential_for(self, backend: str) -> BackendCredential:
class TokenExchangeClient:
"""RFC 8693 token exchange against an OIDC IdP's token endpoint (Keycloak
26.2+'s Standard Token Exchange, GA -- no preview feature flag needed).
Two HTTP round trips per fresh exchange: (1) `client_credentials` as the
Gateway's own exchange client, producing a subject_token; (2) exchanging
that subject_token for one scoped to the target backend's `audience`.
Verified empirically against a real Keycloak instance that step 2 fails
with "Requested audience not available" unless the request also carries
a `scope` that actually adds that audience to the token (the `audience`
parameter alone only *restricts*, never *adds*) -- hence
`BackendConfig.token_exchange_scope`.
Exchanged tokens are cached per backend until shortly before their own
`expires_in` (RFC 8693 exchanged tokens are commonly short-lived, and
re-exchanging on every single `tools/call` would be both wasteful and
slow); a cache hit costs zero HTTP round trips.
"""
def __init__(
self,
client_id: str,
client_secret: str,
*,
http_client: httpx.AsyncClient | None = None,
expiry_margin_seconds: float = 5.0,
) -> None:
self._client_id = client_id
self._client_secret = client_secret
self._http = http_client or httpx.AsyncClient()
self._expiry_margin_seconds = expiry_margin_seconds
self._cache: dict[str, _CachedExchange] = {}
async def aclose(self) -> None:
await self._http.aclose()
async def exchange(
self, *, token_endpoint: str, backend: str, audience: str, scope: str
) -> str:
now = time.monotonic()
cached = self._cache.get(backend)
if cached is not None and cached.expires_at > now:
return cached.token
subject_token = await self._client_credentials(token_endpoint)
token, expires_in = await self._exchange(token_endpoint, subject_token, audience, scope)
self._cache[backend] = _CachedExchange(
token=token, expires_at=now + max(expires_in - self._expiry_margin_seconds, 0.0)
)
return token
async def _client_credentials(self, token_endpoint: str) -> str:
try:
response = await self._http.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": self._client_id,
"client_secret": self._client_secret,
},
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise TokenExchangeError(
f"Failed to obtain the exchange client's own subject token from "
f"{token_endpoint}: {exc}"
) from exc
access_token: str = response.json()["access_token"]
return access_token
async def _exchange(
self, token_endpoint: str, subject_token: str, audience: str, scope: str
) -> tuple[str, int]:
try:
response = await self._http.post(
token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": subject_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": audience,
"scope": scope,
"client_id": self._client_id,
"client_secret": self._client_secret,
},
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise TokenExchangeError(
f"RFC 8693 token exchange for audience {audience!r} (scope {scope!r}) "
f"at {token_endpoint} failed: {exc}"
) from exc
body = response.json()
return body["access_token"], int(body.get("expires_in", 60))
class BackendCredentialProvider:
def __init__(
self,
backend_configs: list[BackendConfig],
*,
token_exchange_client: TokenExchangeClient | None = None,
) -> None:
self._configs = {config.backend: config for config in backend_configs}
self._token_exchange_client = token_exchange_client
async def credential_for(self, backend: str) -> BackendCredential:
config = self._configs.get(backend)
if config is None:
raise UnknownBackendError(backend)
if config.credential_mode == "token_exchange":
raise NotImplementedError(
f"credential_mode='token_exchange' for backend {backend!r} is a documented "
"extension point (RFC 8693 via an OIDC IdP), not implemented in this RI -- "
"see BackendConfig docstring."
)
return await self._credential_via_token_exchange(backend, config)
assert config.service_account_token_env_var is not None # enforced by BackendConfig
token = os.environ.get(config.service_account_token_env_var)
@@ -68,6 +188,30 @@ class BackendCredentialProvider:
backend=backend, url=config.url, token=token, audience=config.audience
)
async def _credential_via_token_exchange(
self, backend: str, config: BackendConfig
) -> BackendCredential:
if self._token_exchange_client is None:
raise BackendCredentialUnavailableError(
f"Backend {backend!r} is configured for credential_mode='token_exchange' but "
"no TokenExchangeClient is available (GATEWAY_TOKEN_EXCHANGE_CLIENT_ID/"
"_SECRET unset) -- see emcp_bus.downstream.identity module docstring."
)
assert config.token_exchange_endpoint is not None # enforced by BackendConfig
assert config.token_exchange_scope is not None # enforced by BackendConfig
try:
token = await self._token_exchange_client.exchange(
token_endpoint=config.token_exchange_endpoint,
backend=backend,
audience=config.audience,
scope=config.token_exchange_scope,
)
except TokenExchangeError as exc:
raise BackendCredentialUnavailableError(str(exc)) from exc
return BackendCredential(
backend=backend, url=config.url, token=token, audience=config.audience
)
def url_for(self, backend: str) -> str:
config = self._configs.get(backend)
if config is None:

View File

@@ -1,11 +1,19 @@
"""Backend credential config schema (EP-07-T02, README.md §20).
One entry per backend the Fabric calls, declaring *how* the Gateway obtains
an outbound credential for it -- never the credential's value. The RI
implements `service_account` (a static credential resolved from an env var
at call time); `token_exchange` (RFC 8693, e.g. via Keycloak) is modeled as
a distinct, documented mode but not implemented in this milestone -- see
`emcp_bus.downstream.identity.BackendCredentialProvider`.
an outbound credential for it -- never the credential's value. Two modes:
`service_account` (a static credential resolved from an env var at call
time) and `token_exchange` (RFC 8693 via Keycloak's Standard Token Exchange,
GA since Keycloak 26.2 -- verified against a real Keycloak 26.7.3 instance,
2026-09-14: see `emcp_bus.downstream.identity.TokenExchangeClient`). Setting
up `token_exchange` mode for a backend requires, on the real IdP: the
Gateway's own exchange client has `attributes.standard.token.exchange.enabled
= "true"` and is `serviceAccountsEnabled`; a client scope named
`token_exchange_scope` exists with an `oidc-audience-mapper` targeting this
backend's `audience` (verified empirically -- requesting `audience` alone,
without also requesting a scope that actually adds it, fails with "Requested
audience not available"); that scope is a default (or requested optional)
scope of the Gateway's exchange client.
"""
from __future__ import annotations
@@ -42,8 +50,13 @@ class BackendConfig(BaseModel):
static credential. Required when `credential_mode == "service_account"`."""
token_exchange_endpoint: str | None = None
"""RFC 8693 token endpoint URL. Documented extension point, not yet
implemented -- see module docstring."""
"""RFC 8693 token endpoint URL, e.g.
`http://keycloak:8080/realms/emcp/protocol/openid-connect/token`."""
token_exchange_scope: str | None = None
"""Name of the Keycloak client scope (carrying an `oidc-audience-mapper`
for this backend's `audience`) to request alongside the RFC 8693
`audience` parameter -- required, see module docstring."""
@field_validator("service_account_token_env_var")
@classmethod
@@ -66,4 +79,8 @@ class BackendConfig(BaseModel):
raise ValueError(
"token_exchange_endpoint is required when credential_mode == 'token_exchange'"
)
if self.credential_mode == "token_exchange" and not self.token_exchange_scope:
raise ValueError(
"token_exchange_scope is required when credential_mode == 'token_exchange'"
)
return self