Files
Enterprise-MCP-Service-Bus/RI/docs/interfaces/downstream-identity.md
2026-09-14 14:01:27 -03:00

94 lines
4.6 KiB
Markdown

# Interface: downstream / outbound identity
Contrato de identidade de saída, para quem vai integrar um novo backend/domain server.
Implementação: [`src/emcp_bus/downstream/models.py`](../../src/emcp_bus/downstream/models.py),
[`downstream/identity.py`](../../src/emcp_bus/downstream/identity.py). Ver
[`AS-BUILT.md`](../AS-BUILT.md#downstream--outbound-identity-ep-07).
Princípio: `inbound token (MCP Client → Gateway) ≠ outbound token (Fabric → Backend)`. O Gateway
autentica o *caller*; este contrato resolve uma pergunta separada — qual credencial o Fabric
apresenta *ao backend* — e nunca a resolve repassando o token inbound sem modificação.
## Registro de um backend (`BackendConfig`)
Um arquivo YAML por backend em `config/backends/*.yaml`:
```yaml
# service_account (modo padrão dos backends seed)
backend: sales-domain
url: http://sales-domain:8100/mcp
audience: sales-domain-service # nunca igual ao OIDC_AUDIENCE inbound do Gateway
credential_mode: service_account
service_account_token_env_var: SALES_DOMAIN_SERVICE_TOKEN # nome da env var, nunca um valor literal
```
```yaml
# token_exchange (alternativa disponível e verificada contra Keycloak real)
backend: finance-domain
url: http://finance-domain:8101/mcp
audience: finance-domain-service
credential_mode: token_exchange
token_exchange_endpoint: http://keycloak:8080/realms/emcp/protocol/openid-connect/token
token_exchange_scope: aud-finance-domain-service
```
## Modo `service_account`
Credencial estática, lida de uma variável de ambiente (nome, nunca valor, declarado em
`service_account_token_env_var`) no momento de cada chamada. Ausência da variável em runtime →
`BackendCredentialUnavailableError`.
## Modo `token_exchange` (RFC 8693, Keycloak Standard Token Exchange)
Dois round-trips HTTP por troca nova (`TokenExchangeClient.exchange`): (1) `client_credentials`
como o próprio client de exchange do Gateway, produzindo um `subject_token`; (2) troca desse
`subject_token` por um token com a `audience` do backend alvo. Cacheado por backend até pouco
antes do próprio `expires_in` da troca — um cache hit custa zero round-trips.
**Configuração exigida no IdP (verificada empiricamente contra Keycloak 26.7.3 real):**
- O client de exchange do Gateway (`GATEWAY_TOKEN_EXCHANGE_CLIENT_ID`/`_SECRET`) precisa ter
`attributes.standard.token.exchange.enabled = "true"` e `serviceAccountsEnabled`.
- `audience` precisa ser o id de um client **realmente registrado** no realm — uma string livre
falha com `"Audience not found"`.
- Pedir `audience` sem também pedir, via `scope`, um client scope que efetivamente adiciona essa
audience falha com `"Requested audience not available"` — o parâmetro `audience` só
**restringe**, nunca **adiciona**. Por isso `token_exchange_scope` é obrigatório neste modo.
- Esse client scope precisa carregar um `oidc-audience-mapper` apontando para a `audience` do
backend, e ser um scope padrão (ou opcional solicitado) do client de exchange do Gateway.
Ver `deploy/keycloak/realm-export.json` para um exemplo real já configurado (`gateway-token-exchange`
+ client scopes `aud-sales-domain-service`/`aud-finance-domain-service`).
## Contrato de saída (`BackendCredential`)
```python
BackendCredential(
backend="finance-domain",
url="http://finance-domain:8101/mcp",
token="...",
audience="finance-domain-service",
)
```
## Como integrar um novo backend
1. Registrar o backend no IdP se usar `token_exchange` (client scope + `oidc-audience-mapper`
para a `audience` escolhida) — ou apenas definir a env var do token estático se usar
`service_account`.
2. Criar `config/backends/<backend>.yaml` (`BackendConfig`).
3. Referenciar `backend.service` == esse mesmo id em cada `CapabilityManifest` que rotear para
ele (ver [`registry-capability-manifest.md`](registry-capability-manifest.md)).
4. Se o backend for MCP-nativo (`backend.type: mcp`, o único com dispatch ao vivo nesta RI),
implementar o `BackendCredentialGate` do lado do backend (ver
`services/example_mcp_servers/sales_domain/server.py` como referência) — uma requisição sem a
credencial de saída do Gateway nunca deve alcançar um handler de tool.
## Erros
| Situação | Exceção | Tratamento pelo caller (Gateway) |
|---|---|---|
| `backend` sem `BackendConfig` registrado | `UnknownBackendError` | `UnroutableCapabilityError` no Fabric → `UNROUTABLE_CAPABILITY`. |
| Env var de `service_account` ausente em runtime | `BackendCredentialUnavailableError` | `backend_credential_unavailable` (evento NOC) → chamada negada. |
| Troca RFC 8693 rejeitada pelo IdP | `TokenExchangeError` → relançada como `BackendCredentialUnavailableError` | Idem acima. |