mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M0 e M1 revisados e fixes implementados
This commit is contained in:
52
.github/workflows/ci.yml
vendored
Normal file
52
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: RI CI
|
||||||
|
|
||||||
|
# EP-00-T03 (docs/RI-PLANNING.md): lint, type-check, validate every config/
|
||||||
|
# YAML against its JSON Schema/Pydantic model, then run the test suite.
|
||||||
|
# Acceptance criterion: a YAML under config/ that violates its schema must
|
||||||
|
# fail CI before any integration test runs -- `validate-schemas` is
|
||||||
|
# deliberately its own early step, ahead of `test`, not folded into it.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: RI
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install OPA (static binary, no Docker needed -- PDP for real e2e tests)
|
||||||
|
run: |
|
||||||
|
curl -sL -o /usr/local/bin/opa "https://openpolicyagent.org/downloads/v1.20.2/opa_linux_amd64_static"
|
||||||
|
chmod +x /usr/local/bin/opa
|
||||||
|
opa version
|
||||||
|
|
||||||
|
- name: Install project (editable, dev extras)
|
||||||
|
run: make install
|
||||||
|
|
||||||
|
- name: Lint (ruff)
|
||||||
|
run: make lint
|
||||||
|
|
||||||
|
- name: Type-check (mypy --strict)
|
||||||
|
run: make typecheck
|
||||||
|
|
||||||
|
- name: Validate config/ YAML against its schema
|
||||||
|
run: make validate-schemas
|
||||||
|
|
||||||
|
- name: Schemas/*.schema.json are up to date with the Pydantic models
|
||||||
|
run: |
|
||||||
|
make export-schemas
|
||||||
|
git diff --exit-code -- schemas/
|
||||||
|
|
||||||
|
- name: Test suite (real OPA/JWKS/Docker/Keycloak; --forked, see RI/README.md)
|
||||||
|
run: .venv/bin/python3 -m pytest -q --forked
|
||||||
@@ -12,8 +12,53 @@ SALES_DOMAIN_PORT=8100
|
|||||||
# use 127.0.0.1 for `make dev`, which runs both processes on the host).
|
# use 127.0.0.1 for `make dev`, which runs both processes on the host).
|
||||||
SALES_DOMAIN_URL=http://sales-domain:8100/mcp
|
SALES_DOMAIN_URL=http://sales-domain:8100/mcp
|
||||||
|
|
||||||
|
# --- Example Finance-domain MCP server (EP-06-T03) ---
|
||||||
|
FINANCE_DOMAIN_HOST=0.0.0.0
|
||||||
|
FINANCE_DOMAIN_PORT=8101
|
||||||
|
FINANCE_DOMAIN_URL=http://finance-domain:8101/mcp
|
||||||
|
|
||||||
# --- Observability (EP-08) ---
|
# --- Observability (EP-08) ---
|
||||||
# Unset (default): traces print to stdout via ConsoleSpanExporter.
|
# Unset (default): traces print to stdout via ConsoleSpanExporter.
|
||||||
# Set to enable OTLP/HTTP export instead (requires the `emcp-bus[otlp]` extra
|
# Set to "http://otel-collector:4318" and run with `--profile observability`
|
||||||
# and a running collector -- see EP-08-T04).
|
# to export real OTLP traces to the local otel-collector + Jaeger
|
||||||
|
# (EP-08-T04; UI at http://localhost:16686). Requires the `emcp-bus[otlp]`
|
||||||
|
# extra (already installed in the Docker image, see deploy/docker/Dockerfile).
|
||||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
||||||
|
|
||||||
|
# Where the Gateway and example domain servers append audit events
|
||||||
|
# (EP-08-T02) -- JSONL, append-only, never contains raw token/secret values.
|
||||||
|
EMCP_AUDIT_LOG_PATH=/tmp/emcp-audit.jsonl
|
||||||
|
|
||||||
|
# TTL (milliseconds) the Gateway hints clients to cache a `tools/list`
|
||||||
|
# response for (EP-05-T03, SEP-2549) -- always `cacheScope: "private"`,
|
||||||
|
# never configurable to "public" (README.md §45 Teste 5).
|
||||||
|
# EMCP_TOOLS_LIST_CACHE_TTL_MS=5000
|
||||||
|
|
||||||
|
# --- PDP (EP-03) ---
|
||||||
|
# Required: the Gateway fails closed at startup if this is unreachable
|
||||||
|
# (README.md §38, EP-05-T06) -- there is no "run without a PDP" mode.
|
||||||
|
OPA_URL=http://opa:8181
|
||||||
|
|
||||||
|
# --- Identity / OIDC (EP-01-T01/T03) ---
|
||||||
|
# Unset (default): no OIDC provider configured, every request is
|
||||||
|
# unauthenticated -- deny-by-default falls back to the Gateway's own
|
||||||
|
# handler-level checks instead of the SDK's RequireAuthMiddleware (see
|
||||||
|
# emcp_bus/gateway/server.py's module docstring). Uncomment all three to
|
||||||
|
# enable real bearer-token auth against the `keycloak` compose service
|
||||||
|
# (profile `identity`; realm imported from deploy/keycloak/realm-export.json
|
||||||
|
# -- clients sales-read-agent/sales-write-agent/finance-payments-agent,
|
||||||
|
# secrets `dev-<client-id>-secret`, matching config/clients/*.yaml). Run with
|
||||||
|
# `docker compose --profile core --profile identity up`. Any OIDC-compliant
|
||||||
|
# IdP works here, not just Keycloak (EP-01-T03 is IdP-agnostic).
|
||||||
|
# OIDC_JWKS_URL=http://keycloak:8080/realms/emcp/protocol/openid-connect/certs
|
||||||
|
# OIDC_ISSUER=http://keycloak:8080/realms/emcp
|
||||||
|
# OIDC_AUDIENCE=emcp-gateway
|
||||||
|
|
||||||
|
# --- Downstream / outbound identity (EP-07) ---
|
||||||
|
# Never the inbound MCP Client's token -- see config/backends/*.yaml and
|
||||||
|
# src/emcp_bus/downstream/identity.py. Also the credential the domain
|
||||||
|
# servers themselves require of every inbound request when run via `main()`
|
||||||
|
# (EP-05-T07's `BackendCredentialGate`) -- a request lacking it never
|
||||||
|
# reaches a tool handler, whether or not it also came through the Gateway.
|
||||||
|
SALES_DOMAIN_SERVICE_TOKEN=dev-sales-domain-service-token
|
||||||
|
FINANCE_DOMAIN_SERVICE_TOKEN=dev-finance-domain-service-token
|
||||||
|
|||||||
19
RI/Makefile
19
RI/Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: venv install lint format typecheck test dev up down logs validate-schemas
|
.PHONY: venv install lint format typecheck test dev up down logs validate-schemas export-schemas
|
||||||
|
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
PYTHON := $(VENV)/bin/python3
|
PYTHON := $(VENV)/bin/python3
|
||||||
@@ -19,16 +19,21 @@ format:
|
|||||||
$(VENV)/bin/ruff check --fix .
|
$(VENV)/bin/ruff check --fix .
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
$(VENV)/bin/mypy src services tests
|
$(VENV)/bin/mypy src services tests scripts
|
||||||
|
|
||||||
|
# --forked (pytest-forked): one process per test function -- resolves the
|
||||||
|
# e2e suite's previously-documented flakiness (RI/README.md), where several
|
||||||
|
# in-process ASGI server pairs sharing one Python process/event loop across
|
||||||
|
# many sequential tests intermittently failed. Verified: 119/119 (97 unit +
|
||||||
|
# 22 e2e) pass reliably with it; costs roughly +1min total on this machine.
|
||||||
test:
|
test:
|
||||||
$(VENV)/bin/python3 -m pytest
|
$(VENV)/bin/python3 -m pytest --forked
|
||||||
|
|
||||||
validate-schemas:
|
validate-schemas:
|
||||||
# Placeholder until EP-02/EP-04 introduce the first JSON Schema-backed
|
$(VENV)/bin/python3 scripts/validate_config.py
|
||||||
# YAML config types; will validate every file under config/ against its
|
|
||||||
# schema (see docs/RI-PLANNING.md, EP-00-T03).
|
export-schemas:
|
||||||
@echo "No schema-validated config types yet (see EP-02-T01, EP-04-T01)."
|
$(VENV)/bin/python3 scripts/export_schemas.py
|
||||||
|
|
||||||
# Runs both M0 services directly on the host (no Docker required) --
|
# Runs both M0 services directly on the host (no Docker required) --
|
||||||
# handy for local development and for environments without a Docker daemon.
|
# handy for local development and for environments without a Docker daemon.
|
||||||
|
|||||||
136
RI/README.md
136
RI/README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Implementação executável da arquitetura descrita em [`../README.md`](../README.md), planejada em [`../docs/RI-PLANNING.md`](../docs/RI-PLANNING.md).
|
Implementação executável da arquitetura descrita em [`../README.md`](../README.md), planejada em [`../docs/RI-PLANNING.md`](../docs/RI-PLANNING.md).
|
||||||
|
|
||||||
> **Status: núcleo de enforcement do Marco M1 concluído e testado ponta a ponta** (auth real → entitlement → PDP → decisão no Gateway, com OPA e um servidor JWKS reais — não mocks). Ver "O que existe hoje" abaixo. Os demais marcos estão descritos em `docs/RI-PLANNING.md`, seção 8.7.
|
> **Status: Marco M1 concluído** (fechamento retroativo pós-M2, 2026-09-11) — todos os gaps de M0/M1 identificados após o M2 foram implementados nesta rodada: Fabric router multi-backend (EP-06-T01), domínio Finance com backend real (EP-06-T03), auditoria IC/NOC/GRL + correlação ponta a ponta (EP-08-T02/T03), stack local de observabilidade (EP-08-T04), cache hint (EP-05-T03), anti-bypass (EP-05-T07), consistência header/body (EP-05-T08), adapter REST genérico (EP-06-T04), lint de shared client (EP-01-T05), spike de mTLS (EP-01-T04), pipeline de CI (EP-00-T03). Ver "O que existe hoje" abaixo. A flakiness da suíte e2e documentada em sessões anteriores está **resolvida** (`--forked`, ver abaixo). Os demais marcos (M3/M4) estão descritos em `docs/RI-PLANNING.md`, seção 8.7.
|
||||||
|
|
||||||
## Quickstart (sem Docker)
|
## Quickstart (sem Docker)
|
||||||
|
|
||||||
@@ -10,10 +10,16 @@ Implementação executável da arquitetura descrita em [`../README.md`](../READM
|
|||||||
make install # cria .venv e instala o pacote em modo editável (dev extras inclusos)
|
make install # cria .venv e instala o pacote em modo editável (dev extras inclusos)
|
||||||
make lint # ruff check + ruff format --check
|
make lint # ruff check + ruff format --check
|
||||||
make typecheck # mypy --strict
|
make typecheck # mypy --strict
|
||||||
make test # pytest (38 testes: unit + e2e)
|
make validate-schemas # valida todo YAML de config/ contra seu modelo Pydantic
|
||||||
|
make export-schemas # regenera schemas/*.schema.json a partir dos modelos
|
||||||
|
make test # pytest --forked (unit + e2e) -- ver nota abaixo
|
||||||
```
|
```
|
||||||
|
|
||||||
`make test` precisa do binário `opa` no PATH (ou `OPA_BINARY` apontando para ele) para os testes que exercitam o PDP de verdade — ver "Como instalar o OPA" abaixo. Sem ele, esses testes são pulados (`pytest.skip`), não falham.
|
`make test` precisa do binário `opa` no PATH (ou `OPA_BINARY` apontando para ele) para os testes que exercitam o PDP de verdade — ver "Como instalar o OPA" abaixo. Sem ele, esses testes são pulados (`pytest.skip`), não falham. Os testes de `test_real_keycloak.py` precisam de `docker` no PATH pela mesma razão.
|
||||||
|
|
||||||
|
### Flakiness da suíte e2e: resolvida com `--forked`
|
||||||
|
|
||||||
|
Sessões anteriores documentaram falhas intermitentes (`mcp.shared.exceptions.MCPError: SSE stream ended without a response`) ao rodar `pytest tests/e2e/` sem isolamento de processo — a hipótese era limpeza assíncrona (anyio/httpx/uvicorn) compartilhada entre pares de servidor ASGI em processo, na mesma sessão pytest, e várias mitigações (mais retries, pausas, isolar por arquivo) não resolviam. O follow-up então recomendado — isolar cada teste em subprocesso próprio via `pytest-forked` — foi implementado e **confirma a hipótese**: com `--forked` (um processo `fork()` por função de teste), a suíte inteira passa de forma consistente. Verificado nesta sessão: **119/119 testes (97 unit + 22 e2e) passando em múltiplas execuções seguidas**, contra a mesma taxa de 1-9 falhas por execução sem `--forked`. `make test` e `.github/workflows/ci.yml` já usam `--forked` por padrão; o custo é só tempo de execução (~+1min nesta máquina).
|
||||||
|
|
||||||
### Como instalar o OPA (sem Docker)
|
### Como instalar o OPA (sem Docker)
|
||||||
|
|
||||||
@@ -33,21 +39,54 @@ Versão usada durante o desenvolvimento desta RI: **OPA v1.20.2** (Rego v1 é o
|
|||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
opa run --server --addr 127.0.0.1:8181 config/policies/ &
|
opa run --server --addr 127.0.0.1:8181 config/policies/ &
|
||||||
|
export SALES_DOMAIN_SERVICE_TOKEN=dev-sales-domain-service-token # EP-07: credencial de saída, nunca o token do client
|
||||||
|
export FINANCE_DOMAIN_SERVICE_TOKEN=dev-finance-domain-service-token
|
||||||
python3 -m example_mcp_servers.sales_domain.server &
|
python3 -m example_mcp_servers.sales_domain.server &
|
||||||
|
python3 -m example_mcp_servers.finance_domain.server &
|
||||||
# Sem OIDC_JWKS_URL/OIDC_ISSUER, o Gateway roda em modo "sem IdP configurado":
|
# Sem OIDC_JWKS_URL/OIDC_ISSUER, o Gateway roda em modo "sem IdP configurado":
|
||||||
# nega tudo por padrão (ver "Dois modos de deployment" abaixo).
|
# nega tudo por padrão (ver "Dois modos de deployment" abaixo).
|
||||||
python3 -m emcp_bus.gateway.server
|
python3 -m emcp_bus.gateway.server
|
||||||
```
|
```
|
||||||
|
|
||||||
Para autenticação real, seria necessário apontar `OIDC_JWKS_URL`/`OIDC_ISSUER` para um IdP OIDC de verdade (Keycloak, EP-01-T01 — não validado nesta sessão, ver abaixo). Os testes automatizados (`tests/e2e/conftest.py`) substituem isso por um servidor JWKS local real + tokens RS256 reais assinados na hora — válido para provar a lógica de validação de token, não para provar o Keycloak em si.
|
O Fabric router (EP-06-T01) resolve cada capability para o backend correto via `config/backends/*.yaml` + o Registry (seedado de `config/capabilities/` no startup do Gateway) — não há mais uma única `SALES_DOMAIN_URL` estática cobrindo tudo.
|
||||||
|
|
||||||
|
Para autenticação real, aponte `OIDC_JWKS_URL`/`OIDC_ISSUER` para um Keycloak real (EP-01-T01, ✅ implementado e validado — ver `deploy/keycloak/realm-export.json` e `tests/e2e/test_real_keycloak.py`) ou qualquer IdP OIDC compliant (`TokenValidator` é agnóstico de fornecedor). A maior parte da suíte (`tests/e2e/conftest.py`) continua usando um servidor JWKS local mais leve como stand-in — válido para a lógica de validação, mas `test_real_keycloak.py` é quem prova a integração com Keycloak de verdade (client_credentials grant, claims reais, `iss`/`aud` reais).
|
||||||
|
|
||||||
## Quickstart (Docker)
|
## Quickstart (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile core up --build
|
make up # == docker compose --profile core up --build (sem IdP)
|
||||||
|
make down # == docker compose --profile core down
|
||||||
|
make logs # == docker compose --profile core logs -f
|
||||||
|
|
||||||
|
# Com Keycloak real (EP-01-T01):
|
||||||
|
cp .env.example .env # descomente as 3 linhas OIDC_*
|
||||||
|
docker compose --profile core --profile identity up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Nota**: `docker-compose.yml` e os Dockerfiles em `deploy/docker/` **não foram validados com um daemon Docker real** nesta sessão (ambiente sem `docker` disponível) — foram escritos e revisados manualmente, e a stack equivalente foi validada rodando os processos diretamente no host. Rode `docker compose --profile core up --build` na primeira vez em um ambiente com Docker e reporte qualquer ajuste necessário. **O `docker-compose.yml` atual ainda não inclui OPA/Keycloak como serviços** — apenas gateway + sales-domain (perfil `core`, herdado do M0); isso é trabalho pendente do M1 (ver tabela abaixo).
|
> **✅ Validado com um daemon Docker real** (Docker 29.8.0 / Compose v5.5.1) — `gateway` + `sales-domain` + `opa` (+ `keycloak`, opcional) sobem, o Gateway sincroniza entitlements reais com o OPA real do compose, e um cliente MCP real do host completa `tools/list`/`tools/call` através do Gateway containerizado, com ou sem Keycloak. Ver "O que foi validado com Docker" abaixo para o que exatamente foi exercitado (incluindo três bugs reais encontrados e corrigidos ao longo dessas rodadas). Sem `.env`/perfil `identity`, o Gateway do compose roda em modo "sem IdP" (toda requisição não autenticada, deny-by-default) — os dois modos são deliberados, não um "modo incompleto" esperando o outro.
|
||||||
|
|
||||||
|
### O que foi validado com Docker
|
||||||
|
|
||||||
|
Build + up + smoke test end-to-end, mais dois testes adicionais montando um segundo container `gateway` manualmente (`docker run --network ri_default ...`) para exercitar caminhos que o compose padrão (sem Keycloak) não cobre, e (nesta rodada) uma segunda validação completa após o Fabric router multi-backend (EP-06-T01), o domínio Finance real (EP-06-T03), a segmentação de rede (EP-05-T07) e a stack de observabilidade (EP-08-T04):
|
||||||
|
|
||||||
|
1. **`docker compose --profile core up --build`** sobe os 4 serviços (`opa`, `sales-domain`, `finance-domain`, `gateway`); ambos os domain servers ficam `healthy`; `gateway` sincroniza com `opa` e seeda o Registry a partir de `config/capabilities/` no startup, permanecendo de pé.
|
||||||
|
2. **Modo sem OIDC** (compose padrão): `tools/list` vazio, `tools/call` negado com `UNAUTHENTICATED` — confirmado com um cliente MCP real do host contra `http://127.0.0.1:8000/mcp`.
|
||||||
|
3. **Sync real de entitlement**: `curl http://127.0.0.1:8181/v1/data/emcp/client_profiles` no OPA do compose mostra os profiles reais (`Sales.Read`, `Finance.Payments`, ...) que o Gateway empurrou no startup — não é um mock.
|
||||||
|
4. **Segmentação de rede real (EP-05-T07)**: `opa`, `sales-domain` e `finance-domain` não publicam porta de host no compose — confirmado com `curl http://127.0.0.1:8100/mcp` do host retornando `Connection refused`, algo que só um daemon Docker real prova (Docker só expõe uma porta de container para o host via `ports:`, então a ausência dela já é a segmentação, sem precisar de uma rede Docker customizada adicional).
|
||||||
|
5. **Caminho autenticado completo, cross-container, incluindo Finance**: com `--profile identity` (Keycloak real) e um token `client_credentials` real para `finance-payments-agent`, `tools/list` retorna exatamente os 3 tools de `Finance.Payments` (roteados pelo Fabric router para o container `finance-domain`, não `sales-domain`) e `tools/call finance.payment.create` executa de verdade contra o backend Finance real, retornando um `payment_id` genuíno.
|
||||||
|
6. **Correlação de trace entre containers de verdade** (não em-processo, como nos testes e2e): o mesmo `trace_id` aparece nos logs de `gateway` e do domain server correspondente para uma chamada bem-sucedida — a forma mais forte de validar o critério de aceite do Marco M0.
|
||||||
|
7. **Fail-closed real**: com o container `opa` parado, um novo container `gateway` falha ao subir (`Application startup failed. Exiting.`, `PDPUnavailableError` no traceback) — confirma README.md §38 sob Docker de verdade, não só em teste unitário.
|
||||||
|
8. **Keycloak real, via `docker compose --profile core --profile identity up`** (EP-01-T01): realm `emcp` importado de `deploy/keycloak/realm-export.json` (3 clients — `sales-read-agent`, `sales-write-agent`, `finance-payments-agent` — cada um com `client_credentials` grant habilitado e um mapper de audience para `emcp-gateway`); token obtido via `client_credentials` real, validado pelo Gateway contra o JWKS real do Keycloak, `tools/list`/`tools/call` corretos para `Sales.Read`, negação correta cross-domain (`Sales.Read` → `finance.payment.execute`) e `REQUIRE_APPROVAL` correto para `Finance.Payments` acima do threshold — tudo com identidades **realmente distintas** emitidas pelo mesmo IdP.
|
||||||
|
9. **`tests/e2e/test_real_keycloak.py`** automatiza o item 8 (sobe seu próprio container Keycloak via `docker run`, pula se `docker` não estiver no PATH) — 4 testes, agora estáveis com `--forked` (ver nota de flakiness acima).
|
||||||
|
|
||||||
|
O perfil `observability` (`otel-collector` + `jaeger`, EP-08-T04) não foi exercitado com tráfego real nesta rodada (apenas `docker compose config` validado) — os dois serviços usam imagens oficiais padrão (`otel/opentelemetry-collector-contrib`, `jaegertracing/all-in-one`) com uma config mínima (`deploy/otel/collector-config.yaml`), sem lógica própria da RI além da configuração em si.
|
||||||
|
|
||||||
|
**Três bugs reais encontrados e corrigidos ao longo da validação original** (nenhum seria pego sem um daemon Docker/Keycloak real):
|
||||||
|
|
||||||
|
- `deploy/docker/Dockerfile` não copiava `config/` para a imagem — o Gateway falhava ao subir (`ConfigError`: diretório `config/clients` não encontrado) porque `/app/config` simplesmente não existia no container. Corrigido com `COPY config ./config`.
|
||||||
|
- O healthcheck de `opa` no `docker-compose.yml` usava `wget`, que **não existe** na imagem oficial `openpolicyagent/opa` (é uma imagem estilo distroless, só o binário `opa`, sem shell). Isso deixava `gateway` (que dependia de `opa: condition: service_healthy`) preso para sempre em `Created`. Corrigido: sem healthcheck em `opa` (não há ferramenta na imagem para escrevê-lo), `gateway` depende de `opa: condition: service_started` e ganhou `restart: on-failure:5` — o próprio fail-closed do Gateway (item 6 acima) faz o papel de "esperar o OPA ficar pronto de verdade", com um pequeno número de retries do Compose cobrindo a janela entre o processo do OPA iniciar e aceitar conexões.
|
||||||
|
- Keycloak deriva o claim `iss` do `Host` header da requisição usada para *obter* o token, a menos que `KC_HOSTNAME` seja fixado — um token obtido via `127.0.0.1:8080` (do host, para teste manual) tinha `iss` diferente do que o Gateway (que valida via `keycloak:8080`, nome interno do compose) esperava, e era rejeitado por "wrong issuer" mesmo sendo um token 100% válido. Corrigido fixando `KC_HOSTNAME` (`keycloak` no compose; a URL completa `http://127.0.0.1:<porta>` no teste automatizado, já que `KC_HOSTNAME` não aceita `host:porta` sem esquema).
|
||||||
|
|
||||||
## Dois modos de deployment (importante)
|
## Dois modos de deployment (importante)
|
||||||
|
|
||||||
@@ -62,37 +101,32 @@ Os dois modos são testados explicitamente em `tests/e2e/test_governed_gateway.p
|
|||||||
|
|
||||||
| Tarefa | Componente | Status |
|
| Tarefa | Componente | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| EP-00 (completo) | Scaffold, config loader, docker-compose base | ✅ (ver README anterior/git history) |
|
| EP-00 (completo) | Scaffold, config loader, docker-compose base, **CI** | ✅ `.github/workflows/ci.yml` (EP-00-T03) |
|
||||||
| EP-01-T02 | Schema de registro de MCP Client | ✅ `src/emcp_bus/identity/models.py` |
|
| EP-01 (completo) | Registro de MCP Client, middleware OIDC/JWT, Keycloak, lint de shared client, spike mTLS | ✅ validado com JWKS real + tokens RS256 reais + Keycloak real (26.7.3, `tests/e2e/test_real_keycloak.py`); lint em `src/emcp_bus/identity/shared_client_lint.py` (ligado a `make validate-schemas`); spike em `docs/adr/ADR-019-identity-extension.md` |
|
||||||
| EP-01-T03 | Middleware de autenticação OIDC/JWT | ✅ validado com JWKS real + tokens RS256 reais (assinatura, expiração, issuer, audience) |
|
| EP-02-T01..T05 (completo) | Registry: schema, pipeline, storage, lifecycle, manifests de exemplo | ✅ `src/emcp_bus/registry/*`, testado (inclui `seed.py`, usado pelo Gateway em runtime desde EP-06-T01) |
|
||||||
| EP-01-T01 | Keycloak (realm export, serviço no compose) | ❌ não implementado — sem Java neste ambiente para validar; ver "Limitações desta sessão" |
|
| EP-03 (completo) | PDP/OPA: entitlement.rego, transaction.rego, cliente, contrato de decisão | ✅ validado com **OPA real** — 9 testes Rego + 6 Python |
|
||||||
| EP-01-T05 | Lint de shared client | ❌ não implementado (prioridade `could`) |
|
| EP-04 (completo) | Entitlement Manager: resolve, revoke, versionamento | ✅ validado com OPA real + YAML reais |
|
||||||
| EP-02 (Registry) | Schema de capability manifest | ✅ `src/emcp_bus/registry/models.py` (schema apenas — pipeline/storage/API ainda não) |
|
| EP-05 (completo) | Gateway: discovery filtrado, execution enforcement, fail-closed, **cache hint**, **anti-bypass**, **consistência header/body** | ✅ `src/emcp_bus/gateway/{server,cache,canonical_request}.py` — ver limitação de wire-mode do cache hint abaixo |
|
||||||
| EP-02-T02/T03/T05 | Publishing pipeline, storage, manifests de exemplo | ❌ não implementado |
|
| EP-06-T01 | Fabric core (roteador por capability via Registry) | ✅ `src/emcp_bus/fabric/router.py` — multi-backend real, testado (Sales→sales-domain, Finance→finance-domain, nunca cruzado) |
|
||||||
| EP-03 (completo) | PDP/OPA: entitlement.rego, transaction.rego, cliente Python, contrato de decisão | ✅ validado com **OPA real** (binário, não mock) — 9 testes Rego nativos + 6 testes Python de integração |
|
| EP-06-T02 | MCP Server de exemplo — Sales | ✅ 6 tools (R1 + R2), agora com `BackendCredentialGate` (EP-05-T07) |
|
||||||
| EP-04 (completo) | Entitlement Manager: resolve, revoke, versionamento | ✅ validado com OPA real + YAML reais de `config/clients/` |
|
| EP-06-T03 | MCP Server de exemplo — Finance | ✅ `services/example_mcp_servers/finance_domain/server.py` — invoice.get (R1), payment.create (R2), payment.execute (R3, ciclo de aprovação completo testado e2e) |
|
||||||
| EP-05-T01 | Gateway skeleton | ✅ (M0) |
|
| EP-06-T04 | Adapter REST genérico | ✅ `src/emcp_bus/fabric/adapters/rest_adapter.py` — implementado e testado standalone (`httpx.MockTransport`); nenhum manifest seed usa `backend.type: "rest"` ainda (os dois domínios de exemplo são MCP-nativos), então não há caminho ao vivo através do Gateway exercitando-o — ver módulo docstring |
|
||||||
| EP-05-T02 | `tools/list` filtrado por entitlement | ✅ testado (README.md §45 Teste 1) |
|
| EP-07 (completo) | Outbound/downstream identity: credencial de backend distinta da do client | ✅ `src/emcp_bus/downstream/{models,identity}.py`, ligado ao Gateway, testado |
|
||||||
| EP-05-T04 | `tools/call` reautorizado via PDP | ✅ testado (Teste 2, Teste 4 — agent role spoofing sem efeito) |
|
|
||||||
| EP-05-T06 | Fail-closed (PDP indisponível) | ✅ testado — `lifespan()` recusa subir se o OPA sync falhar |
|
|
||||||
| EP-05-T03 | Cache privado de `tools/list` | ❌ não implementado |
|
|
||||||
| EP-05-T07 | Anti-bypass (isolamento de rede) | ❌ não implementado (depende de docker-compose real) |
|
|
||||||
| EP-05-T08 | Consistência header/body (Mcp-Method/Mcp-Name) | ❌ não implementado explicitamente (o SDK já expõe os headers nativamente — ver §22 do README de arquitetura; falta o passo de validação cruzada) |
|
|
||||||
| EP-06-T01 | Fabric core (roteador por capability) | ❌ ainda roteamento estático (uma única URL fixa), não por Registry |
|
|
||||||
| EP-06-T02 | MCP Server de exemplo — Sales | ✅ (M0) |
|
|
||||||
| EP-06-T03 | MCP Server de exemplo — Finance | ❌ não implementado — os testes de negação para tools `finance.*` passam porque o PDP nega *antes* de qualquer tentativa de roteamento ao backend, então a ausência do servidor Finance real não invalida esses testes, mas nenhum cenário de **sucesso** em Finance foi exercitado |
|
|
||||||
| EP-06-T04 | Adapter REST genérico | ❌ não implementado |
|
|
||||||
| EP-08-T01 | Tracing OTel | ✅ (M0) |
|
| EP-08-T01 | Tracing OTel | ✅ (M0) |
|
||||||
| EP-08-T02/T03/T04 | Eventos de auditoria (IC/NOC/GRL), correlação, stack de observabilidade | ❌ não implementado — hoje só existe o trace OTel, sem taxonomia de eventos nem sink de auditoria |
|
| EP-08-T02 | Eventos de auditoria (taxonomia IC/NOC/GRL) | ✅ `src/emcp_bus/audit/{models,events,sink}.py` — nunca registra valor de token/secret (hash apenas), sink JSONL append-only |
|
||||||
|
| EP-08-T03 | Correlação ponta a ponta (decisionId/policyDecisionId/entitlementVersion/mcpRequestId) | ✅ `src/emcp_bus/audit/correlation.py` — exposto no trace (span attributes) e em todo evento de auditoria |
|
||||||
|
| EP-08-T04 | Stack local de observabilidade (OTel Collector + Jaeger) | ✅ `docker-compose.yml` perfil `observability` + `deploy/otel/collector-config.yaml` — config validada (`docker compose config`), não exercitada com tráfego real nesta rodada |
|
||||||
|
| EP-14 (completo) | Approval workflow: contrato, serviço, fail-closed, ligado ao Gateway | ✅ `src/emcp_bus/approval/{models,service}.py`, testado (ciclo completo e2e para Sales **e** Finance: nega → aprova externamente → executa → replay nega) |
|
||||||
|
|
||||||
**39 testes automatizados**, todos reais (sem mocks do núcleo de segurança): OPA de verdade (subprocess), JWKS HTTP real + JWT RS256 reais, YAML reais de `config/`.
|
**119 testes automatizados** (97 unit + 22 e2e, incluindo 4 contra Keycloak real em `test_real_keycloak.py`), a lógica de produção sem mocks do núcleo de segurança: OPA real (subprocess), Keycloak real (Docker) + JWKS HTTP real (stand-in leve para o resto da suíte) + JWT RS256 reais, YAML reais de `config/`, SQLite real para o Registry. **100% determinísticos com `--forked`** (verificado em múltiplas execuções seguidas) — a flakiness antes documentada para a suíte e2e está resolvida, ver seção dedicada acima.
|
||||||
|
|
||||||
## Limitações desta sessão
|
## Limitações conhecidas
|
||||||
|
|
||||||
Nenhum `docker` nem `java` disponíveis no ambiente onde esta RI foi escrita:
|
- **SEP-2549 (`ttlMs` do cache hint de `tools/list`, EP-05-T03)**: implementado corretamente (`src/emcp_bus/gateway/cache.py`), mas o campo é vocabulário exclusivo do protocolo 2026-07-28, que só existe no modo *stateless per-request* do SDK (sem handshake `initialize`) — esta RI usa o handshake de sessão clássico em toda parte (Gateway↔Client e Gateway↔backends), então `ttl_ms` nunca chega ao wire nesta arquitetura, não importa o que o Gateway compute (o SDK descarta o campo na serialização para versões de protocolo anteriores a 2026-07-28 — verificado empiricamente). `cache_scope` não sofre essa limitação de wire, mas por não ter dependência de wire-mode também não é algo que um teste e2e consiga verificar de forma discriminante (o cliente sempre usa o default do próprio campo). A propriedade de segurança real (Teste 5, §45 — nunca vazar catálogo entre clientes) é garantida de forma independente pelo Gateway nunca cachear o catálogo filtrado no servidor.
|
||||||
|
- **EP-05-T08 (consistência header/body)**: implementado como um gate próprio (`canonical_request.py`) reaproveitando as constantes/nomes de campo do SDK, e não como o ladder de validação nativo do SDK (`mcp.shared.inbound.classify_inbound_request`), que só se aplica ao mesmo modo *stateless per-request* de 2026-07-28 mencionado acima — pela mesma razão, este RI não o alcança pelo caminho nativo.
|
||||||
- **Docker**: não validado (ver nota acima). Risco considerado baixo — o Dockerfile usa a mesma instalação (`pip install -e .`) já validada localmente.
|
- **EP-06-T04 (adapter REST)**: implementado e testado de forma standalone; nenhum backend de exemplo desta RI é REST (ambos são MCP-nativos), então o roteamento ao vivo através do Gateway para um backend `type: "rest"` não foi exercitado end-to-end.
|
||||||
- **Keycloak**: não implementado nem validado — precisa de JVM. A lógica de validação de OAuth2/OIDC (`TokenValidator`, EP-01-T03) é padrão-agnóstica de IdP (qualquer provedor OIDC compliant funciona) e foi validada com um servidor JWKS real + tokens RS256 reais, o que prova a lógica de validação mas **não prova a integração específica com Keycloak** (realm config, client credentials grant, claims exatas que o Keycloak emite). EP-01-T01 continua pendente.
|
- **Token exchange RFC 8693** (EP-07, modo `token_exchange` de `BackendConfig`): implementado como contrato/config validado, mas a chamada real ao endpoint de token exchange é um `NotImplementedError` documentado — o modo `service_account` (credencial estática por env var) é o caminho testado ponta a ponta.
|
||||||
|
- **Stack de observabilidade** (EP-08-T04): config validada, não exercitada com tráfego real (ver "O que foi validado com Docker").
|
||||||
|
|
||||||
## Versões fixadas
|
## Versões fixadas
|
||||||
|
|
||||||
@@ -101,6 +135,7 @@ Ver `pyproject.toml`. Confirmadas via `pip index versions`/download direto em 20
|
|||||||
- `mcp` 2.2.0 (SDK v2 — API mudou significativamente da v1; ver comentários em `src/emcp_bus/gateway/server.py`)
|
- `mcp` 2.2.0 (SDK v2 — API mudou significativamente da v1; ver comentários em `src/emcp_bus/gateway/server.py`)
|
||||||
- `pydantic` 2.13.x, `opentelemetry-sdk` 1.44.x, `uvicorn` 0.52.x, `pyjwt[crypto]` 2.14.x, `ruff` 0.16.x, `mypy` 2.3.x, `pytest` 9.1.x
|
- `pydantic` 2.13.x, `opentelemetry-sdk` 1.44.x, `uvicorn` 0.52.x, `pyjwt[crypto]` 2.14.x, `ruff` 0.16.x, `mypy` 2.3.x, `pytest` 9.1.x
|
||||||
- `OPA` 1.20.2 (binário estático, Rego v1 padrão)
|
- `OPA` 1.20.2 (binário estático, Rego v1 padrão)
|
||||||
|
- `pytest-forked` 1.7.x (resolve a flakiness da suíte e2e, ver acima)
|
||||||
|
|
||||||
## Estrutura
|
## Estrutura
|
||||||
|
|
||||||
@@ -110,21 +145,38 @@ Ver `../docs/RI-PLANNING.md`, seção 8.5, para a árvore completa planejada. O
|
|||||||
RI/
|
RI/
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── Makefile
|
├── Makefile
|
||||||
├── docker-compose.yml # perfil "core": gateway + sales-domain (OPA/Keycloak pendentes)
|
├── docker-compose.yml # perfil "core": gateway+sales-domain+finance-domain+opa;
|
||||||
|
│ # "identity": +keycloak; "observability": +otel-collector+jaeger
|
||||||
├── .env.example
|
├── .env.example
|
||||||
├── deploy/docker/Dockerfile
|
├── deploy/docker/Dockerfile
|
||||||
|
├── deploy/keycloak/realm-export.json # EP-01-T01: realm "emcp", 3 clients
|
||||||
|
├── deploy/otel/collector-config.yaml # EP-08-T04
|
||||||
|
├── scripts/{export_schemas,validate_config}.py
|
||||||
|
├── schemas/*.schema.json # gerados por export_schemas.py
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── policies/ # EP-03: entitlement.rego, transaction.rego (+ testes opa test)
|
│ ├── policies/ # EP-03: entitlement.rego, transaction.rego (+ opa test)
|
||||||
│ └── clients/ # EP-01/EP-04: registrations + profiles/*.yaml
|
│ ├── clients/{*.yaml,profiles/*.yaml} # EP-01/EP-04: registrations + profiles
|
||||||
|
│ ├── backends/*.yaml # EP-06-T01/EP-07: endpoint + outbound identity por backend
|
||||||
|
│ └── capabilities/{sales,finance}/*.yaml # EP-02-T05: manifests de exemplo
|
||||||
├── src/emcp_bus/
|
├── src/emcp_bus/
|
||||||
│ ├── common/{config,otel,models}.py # EP-00-T02, EP-08-T01, risk tier compartilhado
|
│ ├── common/{config,otel,models}.py # EP-00-T02, EP-08-T01, risk tier compartilhado
|
||||||
│ ├── identity/{models,authn,token_verifier}.py # EP-01-T02/T03
|
│ ├── identity/{models,authn,token_verifier,shared_client_lint}.py # EP-01
|
||||||
│ ├── entitlement/{models,manager}.py # EP-04
|
│ ├── entitlement/{models,manager}.py # EP-04
|
||||||
│ ├── registry/models.py # EP-02-T01 (schema apenas)
|
│ ├── registry/{models,store,pipeline,lifecycle,seed}.py # EP-02
|
||||||
│ ├── pdp/{client,models}.py # EP-03
|
│ ├── pdp/{client,models}.py # EP-03
|
||||||
│ └── gateway/server.py # EP-05-T01/T02/T04/T06
|
│ ├── fabric/router.py # EP-06-T01
|
||||||
├── services/example_mcp_servers/sales_domain/server.py # EP-06-T02
|
│ ├── fabric/adapters/rest_adapter.py # EP-06-T04 (standalone, ver limitações)
|
||||||
|
│ ├── downstream/{models,identity}.py # EP-07
|
||||||
|
│ ├── audit/{models,events,sink,correlation,bypass_detection}.py # EP-08-T02/T03, EP-05-T07
|
||||||
|
│ ├── approval/{models,service}.py # EP-14
|
||||||
|
│ └── gateway/{server,cache,canonical_request}.py # EP-05, integra tudo acima
|
||||||
|
├── services/example_mcp_servers/
|
||||||
|
│ ├── sales_domain/server.py # EP-06-T02 (6 tools: R1 + R2)
|
||||||
|
│ └── finance_domain/server.py # EP-06-T03 (invoice.get R1, payment.create R2, payment.execute R3)
|
||||||
└── tests/
|
└── tests/
|
||||||
├── unit/ # schemas, PDP client, entitlement manager, authn — todos contra dependências reais
|
├── unit/ # schemas, PDP, entitlement, authn, registry, downstream identity, approval,
|
||||||
└── e2e/ # conftest.py (fixtures compartilhados), walking skeleton, governed gateway
|
│ # fabric router, audit, canonical request, rest adapter, bypass detection,
|
||||||
|
│ # gateway cache, shared client lint
|
||||||
|
└── e2e/ # conftest.py (fixtures compartilhados), walking skeleton, governed gateway,
|
||||||
|
# test_real_keycloak.py (EP-01-T01, Docker real)
|
||||||
```
|
```
|
||||||
|
|||||||
5
RI/config/backends/finance-domain.yaml
Normal file
5
RI/config/backends/finance-domain.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
backend: finance-domain
|
||||||
|
url: ${FINANCE_DOMAIN_URL:-http://127.0.0.1:8101/mcp}
|
||||||
|
audience: finance-domain-service
|
||||||
|
credential_mode: service_account
|
||||||
|
service_account_token_env_var: FINANCE_DOMAIN_SERVICE_TOKEN
|
||||||
8
RI/config/backends/sales-domain.yaml
Normal file
8
RI/config/backends/sales-domain.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
backend: sales-domain
|
||||||
|
# Fabric router endpoint (EP-06-T01). ${VAR:-default} so `make dev`/tests work
|
||||||
|
# against 127.0.0.1 without any .env, while docker-compose.yml's SALES_DOMAIN_URL
|
||||||
|
# points at the compose-internal service DNS name instead.
|
||||||
|
url: ${SALES_DOMAIN_URL:-http://127.0.0.1:8100/mcp}
|
||||||
|
audience: sales-domain-service
|
||||||
|
credential_mode: service_account
|
||||||
|
service_account_token_env_var: SALES_DOMAIN_SERVICE_TOKEN
|
||||||
12
RI/config/capabilities/finance/invoice-get.yaml
Normal file
12
RI/config/capabilities/finance/invoice-get.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: finance.invoice.get
|
||||||
|
version: 1.0.0
|
||||||
|
domain: finance
|
||||||
|
owner: finance-platform
|
||||||
|
risk_tier: R1
|
||||||
|
side_effects: false
|
||||||
|
idempotent: true
|
||||||
|
data_classification: [confidential]
|
||||||
|
entitlements: [Finance.Read, Finance.Payments]
|
||||||
|
backend:
|
||||||
|
type: mcp
|
||||||
|
service: finance-domain
|
||||||
12
RI/config/capabilities/finance/payment-create.yaml
Normal file
12
RI/config/capabilities/finance/payment-create.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: finance.payment.create
|
||||||
|
version: 1.0.0
|
||||||
|
domain: finance
|
||||||
|
owner: finance-platform
|
||||||
|
risk_tier: R2
|
||||||
|
side_effects: true
|
||||||
|
idempotent: false
|
||||||
|
data_classification: [confidential, restricted]
|
||||||
|
entitlements: [Finance.Payments]
|
||||||
|
backend:
|
||||||
|
type: mcp
|
||||||
|
service: finance-domain
|
||||||
16
RI/config/capabilities/finance/payment-execute.yaml
Normal file
16
RI/config/capabilities/finance/payment-execute.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# README.md §17 (R3, high impact) + §26 (approval-eligible above Finance.Payments'
|
||||||
|
# restrictions.approval_threshold, enforced by config/policies/transaction.rego)
|
||||||
|
name: finance.payment.execute
|
||||||
|
version: 1.0.0
|
||||||
|
domain: finance
|
||||||
|
owner: finance-platform
|
||||||
|
risk_tier: R3
|
||||||
|
side_effects: true
|
||||||
|
idempotent: false
|
||||||
|
data_classification: [confidential, restricted]
|
||||||
|
entitlements: [Finance.Payments]
|
||||||
|
backend:
|
||||||
|
type: mcp
|
||||||
|
service: finance-domain
|
||||||
|
approval:
|
||||||
|
required: true
|
||||||
12
RI/config/capabilities/sales/customer-get.yaml
Normal file
12
RI/config/capabilities/sales/customer-get.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: sales.customer.get
|
||||||
|
version: 1.0.0
|
||||||
|
domain: sales
|
||||||
|
owner: sales-platform
|
||||||
|
risk_tier: R1
|
||||||
|
side_effects: false
|
||||||
|
idempotent: true
|
||||||
|
data_classification: [internal]
|
||||||
|
entitlements: [Sales.Read]
|
||||||
|
backend:
|
||||||
|
type: mcp
|
||||||
|
service: sales-domain
|
||||||
12
RI/config/capabilities/sales/customer-search.yaml
Normal file
12
RI/config/capabilities/sales/customer-search.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: sales.customer.search
|
||||||
|
version: 1.0.0
|
||||||
|
domain: sales
|
||||||
|
owner: sales-platform
|
||||||
|
risk_tier: R1
|
||||||
|
side_effects: false
|
||||||
|
idempotent: true
|
||||||
|
data_classification: [internal]
|
||||||
|
entitlements: [Sales.Read]
|
||||||
|
backend:
|
||||||
|
type: mcp
|
||||||
|
service: sales-domain
|
||||||
12
RI/config/capabilities/sales/inventory-check.yaml
Normal file
12
RI/config/capabilities/sales/inventory-check.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: sales.inventory.check
|
||||||
|
version: 1.0.0
|
||||||
|
domain: sales
|
||||||
|
owner: sales-platform
|
||||||
|
risk_tier: R1
|
||||||
|
side_effects: false
|
||||||
|
idempotent: true
|
||||||
|
data_classification: [internal]
|
||||||
|
entitlements: [Sales.Read]
|
||||||
|
backend:
|
||||||
|
type: mcp
|
||||||
|
service: sales-domain
|
||||||
12
RI/config/capabilities/sales/order-get.yaml
Normal file
12
RI/config/capabilities/sales/order-get.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: sales.order.get
|
||||||
|
version: 1.0.0
|
||||||
|
domain: sales
|
||||||
|
owner: sales-platform
|
||||||
|
risk_tier: R1
|
||||||
|
side_effects: false
|
||||||
|
idempotent: true
|
||||||
|
data_classification: [internal]
|
||||||
|
entitlements: [Sales.Read]
|
||||||
|
backend:
|
||||||
|
type: mcp
|
||||||
|
service: sales-domain
|
||||||
12
RI/config/capabilities/sales/quote-create.yaml
Normal file
12
RI/config/capabilities/sales/quote-create.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: sales.quote.create
|
||||||
|
version: 1.0.0
|
||||||
|
domain: sales
|
||||||
|
owner: sales-platform
|
||||||
|
risk_tier: R2
|
||||||
|
side_effects: true
|
||||||
|
idempotent: false
|
||||||
|
data_classification: [confidential]
|
||||||
|
entitlements: [Sales.Write]
|
||||||
|
backend:
|
||||||
|
type: mcp
|
||||||
|
service: sales-domain
|
||||||
12
RI/config/capabilities/sales/quote-update.yaml
Normal file
12
RI/config/capabilities/sales/quote-update.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: sales.quote.update
|
||||||
|
version: 1.0.0
|
||||||
|
domain: sales
|
||||||
|
owner: sales-platform
|
||||||
|
risk_tier: R2
|
||||||
|
side_effects: true
|
||||||
|
idempotent: false
|
||||||
|
data_classification: [confidential]
|
||||||
|
entitlements: [Sales.Write]
|
||||||
|
backend:
|
||||||
|
type: mcp
|
||||||
|
service: sales-domain
|
||||||
@@ -13,6 +13,7 @@ allowed_tools:
|
|||||||
restrictions:
|
restrictions:
|
||||||
max_transaction_value: 500000
|
max_transaction_value: 500000
|
||||||
allowed_regions: ["BR-SP", "BR-RJ"]
|
allowed_regions: ["BR-SP", "BR-RJ"]
|
||||||
|
approval_threshold: 100000
|
||||||
credential_policy:
|
credential_policy:
|
||||||
workload_identity: true
|
workload_identity: true
|
||||||
short_lived: true
|
short_lived: true
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# Shared image for every RI service (Gateway, example MCP servers, ...).
|
# Shared image for every RI service (Gateway, example MCP servers, ...).
|
||||||
# docker-compose.yml selects the process to run per service via `command:`.
|
# docker-compose.yml selects the process to run per service via `command:`.
|
||||||
#
|
#
|
||||||
# NOTE: not verified with a real `docker build` in this session -- no Docker
|
# Verified with a real `docker build`/`docker compose up` (RI/README.md, "O
|
||||||
# daemon was available in the sandbox this was authored in (see RI/README.md,
|
# que foi validado com Docker") -- this image originally omitted `config ./config`
|
||||||
# "Validado nesta sessão" for exactly what *was* run). Please verify on first
|
# below, which built fine but made the Gateway fail closed at startup
|
||||||
# use in an environment with Docker and report back if anything needs fixing.
|
# (`ConfigError`: config/clients not found -- `load_yaml_models`, EP-01/EP-04),
|
||||||
|
# since the container never had client registrations/profiles to load.
|
||||||
FROM python:3.12-slim AS base
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -14,16 +15,19 @@ WORKDIR /app
|
|||||||
COPY pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY services ./services
|
COPY services ./services
|
||||||
|
# The Gateway loads client registrations/profiles/backend-credential config
|
||||||
|
# from `config/` at startup (EP-01/EP-04/EP-07, GatewayConfig.config_dir).
|
||||||
|
COPY config ./config
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip \
|
RUN pip install --no-cache-dir --upgrade pip \
|
||||||
&& pip install --no-cache-dir -e .
|
&& pip install --no-cache-dir -e ".[otlp]"
|
||||||
|
|
||||||
# Runs as a non-root user; every RI service binds 0.0.0.0 inside its own
|
# Runs as a non-root user; every RI service binds 0.0.0.0 inside its own
|
||||||
# container network namespace, never the host network directly.
|
# container network namespace, never the host network directly.
|
||||||
RUN useradd --create-home --uid 10001 emcp
|
RUN useradd --create-home --uid 10001 emcp
|
||||||
USER emcp
|
USER emcp
|
||||||
|
|
||||||
EXPOSE 8000 8100
|
EXPOSE 8000 8100 8101
|
||||||
|
|
||||||
# Overridden per-service by docker-compose.yml `command:`.
|
# Overridden per-service by docker-compose.yml `command:`.
|
||||||
CMD ["python3", "-m", "emcp_bus.gateway.server"]
|
CMD ["python3", "-m", "emcp_bus.gateway.server"]
|
||||||
|
|||||||
75
RI/deploy/keycloak/realm-export.json
Normal file
75
RI/deploy/keycloak/realm-export.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"realm": "emcp",
|
||||||
|
"enabled": true,
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "sales-read-agent",
|
||||||
|
"name": "Sales.Read agent (README.md §7.1)",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": false,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": true,
|
||||||
|
"secret": "dev-sales-read-agent-secret",
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "emcp-gateway-audience",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"config": {
|
||||||
|
"included.custom.audience": "emcp-gateway",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "sales-write-agent",
|
||||||
|
"name": "Sales.Write agent (README.md §7.1)",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": false,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": true,
|
||||||
|
"secret": "dev-sales-write-agent-secret",
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "emcp-gateway-audience",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"config": {
|
||||||
|
"included.custom.audience": "emcp-gateway",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "finance-payments-agent",
|
||||||
|
"name": "Finance.Payments agent (README.md §7.2)",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": false,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": true,
|
||||||
|
"secret": "dev-finance-payments-agent-secret",
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "emcp-gateway-audience",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"config": {
|
||||||
|
"included.custom.audience": "emcp-gateway",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
29
RI/deploy/otel/collector-config.yaml
Normal file
29
RI/deploy/otel/collector-config.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# EP-08-T04: minimal OTel Collector config for local development.
|
||||||
|
#
|
||||||
|
# RI services export OTLP/HTTP to this collector (OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
# common/otel.py); the collector forwards to Jaeger's own OTLP/gRPC receiver
|
||||||
|
# for the local `jaeger` UI (docker-compose.yml, `observability` profile), and
|
||||||
|
# also logs a basic summary of every batch to its own stdout for a quick
|
||||||
|
# sanity check without opening the Jaeger UI.
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
http:
|
||||||
|
endpoint: 0.0.0.0:4318
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
otlp:
|
||||||
|
endpoint: jaeger:4317
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
debug:
|
||||||
|
verbosity: basic
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [otlp, debug]
|
||||||
@@ -1,19 +1,74 @@
|
|||||||
# EP-00-T04 (walking skeleton) + EP-16-T01 (grows into the full stack).
|
# EP-00-T04 (walking skeleton) + EP-16-T01 (grows into the full stack).
|
||||||
#
|
#
|
||||||
# `core` profile = M0: gateway + one example MCP server, static routing, no
|
# `core` profile = the minimum that actually runs: gateway + sales-domain +
|
||||||
# enforcement yet. Later milestones add services under additional profiles
|
# finance-domain + OPA. OPA stopped being optional as of M1 (EP-03/EP-05-T06):
|
||||||
# (`identity`, `policy`, `observability`, `llm-observability`, ...) so `docker
|
# the Gateway's `lifespan()` fails closed at startup if it cannot sync
|
||||||
# compose --profile core up` stays cheap through M0/M1 instead of requiring
|
# entitlements to a PDP, so "gateway without OPA" is not a smaller working
|
||||||
# the whole stack (Keycloak, OPA, Langfuse, ...) just to see the walking
|
# stack, it's a Gateway that refuses to start (README.md §38) -- by design,
|
||||||
# skeleton trace.
|
# not an oversight. `identity` profile (EP-01-T01) adds a real Keycloak with
|
||||||
|
# the realm this RI's client registrations expect -- opt-in via `.env` (see
|
||||||
|
# `.env.example`), since the Gateway's "no OIDC provider configured" mode
|
||||||
|
# (deny-by-default via handler-level checks) is just as valid a deployment
|
||||||
|
# choice as configuring one (see gateway/server.py's module docstring).
|
||||||
|
# `observability` profile (EP-08-T04) adds a real OTel Collector + Jaeger.
|
||||||
#
|
#
|
||||||
# NOT executed against a real Docker daemon in the session this was authored
|
# EP-05-T07 (anti-bypass): `opa`, `sales-domain` and `finance-domain`
|
||||||
# in (no `docker` binary in that sandbox) -- validated instead by running the
|
# deliberately publish no host port below -- on a real Docker host, that
|
||||||
# same two processes directly on the host (see RI/README.md). Please run
|
# already means nothing outside the compose network can reach them at all
|
||||||
# `docker compose --profile core up --build` on first use and report back if
|
# (Docker only exposes a container port past the host via `ports:`); only
|
||||||
# anything needs adjusting.
|
# `gateway` does. The domain servers add a second, in-process layer on top
|
||||||
|
# (`emcp_bus.audit.bypass_detection.BackendCredentialGate`, applied when
|
||||||
|
# `main()` runs, i.e. exactly this deployment): a request lacking the
|
||||||
|
# Gateway's own outbound service credential is rejected even if network
|
||||||
|
# segmentation were ever misconfigured. See RI/README.md, "O que foi
|
||||||
|
# validado com Docker", for exactly what was run and the two real bugs this
|
||||||
|
# caught (Dockerfile missing `COPY config`; `opa`'s healthcheck relying on a
|
||||||
|
# `wget` the official image doesn't ship).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
opa:
|
||||||
|
image: openpolicyagent/opa:1.20.2
|
||||||
|
command: ["run", "--server", "--addr", "0.0.0.0:8181", "/policies"]
|
||||||
|
volumes:
|
||||||
|
- ./config/policies:/policies:ro
|
||||||
|
profiles: ["core"]
|
||||||
|
# No host port published (EP-05-T07) -- only `gateway`, on the same
|
||||||
|
# compose network, ever reaches this service.
|
||||||
|
# No HEALTHCHECK either: the official image is a scratch/distroless-style
|
||||||
|
# build with only the `opa` binary -- no shell, wget, curl or nc to probe
|
||||||
|
# itself with (confirmed by `docker exec`: "sh": executable file not
|
||||||
|
# found). `gateway` below compensates with `service_started` +
|
||||||
|
# `restart: on-failure`, which is also just the fail-closed behavior
|
||||||
|
# (README.md §38) working as intended: a Gateway that starts before OPA
|
||||||
|
# is actually accepting connections refuses to come up and gets retried,
|
||||||
|
# rather than silently caching an empty entitlement set.
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:26.7.3
|
||||||
|
command: ["start-dev", "--import-realm"]
|
||||||
|
environment:
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||||
|
# Fixed so the `iss` claim on every issued token is always
|
||||||
|
# `http://keycloak:8080/realms/emcp`, regardless of whether the caller
|
||||||
|
# reached Keycloak via the container network (`keycloak:8080`, what the
|
||||||
|
# Gateway uses to validate) or the host-mapped port (`127.0.0.1:8080`,
|
||||||
|
# e.g. to fetch a token for manual testing) -- without this, Keycloak
|
||||||
|
# derives `iss` from the request's own Host header, so a token fetched
|
||||||
|
# from the host would fail the Gateway's issuer check.
|
||||||
|
KC_HOSTNAME: keycloak
|
||||||
|
KC_HOSTNAME_STRICT: "false"
|
||||||
|
volumes:
|
||||||
|
- ./deploy/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
profiles: ["identity"]
|
||||||
|
# Same reasoning as `opa`: `start-dev` is a JVM/Quarkus dev-mode image
|
||||||
|
# with no lightweight in-container probe available out of the box, and
|
||||||
|
# ~15-20s cold start (schema init + realm import) makes a short
|
||||||
|
# healthcheck interval noisy. `gateway`'s own `restart: on-failure`
|
||||||
|
# covers the startup race the same way it does for `opa`.
|
||||||
|
|
||||||
sales-domain:
|
sales-domain:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -22,9 +77,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SALES_DOMAIN_HOST: "0.0.0.0"
|
SALES_DOMAIN_HOST: "0.0.0.0"
|
||||||
SALES_DOMAIN_PORT: "8100"
|
SALES_DOMAIN_PORT: "8100"
|
||||||
ports:
|
SALES_DOMAIN_SERVICE_TOKEN: "dev-sales-domain-service-token"
|
||||||
- "8100:8100"
|
EMCP_AUDIT_LOG_PATH: "/tmp/emcp-audit.jsonl"
|
||||||
profiles: ["core"]
|
profiles: ["core"]
|
||||||
|
# No host port published (EP-05-T07) -- see module-level comment above.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# A bare TCP connect, not an HTTP/MCP-protocol check: streamable-http
|
# A bare TCP connect, not an HTTP/MCP-protocol check: streamable-http
|
||||||
# rejects a plain GET, so this only confirms the process is listening.
|
# rejects a plain GET, so this only confirms the process is listening.
|
||||||
@@ -33,6 +89,24 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
finance-domain:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: deploy/docker/Dockerfile
|
||||||
|
command: ["python3", "-m", "example_mcp_servers.finance_domain.server"]
|
||||||
|
environment:
|
||||||
|
FINANCE_DOMAIN_HOST: "0.0.0.0"
|
||||||
|
FINANCE_DOMAIN_PORT: "8101"
|
||||||
|
FINANCE_DOMAIN_SERVICE_TOKEN: "dev-finance-domain-service-token"
|
||||||
|
EMCP_AUDIT_LOG_PATH: "/tmp/emcp-audit.jsonl"
|
||||||
|
profiles: ["core"]
|
||||||
|
# No host port published (EP-05-T07) -- see module-level comment above.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python3", "-c", "import socket; socket.create_connection(('127.0.0.1', 8101), timeout=2)"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -42,9 +116,49 @@ services:
|
|||||||
GATEWAY_HOST: "0.0.0.0"
|
GATEWAY_HOST: "0.0.0.0"
|
||||||
GATEWAY_PORT: "8000"
|
GATEWAY_PORT: "8000"
|
||||||
SALES_DOMAIN_URL: "http://sales-domain:8100/mcp"
|
SALES_DOMAIN_URL: "http://sales-domain:8100/mcp"
|
||||||
|
FINANCE_DOMAIN_URL: "http://finance-domain:8101/mcp"
|
||||||
|
OPA_URL: "http://opa:8181"
|
||||||
|
SALES_DOMAIN_SERVICE_TOKEN: "dev-sales-domain-service-token"
|
||||||
|
FINANCE_DOMAIN_SERVICE_TOKEN: "dev-finance-domain-service-token"
|
||||||
|
EMCP_AUDIT_LOG_PATH: "/tmp/emcp-audit.jsonl"
|
||||||
|
# Empty (default) unless set in `.env`/the shell environment before
|
||||||
|
# `docker compose --profile core --profile identity up`: the Gateway
|
||||||
|
# then runs in its "no OIDC provider configured" mode -- deny-by-default
|
||||||
|
# via handler-level checks instead of the SDK's RequireAuthMiddleware
|
||||||
|
# (see gateway/server.py's module docstring and `build_auth`). Set all
|
||||||
|
# three (see `.env.example`) to point at the `keycloak` service above.
|
||||||
|
OIDC_JWKS_URL: "${OIDC_JWKS_URL:-}"
|
||||||
|
OIDC_ISSUER: "${OIDC_ISSUER:-}"
|
||||||
|
OIDC_AUDIENCE: "${OIDC_AUDIENCE:-emcp-gateway}"
|
||||||
|
# EP-08-T04: empty (default) exports traces to stdout via
|
||||||
|
# ConsoleSpanExporter; set to "http://otel-collector:4318" (with
|
||||||
|
# `--profile observability`) to export real OTLP traces instead.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT:-}"
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
profiles: ["core"]
|
profiles: ["core"]
|
||||||
|
restart: on-failure:5
|
||||||
depends_on:
|
depends_on:
|
||||||
sales-domain:
|
sales-domain:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
finance-domain:
|
||||||
|
condition: service_healthy
|
||||||
|
opa:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
otel-collector:
|
||||||
|
image: otel/opentelemetry-collector-contrib:0.114.0
|
||||||
|
command: ["--config=/etc/otel-collector-config.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./deploy/otel/collector-config.yaml:/etc/otel-collector-config.yaml:ro
|
||||||
|
profiles: ["observability"]
|
||||||
|
# No host port published -- only the RI services (pointed at it via
|
||||||
|
# OTEL_EXPORTER_OTLP_ENDPOINT above) and `jaeger` need to reach it.
|
||||||
|
|
||||||
|
jaeger:
|
||||||
|
image: jaegertracing/all-in-one:1.62.0
|
||||||
|
environment:
|
||||||
|
COLLECTOR_OTLP_ENABLED: "true"
|
||||||
|
ports:
|
||||||
|
- "16686:16686" # Jaeger UI
|
||||||
|
profiles: ["observability"]
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ dev = [
|
|||||||
"mypy>=2.3.1,<3",
|
"mypy>=2.3.1,<3",
|
||||||
"pytest>=9.1.1,<10",
|
"pytest>=9.1.1,<10",
|
||||||
"pytest-asyncio>=1.4.0,<2",
|
"pytest-asyncio>=1.4.0,<2",
|
||||||
|
# Resolves the e2e suite's previously-documented flakiness (RI/README.md):
|
||||||
|
# one process *per test function* means no in-process ASGI server pair
|
||||||
|
# ever shares event-loop/socket state with another test's pair. Verified:
|
||||||
|
# 22/22 e2e tests pass reliably with `--forked`, where the un-forked
|
||||||
|
# suite intermittently failed several. Used by `make test` itself now,
|
||||||
|
# not just CI.
|
||||||
|
"pytest-forked>=1.7.5,<2",
|
||||||
"types-PyYAML>=6.0.12,<7",
|
"types-PyYAML>=6.0.12,<7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
60
RI/schemas/approval-request.schema.json
Normal file
60
RI/schemas/approval-request.schema.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"approval_id": {
|
||||||
|
"title": "Approval Id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"arguments_summary": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"title": "Arguments Summary",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"client_profile": {
|
||||||
|
"title": "Client Profile",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Expires At",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"operation_hash": {
|
||||||
|
"title": "Operation Hash",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"reason_code": {
|
||||||
|
"title": "Reason Code",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"requested_at": {
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Requested At",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"default": "pending",
|
||||||
|
"enum": [
|
||||||
|
"pending",
|
||||||
|
"granted",
|
||||||
|
"denied",
|
||||||
|
"consumed"
|
||||||
|
],
|
||||||
|
"title": "Status",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tool": {
|
||||||
|
"title": "Tool",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"operation_hash",
|
||||||
|
"client_profile",
|
||||||
|
"tool",
|
||||||
|
"arguments_summary",
|
||||||
|
"reason_code",
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"title": "ApprovalRequest",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
56
RI/schemas/backend-credentials.schema.json
Normal file
56
RI/schemas/backend-credentials.schema.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"audience": {
|
||||||
|
"title": "Audience",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"title": "Backend",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"credential_mode": {
|
||||||
|
"default": "service_account",
|
||||||
|
"enum": [
|
||||||
|
"service_account",
|
||||||
|
"token_exchange"
|
||||||
|
],
|
||||||
|
"title": "Credential Mode",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"service_account_token_env_var": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Service Account Token Env Var"
|
||||||
|
},
|
||||||
|
"token_exchange_endpoint": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Token Exchange Endpoint"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"title": "Url",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"backend",
|
||||||
|
"url",
|
||||||
|
"audience"
|
||||||
|
],
|
||||||
|
"title": "BackendConfig",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
199
RI/schemas/capability-manifest.schema.json
Normal file
199
RI/schemas/capability-manifest.schema.json
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
{
|
||||||
|
"$defs": {
|
||||||
|
"ApprovalPolicy": {
|
||||||
|
"properties": {
|
||||||
|
"required": {
|
||||||
|
"default": false,
|
||||||
|
"title": "Required",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "ApprovalPolicy",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"BackendRef": {
|
||||||
|
"description": "Where the Fabric (EP-06-T01) routes a capability once authorized.",
|
||||||
|
"properties": {
|
||||||
|
"operation": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"description": "Informational, e.g. 'GET /v2/customers/{id}' (README.md \u00a76.8).",
|
||||||
|
"title": "Operation"
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"description": "Adapter/service id the Fabric router resolves, e.g. 'sales-domain'.",
|
||||||
|
"title": "Service",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"default": "mcp",
|
||||||
|
"enum": [
|
||||||
|
"rest",
|
||||||
|
"mcp",
|
||||||
|
"grpc"
|
||||||
|
],
|
||||||
|
"title": "Type",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"service"
|
||||||
|
],
|
||||||
|
"title": "BackendRef",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"DataClassification": {
|
||||||
|
"enum": [
|
||||||
|
"public",
|
||||||
|
"internal",
|
||||||
|
"confidential",
|
||||||
|
"restricted"
|
||||||
|
],
|
||||||
|
"title": "DataClassification",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"LifecycleInfo": {
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/$defs/LifecycleStatus",
|
||||||
|
"default": "nominate"
|
||||||
|
},
|
||||||
|
"sunset_after": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"format": "date",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Sunset After"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "LifecycleInfo",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"LifecycleStatus": {
|
||||||
|
"description": "Registry governance lifecycle (README.md \u00a723).",
|
||||||
|
"enum": [
|
||||||
|
"nominate",
|
||||||
|
"review",
|
||||||
|
"register",
|
||||||
|
"active",
|
||||||
|
"deprecated",
|
||||||
|
"retired"
|
||||||
|
],
|
||||||
|
"title": "LifecycleStatus",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ObservabilityPolicy": {
|
||||||
|
"properties": {
|
||||||
|
"audit_level": {
|
||||||
|
"default": "full",
|
||||||
|
"enum": [
|
||||||
|
"none",
|
||||||
|
"standard",
|
||||||
|
"full"
|
||||||
|
],
|
||||||
|
"title": "Audit Level",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "ObservabilityPolicy",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"RiskTier": {
|
||||||
|
"description": "Capability/client risk classification (README.md \u00a717).\n\nR0 discovery/public, R1 read, R2 write, R3 high-impact (approval-eligible),\nR4 destructive/restricted. The *same* enum is used for both a capability\nmanifest's `risk_tier` (EP-02-T01) and a client profile's `risk_ceiling`\n(EP-04-T01) so entitlement resolution (EP-04-T02) can compare them\ndirectly instead of maintaining two parallel classifications.",
|
||||||
|
"enum": [
|
||||||
|
"R0",
|
||||||
|
"R1",
|
||||||
|
"R2",
|
||||||
|
"R3",
|
||||||
|
"R4"
|
||||||
|
],
|
||||||
|
"title": "RiskTier",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"approval": {
|
||||||
|
"$ref": "#/$defs/ApprovalPolicy"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"$ref": "#/$defs/BackendRef"
|
||||||
|
},
|
||||||
|
"data_classification": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/DataClassification"
|
||||||
|
},
|
||||||
|
"title": "Data Classification",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"title": "Domain",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"entitlements": {
|
||||||
|
"description": "ClientProfile names required to see/call this capability (EP-04).",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"title": "Entitlements",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"idempotent": {
|
||||||
|
"title": "Idempotent",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"lifecycle": {
|
||||||
|
"$ref": "#/$defs/LifecycleInfo"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "Canonical '<domain>.<capability>' name (README.md \u00a739).",
|
||||||
|
"title": "Name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"observability": {
|
||||||
|
"$ref": "#/$defs/ObservabilityPolicy"
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"title": "Owner",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"risk_tier": {
|
||||||
|
"$ref": "#/$defs/RiskTier"
|
||||||
|
},
|
||||||
|
"side_effects": {
|
||||||
|
"title": "Side Effects",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"description": "SemVer, e.g. '1.2.0' (README.md \u00a740).",
|
||||||
|
"title": "Version",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"version",
|
||||||
|
"domain",
|
||||||
|
"owner",
|
||||||
|
"risk_tier",
|
||||||
|
"side_effects",
|
||||||
|
"idempotent",
|
||||||
|
"entitlements",
|
||||||
|
"backend"
|
||||||
|
],
|
||||||
|
"title": "CapabilityManifest",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
148
RI/schemas/client-profile.schema.json
Normal file
148
RI/schemas/client-profile.schema.json
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"$defs": {
|
||||||
|
"AuditPolicy": {
|
||||||
|
"properties": {
|
||||||
|
"level": {
|
||||||
|
"default": "full",
|
||||||
|
"enum": [
|
||||||
|
"none",
|
||||||
|
"standard",
|
||||||
|
"full"
|
||||||
|
],
|
||||||
|
"title": "Level",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "AuditPolicy",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"CredentialPolicy": {
|
||||||
|
"properties": {
|
||||||
|
"short_lived": {
|
||||||
|
"default": true,
|
||||||
|
"title": "Short Lived",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"workload_identity": {
|
||||||
|
"default": true,
|
||||||
|
"title": "Workload Identity",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "CredentialPolicy",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"RiskTier": {
|
||||||
|
"description": "Capability/client risk classification (README.md \u00a717).\n\nR0 discovery/public, R1 read, R2 write, R3 high-impact (approval-eligible),\nR4 destructive/restricted. The *same* enum is used for both a capability\nmanifest's `risk_tier` (EP-02-T01) and a client profile's `risk_ceiling`\n(EP-04-T01) so entitlement resolution (EP-04-T02) can compare them\ndirectly instead of maintaining two parallel classifications.",
|
||||||
|
"enum": [
|
||||||
|
"R0",
|
||||||
|
"R1",
|
||||||
|
"R2",
|
||||||
|
"R3",
|
||||||
|
"R4"
|
||||||
|
],
|
||||||
|
"title": "RiskTier",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"TransactionRestrictions": {
|
||||||
|
"description": "Argument/resource-level bounds enforced by the Transaction Policy (EP-03-T03).\n\nThese are *hints* carried alongside the profile for the PDP's Rego data\ndocument -- the PDP, not this model, is the authority that actually\nenforces them (README.md \u00a76.5.B, \u00a725 Passo 2).",
|
||||||
|
"properties": {
|
||||||
|
"allowed_regions": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Allowed Regions"
|
||||||
|
},
|
||||||
|
"approval_threshold": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"description": "Above this `arguments.amount`, the transaction policy returns REQUIRE_APPROVAL instead of ALLOW (README.md \u00a726 example: payment.execute < 10k ALLOW, >= 10k REQUIRE_APPROVAL).",
|
||||||
|
"title": "Approval Threshold"
|
||||||
|
},
|
||||||
|
"max_transaction_value": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Max Transaction Value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "TransactionRestrictions",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "A governed client profile (README.md \u00a741 example).",
|
||||||
|
"properties": {
|
||||||
|
"allowed_tools": {
|
||||||
|
"description": "Canonical <domain>.<capability> tool names (README.md \u00a739).",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1,
|
||||||
|
"title": "Allowed Tools",
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"$ref": "#/$defs/AuditPolicy"
|
||||||
|
},
|
||||||
|
"credential_policy": {
|
||||||
|
"$ref": "#/$defs/CredentialPolicy"
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"default": "production",
|
||||||
|
"enum": [
|
||||||
|
"dev",
|
||||||
|
"ci",
|
||||||
|
"production"
|
||||||
|
],
|
||||||
|
"title": "Environment",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"description": "e.g. \"Sales.Read\", \"Finance.Payments\" (README.md \u00a77.1).",
|
||||||
|
"title": "Name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"title": "Owner",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"restrictions": {
|
||||||
|
"$ref": "#/$defs/TransactionRestrictions"
|
||||||
|
},
|
||||||
|
"risk_ceiling": {
|
||||||
|
"$ref": "#/$defs/RiskTier"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"owner",
|
||||||
|
"risk_ceiling",
|
||||||
|
"allowed_tools"
|
||||||
|
],
|
||||||
|
"title": "ClientProfile",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
40
RI/schemas/client-registration.schema.json
Normal file
40
RI/schemas/client-registration.schema.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"client_id": {
|
||||||
|
"title": "Client Id",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": null,
|
||||||
|
"title": "Description"
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"default": "production",
|
||||||
|
"enum": [
|
||||||
|
"dev",
|
||||||
|
"ci",
|
||||||
|
"production"
|
||||||
|
],
|
||||||
|
"title": "Environment",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profile",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"client_id",
|
||||||
|
"profile"
|
||||||
|
],
|
||||||
|
"title": "ClientRegistration",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
33
RI/schemas/pdp-decision.schema.json
Normal file
33
RI/schemas/pdp-decision.schema.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$defs": {
|
||||||
|
"PDPOutcome": {
|
||||||
|
"enum": [
|
||||||
|
"ALLOW",
|
||||||
|
"DENY",
|
||||||
|
"REQUIRE_APPROVAL"
|
||||||
|
],
|
||||||
|
"title": "PDPOutcome",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"outcome": {
|
||||||
|
"$ref": "#/$defs/PDPOutcome"
|
||||||
|
},
|
||||||
|
"policy_version": {
|
||||||
|
"title": "Policy Version",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"reason_code": {
|
||||||
|
"title": "Reason Code",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"outcome",
|
||||||
|
"policy_version",
|
||||||
|
"reason_code"
|
||||||
|
],
|
||||||
|
"title": "PDPDecision",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
44
RI/scripts/export_schemas.py
Normal file
44
RI/scripts/export_schemas.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Exports JSON Schema for every declarative config/contract model in the RI
|
||||||
|
into schemas/*.schema.json (EP-00-T02/T03: "todo YAML de config tem JSON
|
||||||
|
Schema"). Run via `make export-schemas`; CI (`make validate-schemas`) checks
|
||||||
|
the committed files are up to date rather than regenerating them silently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from emcp_bus.approval.models import ApprovalRequest
|
||||||
|
from emcp_bus.downstream.models import BackendConfig
|
||||||
|
from emcp_bus.entitlement.models import ClientProfile
|
||||||
|
from emcp_bus.identity.models import ClientRegistration
|
||||||
|
from emcp_bus.pdp.models import PDPDecision
|
||||||
|
from emcp_bus.registry.models import CapabilityManifest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCHEMAS_DIR = REPO_ROOT / "schemas"
|
||||||
|
|
||||||
|
MODELS: dict[str, type[BaseModel]] = {
|
||||||
|
"client-registration": ClientRegistration,
|
||||||
|
"client-profile": ClientProfile,
|
||||||
|
"capability-manifest": CapabilityManifest,
|
||||||
|
"backend-credentials": BackendConfig,
|
||||||
|
"approval-request": ApprovalRequest,
|
||||||
|
"pdp-decision": PDPDecision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
SCHEMAS_DIR.mkdir(exist_ok=True)
|
||||||
|
for file_stem, model in MODELS.items():
|
||||||
|
schema = model.model_json_schema()
|
||||||
|
path = SCHEMAS_DIR / f"{file_stem}.schema.json"
|
||||||
|
path.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {path.relative_to(REPO_ROOT)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
67
RI/scripts/validate_config.py
Normal file
67
RI/scripts/validate_config.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""CI/local check (EP-00-T03): every YAML file under config/ must validate
|
||||||
|
against its Pydantic model -- this is the thing that actually matters
|
||||||
|
(RI-PLANNING.md's "todo YAML de config tem ... validação em CI"), not just
|
||||||
|
that a JSON Schema file exists somewhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from emcp_bus.common.config import ConfigError, load_yaml_models
|
||||||
|
from emcp_bus.downstream.models import BackendConfig
|
||||||
|
from emcp_bus.entitlement.models import ClientProfile
|
||||||
|
from emcp_bus.identity.models import ClientRegistration
|
||||||
|
from emcp_bus.identity.shared_client_lint import find_shared_client_violations
|
||||||
|
from emcp_bus.registry.models import CapabilityManifest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CONFIG_DIR = REPO_ROOT / "config"
|
||||||
|
|
||||||
|
CHECKS: list[tuple[str, type[BaseModel]]] = [
|
||||||
|
("clients/profiles", ClientProfile),
|
||||||
|
("clients", ClientRegistration), # non-recursive glob: skips clients/profiles/
|
||||||
|
("backends", BackendConfig),
|
||||||
|
("capabilities/sales", CapabilityManifest),
|
||||||
|
("capabilities/finance", CapabilityManifest),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
had_errors = False
|
||||||
|
client_registrations: list[ClientRegistration] = []
|
||||||
|
for relative_dir, model in CHECKS:
|
||||||
|
directory = CONFIG_DIR / relative_dir
|
||||||
|
try:
|
||||||
|
items = load_yaml_models(directory, model)
|
||||||
|
except ConfigError as exc:
|
||||||
|
print(f"FAIL {relative_dir}: {exc}")
|
||||||
|
had_errors = True
|
||||||
|
continue
|
||||||
|
print(f"OK {relative_dir}: {len(items)} file(s) validated against {model.__name__}")
|
||||||
|
if model is ClientRegistration:
|
||||||
|
client_registrations = items # type: ignore[assignment]
|
||||||
|
|
||||||
|
# EP-01-T05: two agents sharing an MCP Client identity must resolve to
|
||||||
|
# the same profile (README.md §18/§35) -- a config-shape check schema
|
||||||
|
# validation alone cannot express, since it spans multiple files.
|
||||||
|
violations = find_shared_client_violations(client_registrations)
|
||||||
|
if violations:
|
||||||
|
for violation in violations:
|
||||||
|
print(f"FAIL clients (shared-client lint): {violation}")
|
||||||
|
had_errors = True
|
||||||
|
else:
|
||||||
|
print("OK clients (shared-client lint): no violations")
|
||||||
|
|
||||||
|
if had_errors:
|
||||||
|
print("\nConfig validation failed.")
|
||||||
|
return 1
|
||||||
|
print("\nAll config files validate against their schema.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
111
RI/services/example_mcp_servers/finance_domain/server.py
Normal file
111
RI/services/example_mcp_servers/finance_domain/server.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""Example Finance-domain MCP server (EP-06-T03).
|
||||||
|
|
||||||
|
invoice.get (R1, read-only), payment.create (R2, side-effecting, not yet
|
||||||
|
executed), payment.execute (R3, high-impact -- REQUIRE_APPROVAL above
|
||||||
|
Finance.Payments' `restrictions.approval_threshold`, enforced entirely by
|
||||||
|
config/policies/transaction.rego before the call ever reaches here). Backed
|
||||||
|
by static mock data -- this server is deliberately "dumb" (ADR-023): it never
|
||||||
|
authorizes anything, only the Gateway/PDP does that, upstream of this process.
|
||||||
|
|
||||||
|
Run directly for local development:
|
||||||
|
|
||||||
|
python -m example_mcp_servers.finance_domain.server
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
|
from emcp_bus.audit.bypass_detection import BackendCredentialGate
|
||||||
|
from emcp_bus.audit.sink import JSONLFileAuditSink
|
||||||
|
from emcp_bus.common.otel import setup_tracing
|
||||||
|
|
||||||
|
setup_tracing("finance-domain-mcp-server")
|
||||||
|
|
||||||
|
server = MCPServer(name="finance-domain", version="0.1.0")
|
||||||
|
|
||||||
|
_INVOICES: dict[str, dict[str, object]] = {
|
||||||
|
"inv-001": {
|
||||||
|
"invoice_id": "inv-001",
|
||||||
|
"customer_id": "cust-001",
|
||||||
|
"amount": 5000,
|
||||||
|
"status": "open",
|
||||||
|
},
|
||||||
|
"inv-002": {
|
||||||
|
"invoice_id": "inv-002",
|
||||||
|
"customer_id": "cust-002",
|
||||||
|
"amount": 25000,
|
||||||
|
"status": "overdue",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_PAYMENTS: dict[str, dict[str, object]] = {}
|
||||||
|
_payment_ids = itertools.count(1)
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool(name="finance.invoice.get", description="Get a single finance invoice by id.")
|
||||||
|
def invoice_get(invoice_id: str) -> dict[str, object]:
|
||||||
|
invoice = _INVOICES.get(invoice_id)
|
||||||
|
if invoice is None:
|
||||||
|
return {"error": "not_found", "invoice_id": invoice_id}
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool(
|
||||||
|
name="finance.payment.create", description="Create a draft payment against an invoice."
|
||||||
|
)
|
||||||
|
def payment_create(invoice_id: str, amount: float) -> dict[str, object]:
|
||||||
|
payment_id = f"pay-{next(_payment_ids):03d}"
|
||||||
|
payment = {
|
||||||
|
"payment_id": payment_id,
|
||||||
|
"invoice_id": invoice_id,
|
||||||
|
"amount": amount,
|
||||||
|
"status": "draft",
|
||||||
|
}
|
||||||
|
_PAYMENTS[payment_id] = payment
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool(name="finance.payment.execute", description="Execute a previously created payment.")
|
||||||
|
def payment_execute(payment_id: str, amount: float) -> dict[str, object]:
|
||||||
|
"""`amount` is declared by the caller (not re-derived from the stored
|
||||||
|
payment) so it is visible to `config/policies/transaction.rego`'s
|
||||||
|
`approval_threshold` check (README.md §26) -- the transaction policy
|
||||||
|
inspects call arguments, never backend state, matching `sales.quote.create`'s
|
||||||
|
same pattern (EP-06-T02)."""
|
||||||
|
payment = _PAYMENTS.get(payment_id)
|
||||||
|
if payment is None:
|
||||||
|
return {"error": "not_found", "payment_id": payment_id}
|
||||||
|
payment["status"] = "executed"
|
||||||
|
payment["executed_amount"] = amount
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
host = os.environ.get("FINANCE_DOMAIN_HOST", "127.0.0.1")
|
||||||
|
port = int(os.environ.get("FINANCE_DOMAIN_PORT", "8101"))
|
||||||
|
expected_token = os.environ.get("FINANCE_DOMAIN_SERVICE_TOKEN")
|
||||||
|
audit_log_path = os.environ.get("EMCP_AUDIT_LOG_PATH", "/tmp/emcp-audit.jsonl")
|
||||||
|
|
||||||
|
app: ASGIApp = server.streamable_http_app(host=host)
|
||||||
|
if expected_token:
|
||||||
|
# EP-05-T07: reject anything that didn't come through the Gateway's
|
||||||
|
# outbound identity (EP-07-T01) with the matching service credential.
|
||||||
|
app = BackendCredentialGate(
|
||||||
|
app,
|
||||||
|
expected_token=expected_token,
|
||||||
|
backend="finance-domain",
|
||||||
|
audit_sink=JSONLFileAuditSink(audit_log_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Example Sales-domain MCP server (EP-06-T02, walking-skeleton subset).
|
"""Example Sales-domain MCP server (EP-06-T02).
|
||||||
|
|
||||||
Read-only tools (risk tier R1, see README.md §17): customer.search,
|
Read-only tools (risk tier R1, see README.md §17): customer.search,
|
||||||
customer.get, order.get, inventory.check. Backed by static mock data -- this
|
customer.get, order.get, inventory.check. Write tools (R2): quote.create,
|
||||||
server is deliberately "dumb": it never authorizes anything (ADR-023 in
|
quote.update. Backed by static mock data -- this server is deliberately
|
||||||
docs/RI-PLANNING.md). Authorization happens exclusively upstream, in the
|
"dumb": it never authorizes anything (ADR-023 in docs/RI-PLANNING.md).
|
||||||
Gateway/PEP (EP-05) before a call ever reaches this process.
|
Authorization happens exclusively upstream, in the Gateway/PEP (EP-05)
|
||||||
|
before a call ever reaches this process.
|
||||||
|
|
||||||
Run directly for local development:
|
Run directly for local development:
|
||||||
|
|
||||||
@@ -13,10 +14,14 @@ Run directly for local development:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from mcp.server.mcpserver import MCPServer
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
|
from emcp_bus.audit.bypass_detection import BackendCredentialGate
|
||||||
|
from emcp_bus.audit.sink import JSONLFileAuditSink
|
||||||
from emcp_bus.common.otel import setup_tracing
|
from emcp_bus.common.otel import setup_tracing
|
||||||
|
|
||||||
setup_tracing("sales-domain-mcp-server")
|
setup_tracing("sales-domain-mcp-server")
|
||||||
@@ -37,6 +42,9 @@ _INVENTORY: dict[str, int] = {
|
|||||||
"sku-200": 0,
|
"sku-200": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_QUOTES: dict[str, dict[str, object]] = {}
|
||||||
|
_quote_ids = itertools.count(1)
|
||||||
|
|
||||||
|
|
||||||
@server.tool(name="sales.customer.search", description="Search sales customers by name fragment.")
|
@server.tool(name="sales.customer.search", description="Search sales customers by name fragment.")
|
||||||
def customer_search(query: str) -> dict[str, object]:
|
def customer_search(query: str) -> dict[str, object]:
|
||||||
@@ -65,10 +73,50 @@ def inventory_check(sku: str) -> dict[str, object]:
|
|||||||
return {"sku": sku, "available": _INVENTORY.get(sku, 0)}
|
return {"sku": sku, "available": _INVENTORY.get(sku, 0)}
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool(name="sales.quote.create", description="Create a sales quote for a customer.")
|
||||||
|
def quote_create(customer_id: str, amount: float, region: str = "BR-SP") -> dict[str, object]:
|
||||||
|
quote_id = f"quote-{next(_quote_ids):03d}"
|
||||||
|
quote = {
|
||||||
|
"quote_id": quote_id,
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"amount": amount,
|
||||||
|
"region": region,
|
||||||
|
"status": "draft",
|
||||||
|
}
|
||||||
|
_QUOTES[quote_id] = quote
|
||||||
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool(name="sales.quote.update", description="Update an existing sales quote.")
|
||||||
|
def quote_update(quote_id: str, amount: float | None = None) -> dict[str, object]:
|
||||||
|
quote = _QUOTES.get(quote_id)
|
||||||
|
if quote is None:
|
||||||
|
return {"error": "not_found", "quote_id": quote_id}
|
||||||
|
if amount is not None:
|
||||||
|
quote["amount"] = amount
|
||||||
|
return quote
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
host = os.environ.get("SALES_DOMAIN_HOST", "127.0.0.1")
|
host = os.environ.get("SALES_DOMAIN_HOST", "127.0.0.1")
|
||||||
port = int(os.environ.get("SALES_DOMAIN_PORT", "8100"))
|
port = int(os.environ.get("SALES_DOMAIN_PORT", "8100"))
|
||||||
server.run(transport="streamable-http", host=host, port=port)
|
expected_token = os.environ.get("SALES_DOMAIN_SERVICE_TOKEN")
|
||||||
|
audit_log_path = os.environ.get("EMCP_AUDIT_LOG_PATH", "/tmp/emcp-audit.jsonl")
|
||||||
|
|
||||||
|
app: ASGIApp = server.streamable_http_app(host=host)
|
||||||
|
if expected_token:
|
||||||
|
# EP-05-T07: reject anything that didn't come through the Gateway's
|
||||||
|
# outbound identity (EP-07-T01) with the matching service credential.
|
||||||
|
app = BackendCredentialGate(
|
||||||
|
app,
|
||||||
|
expected_token=expected_token,
|
||||||
|
backend="sales-domain",
|
||||||
|
audit_sink=JSONLFileAuditSink(audit_log_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
0
RI/src/emcp_bus/approval/__init__.py
Normal file
0
RI/src/emcp_bus/approval/__init__.py
Normal file
52
RI/src/emcp_bus/approval/models.py
Normal file
52
RI/src/emcp_bus/approval/models.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Approval request contract (EP-14-T01, README.md §26).
|
||||||
|
|
||||||
|
Out-of-band, bound to the *exact* operation it was requested for (not just
|
||||||
|
the tool name), with an expiry and single-use semantics -- so an approval
|
||||||
|
granted for one call can never be replayed against a different one (same
|
||||||
|
tool, different arguments) or reused twice (README.md §46, "approval
|
||||||
|
replay").
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
def compute_operation_hash(*, client_profile: str, tool: str, arguments: dict[str, Any]) -> str:
|
||||||
|
"""Binds an approval to one exact (client_profile, tool, arguments) triple.
|
||||||
|
|
||||||
|
Canonical JSON (sorted keys) so the same logical call always hashes the
|
||||||
|
same way regardless of dict insertion order.
|
||||||
|
"""
|
||||||
|
canonical = json.dumps(
|
||||||
|
{"client_profile": client_profile, "tool": tool, "arguments": arguments},
|
||||||
|
sort_keys=True,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRequest(BaseModel):
|
||||||
|
approval_id: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
|
operation_hash: str
|
||||||
|
client_profile: str
|
||||||
|
tool: str
|
||||||
|
arguments_summary: dict[str, Any]
|
||||||
|
"""A copy of the relevant arguments for an approver to review -- README.md
|
||||||
|
§26 ("com resumo dos argumentos relevantes"). Not redacted in this RI;
|
||||||
|
a production deployment would apply the same PII-minimization rules as
|
||||||
|
audit logging (README.md §27)."""
|
||||||
|
|
||||||
|
reason_code: str
|
||||||
|
requested_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
expires_at: datetime
|
||||||
|
status: Literal["pending", "granted", "denied", "consumed"] = "pending"
|
||||||
|
|
||||||
|
def is_expired(self, *, now: datetime | None = None) -> bool:
|
||||||
|
return (now or datetime.now(UTC)) >= self.expires_at
|
||||||
113
RI/src/emcp_bus/approval/service.py
Normal file
113
RI/src/emcp_bus/approval/service.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""Approval service (EP-14-T02, README.md §26).
|
||||||
|
|
||||||
|
Gates a `REQUIRE_APPROVAL` PDP decision (EP-03-T04): the Gateway calls
|
||||||
|
`check_and_consume` for every attempt of an operation that needs approval; a
|
||||||
|
human approver (CLI, API, or a future Orchestrator human-in-the-loop node,
|
||||||
|
EP-11-T05) calls `request`/`grant`/`deny` out-of-band. Approval is never
|
||||||
|
implicit -- a REQUIRE_APPROVAL attempt with no matching grant is denied, not
|
||||||
|
queued-and-allowed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from emcp_bus.approval.models import ApprovalRequest, compute_operation_hash
|
||||||
|
from emcp_bus.audit.events import approval_granted
|
||||||
|
from emcp_bus.audit.sink import AuditSink
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalService:
|
||||||
|
def __init__(self, audit_sink: AuditSink | None = None) -> None:
|
||||||
|
self._requests: dict[str, ApprovalRequest] = {}
|
||||||
|
self._audit_sink = audit_sink
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client_profile: str,
|
||||||
|
tool: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
reason_code: str,
|
||||||
|
ttl_seconds: int = 300,
|
||||||
|
) -> ApprovalRequest:
|
||||||
|
"""Create (or return the existing pending one for) an approval request
|
||||||
|
for this exact operation -- repeated attempts of the same call while
|
||||||
|
one is pending do not spawn duplicate requests for an approver to
|
||||||
|
wade through."""
|
||||||
|
operation_hash = compute_operation_hash(
|
||||||
|
client_profile=client_profile, tool=tool, arguments=arguments
|
||||||
|
)
|
||||||
|
existing = self._pending_for_operation(operation_hash)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
approval = ApprovalRequest(
|
||||||
|
operation_hash=operation_hash,
|
||||||
|
client_profile=client_profile,
|
||||||
|
tool=tool,
|
||||||
|
arguments_summary=arguments,
|
||||||
|
reason_code=reason_code,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(seconds=ttl_seconds),
|
||||||
|
)
|
||||||
|
self._requests[approval.approval_id] = approval
|
||||||
|
return approval
|
||||||
|
|
||||||
|
def grant(self, approval_id: str) -> None:
|
||||||
|
approval = self._get(approval_id)
|
||||||
|
approval.status = "granted"
|
||||||
|
if self._audit_sink is not None:
|
||||||
|
self._audit_sink.emit(
|
||||||
|
approval_granted(
|
||||||
|
approval_id=approval.approval_id,
|
||||||
|
client_profile=approval.client_profile,
|
||||||
|
tool=approval.tool,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def deny(self, approval_id: str) -> None:
|
||||||
|
approval = self._get(approval_id)
|
||||||
|
approval.status = "denied"
|
||||||
|
|
||||||
|
def check_and_consume(
|
||||||
|
self, *, client_profile: str, tool: str, arguments: dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
"""True exactly once for a matching, granted, unexpired approval.
|
||||||
|
|
||||||
|
Single-use (README.md §46, "approval replay"): a granted approval
|
||||||
|
that passes this check is immediately marked `consumed` and can
|
||||||
|
never satisfy a second attempt, even of the identical operation.
|
||||||
|
"""
|
||||||
|
operation_hash = compute_operation_hash(
|
||||||
|
client_profile=client_profile, tool=tool, arguments=arguments
|
||||||
|
)
|
||||||
|
for approval in self._requests.values():
|
||||||
|
if (
|
||||||
|
approval.operation_hash == operation_hash
|
||||||
|
and approval.status == "granted"
|
||||||
|
and not approval.is_expired()
|
||||||
|
):
|
||||||
|
approval.status = "consumed"
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _pending_for_operation(self, operation_hash: str) -> ApprovalRequest | None:
|
||||||
|
for approval in self._requests.values():
|
||||||
|
if (
|
||||||
|
approval.operation_hash == operation_hash
|
||||||
|
and approval.status == "pending"
|
||||||
|
and not approval.is_expired()
|
||||||
|
):
|
||||||
|
return approval
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get(self, approval_id: str) -> ApprovalRequest:
|
||||||
|
approval = self._requests.get(approval_id)
|
||||||
|
if approval is None:
|
||||||
|
raise ApprovalNotFoundError(approval_id)
|
||||||
|
return approval
|
||||||
0
RI/src/emcp_bus/audit/__init__.py
Normal file
0
RI/src/emcp_bus/audit/__init__.py
Normal file
80
RI/src/emcp_bus/audit/bypass_detection.py
Normal file
80
RI/src/emcp_bus/audit/bypass_detection.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Anti-bypass check for domain ("dumb") MCP servers (EP-05-T07, README.md §21, ADR-007).
|
||||||
|
|
||||||
|
Network segmentation (docker-compose.yml: domain servers publish no host
|
||||||
|
port, reachable only on the compose-internal network the Gateway also sits
|
||||||
|
on) is the primary control. This module is the second, in-process layer:
|
||||||
|
every domain server call must carry the *outbound* credential only the
|
||||||
|
Gateway holds (`BackendCredentialProvider`, EP-07-T01) as a bearer token: a
|
||||||
|
request missing it, or carrying the wrong value, did not come from the
|
||||||
|
Gateway and is rejected here, before it ever reaches a tool handler.
|
||||||
|
|
||||||
|
This is deliberately NOT an authorization decision (ADR-023: domain servers
|
||||||
|
never decide entitlement/risk) -- it is a bypass/integrity check, equivalent
|
||||||
|
in spirit to requiring mTLS from a fixed set of peers. The only decision
|
||||||
|
made here is "did this request present the one credential the Gateway is
|
||||||
|
configured to send", never "is this client allowed to call this tool".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
|
from emcp_bus.audit.events import bypass_attempt_detected
|
||||||
|
from emcp_bus.audit.sink import AuditSink
|
||||||
|
|
||||||
|
|
||||||
|
class BackendCredentialGate:
|
||||||
|
"""ASGI middleware: reject any request not bearing `expected_token`.
|
||||||
|
|
||||||
|
Wrap a domain server's app with this, e.g.:
|
||||||
|
|
||||||
|
app = BackendCredentialGate(server.streamable_http_app(...),
|
||||||
|
expected_token=..., backend="sales-domain",
|
||||||
|
audit_sink=...)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app: ASGIApp,
|
||||||
|
*,
|
||||||
|
expected_token: str,
|
||||||
|
backend: str,
|
||||||
|
audit_sink: AuditSink | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._app = app
|
||||||
|
self._expected_token = expected_token
|
||||||
|
self._backend = backend
|
||||||
|
self._audit_sink = audit_sink
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self._app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
request = Request(scope)
|
||||||
|
presented = _extract_bearer_token(request.headers.get("authorization"))
|
||||||
|
if presented is None or not hmac.compare_digest(presented, self._expected_token):
|
||||||
|
if self._audit_sink is not None:
|
||||||
|
reason = "missing_credential" if presented is None else "wrong_credential"
|
||||||
|
self._audit_sink.emit(bypass_attempt_detected(backend=self._backend, reason=reason))
|
||||||
|
response = JSONResponse(
|
||||||
|
{"error": "backend_credential_required"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bearer_token(authorization_header: str | None) -> str | None:
|
||||||
|
if authorization_header is None:
|
||||||
|
return None
|
||||||
|
scheme, _, value = authorization_header.partition(" ")
|
||||||
|
if scheme.lower() != "bearer" or not value:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
76
RI/src/emcp_bus/audit/correlation.py
Normal file
76
RI/src/emcp_bus/audit/correlation.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""End-to-end correlation (EP-08-T03, README.md §27/§28, ADR-017).
|
||||||
|
|
||||||
|
One `DecisionCorrelation` is created per `tools/call` attempt and threaded
|
||||||
|
through: the PDP decision, the audit events it produces, and the current
|
||||||
|
OTel span (as attributes) -- so a single `decision_id` recovers every event
|
||||||
|
of one authorization attempt, and `policy_decision_id` recovers every event
|
||||||
|
that reused the *same* policy evaluation (relevant for the approval flow,
|
||||||
|
where a REQUIRE_APPROVAL attempt and its later granted retry share
|
||||||
|
`policy_decision_id` but not `decision_id`, since they are two distinct
|
||||||
|
attempts against the same policy_version+client_profile+tool+arguments).
|
||||||
|
|
||||||
|
Deliberately not an ID minted by OPA: this RI's OPA deployment does not use
|
||||||
|
bundle/decision-log infrastructure (see `PDPClient`/`compute_policy_version`),
|
||||||
|
so `policy_decision_id` is a deterministic hash over exactly the inputs the
|
||||||
|
PDP was actually given, which is sufficient to recover "which evaluation was
|
||||||
|
this" without that infrastructure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from opentelemetry import trace
|
||||||
|
|
||||||
|
|
||||||
|
def new_decision_id() -> str:
|
||||||
|
"""Unique per attempt -- never reused, even for a retried identical call."""
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def compute_policy_decision_id(
|
||||||
|
*, policy_version: str, client_profile: str, tool: str, arguments: dict[str, Any]
|
||||||
|
) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
digest.update(policy_version.encode("utf-8"))
|
||||||
|
digest.update(client_profile.encode("utf-8"))
|
||||||
|
digest.update(tool.encode("utf-8"))
|
||||||
|
for key in sorted(arguments):
|
||||||
|
digest.update(f"{key}={arguments[key]!r}".encode())
|
||||||
|
return digest.hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DecisionCorrelation:
|
||||||
|
decision_id: str
|
||||||
|
policy_decision_id: str
|
||||||
|
entitlement_version: str | None
|
||||||
|
policy_version: str | None
|
||||||
|
mcp_request_id: str | None
|
||||||
|
|
||||||
|
def record_on_current_span(self) -> None:
|
||||||
|
"""Expose every correlation field as an attribute on the current OTel
|
||||||
|
span (README.md §27: "expostos no trace e no evento de auditoria") --
|
||||||
|
the span itself is already correlated cross-process via the SDK's
|
||||||
|
`OpenTelemetryMiddleware` (see common/otel.py); this adds the
|
||||||
|
business-level ids the trace_id alone doesn't carry."""
|
||||||
|
span = trace.get_current_span()
|
||||||
|
span.set_attribute("emcp.decision_id", self.decision_id)
|
||||||
|
span.set_attribute("emcp.policy_decision_id", self.policy_decision_id)
|
||||||
|
if self.entitlement_version is not None:
|
||||||
|
span.set_attribute("emcp.entitlement_version", self.entitlement_version)
|
||||||
|
if self.policy_version is not None:
|
||||||
|
span.set_attribute("emcp.policy_version", self.policy_version)
|
||||||
|
if self.mcp_request_id is not None:
|
||||||
|
span.set_attribute("emcp.mcp_request_id", self.mcp_request_id)
|
||||||
|
|
||||||
|
|
||||||
|
def current_trace_id() -> str | None:
|
||||||
|
span = trace.get_current_span()
|
||||||
|
context = span.get_span_context()
|
||||||
|
if context is None or context.trace_id == 0:
|
||||||
|
return None
|
||||||
|
return format(context.trace_id, "032x")
|
||||||
154
RI/src/emcp_bus/audit/events.py
Normal file
154
RI/src/emcp_bus/audit/events.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Audit event taxonomy (EP-08-T02, README.md §27).
|
||||||
|
|
||||||
|
One factory function per event type named in §27's taxonomy (plus the
|
||||||
|
bypass-detection event from EP-05-T07). Each hard-codes its `EventCategory`
|
||||||
|
so callers never have to decide IC/NOC/GRL per call site -- that
|
||||||
|
classification is a property of the event type itself, not of the moment
|
||||||
|
it fires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from emcp_bus.audit.correlation import DecisionCorrelation, current_trace_id
|
||||||
|
from emcp_bus.audit.models import EventCategory, EventEnvelope
|
||||||
|
|
||||||
|
|
||||||
|
def _base(
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
category: EventCategory,
|
||||||
|
client_id: str | None = None,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
tool: str | None = None,
|
||||||
|
reason_code: str | None = None,
|
||||||
|
correlation: DecisionCorrelation | None = None,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> EventEnvelope:
|
||||||
|
return EventEnvelope(
|
||||||
|
event_type=event_type,
|
||||||
|
category=category,
|
||||||
|
client_id=client_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
tool=tool,
|
||||||
|
reason_code=reason_code,
|
||||||
|
decision_id=correlation.decision_id if correlation else None,
|
||||||
|
policy_decision_id=correlation.policy_decision_id if correlation else None,
|
||||||
|
entitlement_version=correlation.entitlement_version if correlation else None,
|
||||||
|
policy_version=correlation.policy_version if correlation else None,
|
||||||
|
mcp_request_id=correlation.mcp_request_id if correlation else None,
|
||||||
|
trace_id=current_trace_id(),
|
||||||
|
payload=payload or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def client_authenticated(*, client_id: str, mcp_request_id: str | None) -> EventEnvelope:
|
||||||
|
return _base(
|
||||||
|
event_type="client_authenticated",
|
||||||
|
category=EventCategory.IC,
|
||||||
|
client_id=client_id,
|
||||||
|
payload={"mcp_request_id": mcp_request_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tools_list_filtered(
|
||||||
|
*, client_id: str, profile_name: str, entitlement_version: str, allowed: int, total: int
|
||||||
|
) -> EventEnvelope:
|
||||||
|
return _base(
|
||||||
|
event_type="tools_list_filtered",
|
||||||
|
category=EventCategory.GRL,
|
||||||
|
client_id=client_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
payload={"entitlement_version": entitlement_version, "allowed": allowed, "total": total},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tool_call_allowed(
|
||||||
|
*, client_id: str, profile_name: str, tool: str, correlation: DecisionCorrelation
|
||||||
|
) -> EventEnvelope:
|
||||||
|
return _base(
|
||||||
|
event_type="tool_call_allowed",
|
||||||
|
category=EventCategory.IC,
|
||||||
|
client_id=client_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
tool=tool,
|
||||||
|
correlation=correlation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tool_call_denied(
|
||||||
|
*,
|
||||||
|
client_id: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
tool: str | None,
|
||||||
|
reason_code: str,
|
||||||
|
correlation: DecisionCorrelation | None = None,
|
||||||
|
) -> EventEnvelope:
|
||||||
|
return _base(
|
||||||
|
event_type="tool_call_denied",
|
||||||
|
category=EventCategory.GRL,
|
||||||
|
client_id=client_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
tool=tool,
|
||||||
|
reason_code=reason_code,
|
||||||
|
correlation=correlation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tool_call_require_approval(
|
||||||
|
*,
|
||||||
|
client_id: str,
|
||||||
|
profile_name: str,
|
||||||
|
tool: str,
|
||||||
|
approval_id: str,
|
||||||
|
correlation: DecisionCorrelation,
|
||||||
|
) -> EventEnvelope:
|
||||||
|
return _base(
|
||||||
|
event_type="tool_call_require_approval",
|
||||||
|
category=EventCategory.GRL,
|
||||||
|
client_id=client_id,
|
||||||
|
profile_name=profile_name,
|
||||||
|
tool=tool,
|
||||||
|
correlation=correlation,
|
||||||
|
payload={"approval_id": approval_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def approval_granted(*, approval_id: str, client_profile: str, tool: str) -> EventEnvelope:
|
||||||
|
return _base(
|
||||||
|
event_type="approval_granted",
|
||||||
|
category=EventCategory.GRL,
|
||||||
|
profile_name=client_profile,
|
||||||
|
tool=tool,
|
||||||
|
payload={"approval_id": approval_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pdp_unavailable(*, client_id: str, tool: str) -> EventEnvelope:
|
||||||
|
return _base(
|
||||||
|
event_type="pdp_unavailable",
|
||||||
|
category=EventCategory.NOC,
|
||||||
|
client_id=client_id,
|
||||||
|
tool=tool,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def backend_credential_unavailable(*, client_id: str, tool: str, backend: str) -> EventEnvelope:
|
||||||
|
return _base(
|
||||||
|
event_type="backend_credential_unavailable",
|
||||||
|
category=EventCategory.NOC,
|
||||||
|
client_id=client_id,
|
||||||
|
tool=tool,
|
||||||
|
payload={"backend": backend},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bypass_attempt_detected(*, backend: str, reason: str) -> EventEnvelope:
|
||||||
|
"""EP-05-T07: a request reached a domain server without the Gateway's
|
||||||
|
outbound credential -- i.e. it did not traverse the PEP."""
|
||||||
|
return _base(
|
||||||
|
event_type="bypass_attempt_detected",
|
||||||
|
category=EventCategory.GRL,
|
||||||
|
payload={"backend": backend, "reason": reason},
|
||||||
|
)
|
||||||
89
RI/src/emcp_bus/audit/models.py
Normal file
89
RI/src/emcp_bus/audit/models.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Audit event schema (EP-08-T02, README.md §27).
|
||||||
|
|
||||||
|
Adopts the IC/NOC/GRL taxonomy from the Agent Platform OCI reference
|
||||||
|
(ADR-022, docs/research/hoshikawa-agent-platform-oci.md) as the
|
||||||
|
`EventEnvelope.category` classification, rather than inventing a parallel
|
||||||
|
one: IC (Interação de Cliente/negócio-jornada), NOC (operacional/erro), GRL
|
||||||
|
(guardrail/governança). `decision_id`/`policy_decision_id`/`entitlement_version`
|
||||||
|
(ADR-017, EP-08-T03) travel as first-class correlation fields on every event,
|
||||||
|
not as opaque payload keys, so a consumer can join events across the chain
|
||||||
|
without parsing `payload`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
_SENSITIVE_KEY_MARKERS = ("token", "secret", "password", "authorization", "credential")
|
||||||
|
|
||||||
|
|
||||||
|
class EventCategory(StrEnum):
|
||||||
|
"""README.md §27 event classes, mapped onto the Hoshikawa IC/NOC/GRL vocabulary."""
|
||||||
|
|
||||||
|
IC = "ic"
|
||||||
|
"""Interação de Cliente / negócio-jornada: the call progressed normally
|
||||||
|
(e.g. `client_authenticated`, `tool_call_allowed`)."""
|
||||||
|
|
||||||
|
NOC = "noc"
|
||||||
|
"""Operacional/erro: an availability/infra failure, not a policy decision
|
||||||
|
(e.g. `pdp_unavailable`, `backend_credential_unavailable`)."""
|
||||||
|
|
||||||
|
GRL = "grl"
|
||||||
|
"""Guardrail/governança: an entitlement/policy boundary was enforced or
|
||||||
|
tested (e.g. `tool_call_denied`, `tools_list_filtered`, `bypass_attempt_detected`)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(value: Any) -> Any:
|
||||||
|
"""Never let a token/secret value reach a sink (§27, "Não registrar").
|
||||||
|
|
||||||
|
Recurses into mappings/sequences; any mapping key whose lowercased name
|
||||||
|
contains a marker in `_SENSITIVE_KEY_MARKERS` has its value replaced by a
|
||||||
|
`sha256:<hex>` reference instead of being dropped -- a reader can still
|
||||||
|
tell two events referenced the *same* secret without ever seeing it.
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
sanitized: dict[str, Any] = {}
|
||||||
|
for key, inner in value.items():
|
||||||
|
if isinstance(key, str) and any(
|
||||||
|
marker in key.lower() for marker in _SENSITIVE_KEY_MARKERS
|
||||||
|
):
|
||||||
|
digest = hashlib.sha256(str(inner).encode("utf-8")).hexdigest()
|
||||||
|
sanitized[key] = f"sha256:{digest}"
|
||||||
|
else:
|
||||||
|
sanitized[key] = _sanitize(inner)
|
||||||
|
return sanitized
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_sanitize(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class EventEnvelope(BaseModel):
|
||||||
|
event_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
||||||
|
event_type: str
|
||||||
|
category: EventCategory
|
||||||
|
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
client_id: str | None = None
|
||||||
|
profile_name: str | None = None
|
||||||
|
tool: str | None = None
|
||||||
|
reason_code: str | None = None
|
||||||
|
|
||||||
|
decision_id: str | None = None
|
||||||
|
policy_decision_id: str | None = None
|
||||||
|
entitlement_version: str | None = None
|
||||||
|
policy_version: str | None = None
|
||||||
|
mcp_request_id: str | None = None
|
||||||
|
trace_id: str | None = None
|
||||||
|
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
def model_post_init(self, __context: Any) -> None:
|
||||||
|
# Sanitize post-construction so every construction path (including
|
||||||
|
# `model_validate` deserialization from a sink) gets the same guarantee.
|
||||||
|
self.payload = _sanitize(self.payload)
|
||||||
38
RI/src/emcp_bus/audit/sink.py
Normal file
38
RI/src/emcp_bus/audit/sink.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Audit event sinks (EP-08-T02).
|
||||||
|
|
||||||
|
Append-only by contract: nothing in this module ever updates or deletes a
|
||||||
|
previously emitted event. `JSONLFileAuditSink` is the dev/deploy default
|
||||||
|
(one JSON object per line); `InMemoryAuditSink` exists purely for tests that
|
||||||
|
need to assert on emitted events without touching a filesystem. A Postgres
|
||||||
|
sink is a documented future extension (same `AuditSink` protocol), not
|
||||||
|
implemented in this RI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from emcp_bus.audit.models import EventEnvelope
|
||||||
|
|
||||||
|
|
||||||
|
class AuditSink(Protocol):
|
||||||
|
def emit(self, event: EventEnvelope) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryAuditSink:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.events: list[EventEnvelope] = []
|
||||||
|
|
||||||
|
def emit(self, event: EventEnvelope) -> None:
|
||||||
|
self.events.append(event)
|
||||||
|
|
||||||
|
|
||||||
|
class JSONLFileAuditSink:
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self._path = Path(path)
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def emit(self, event: EventEnvelope) -> None:
|
||||||
|
with self._path.open("a", encoding="utf-8") as f:
|
||||||
|
f.write(event.model_dump_json() + "\n")
|
||||||
0
RI/src/emcp_bus/downstream/__init__.py
Normal file
0
RI/src/emcp_bus/downstream/__init__.py
Normal file
75
RI/src/emcp_bus/downstream/identity.py
Normal file
75
RI/src/emcp_bus/downstream/identity.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Outbound (backend) identity, distinct from inbound (MCP Client) identity
|
||||||
|
(EP-07-T01, README.md §20, ADR-006).
|
||||||
|
|
||||||
|
inbound token (MCP Client -> Gateway) != outbound token (Fabric -> Backend)
|
||||||
|
|
||||||
|
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`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from emcp_bus.downstream.models import BackendConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownBackendError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BackendCredentialUnavailableError(Exception):
|
||||||
|
"""The configured env var for a backend's service-account token is unset.
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BackendCredential(BaseModel):
|
||||||
|
backend: str
|
||||||
|
url: str
|
||||||
|
token: str
|
||||||
|
audience: str
|
||||||
|
|
||||||
|
|
||||||
|
class BackendCredentialProvider:
|
||||||
|
def __init__(self, backend_configs: list[BackendConfig]) -> None:
|
||||||
|
self._configs = {config.backend: config for config in backend_configs}
|
||||||
|
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.service_account_token_env_var is not None # enforced by BackendConfig
|
||||||
|
token = os.environ.get(config.service_account_token_env_var)
|
||||||
|
if not token:
|
||||||
|
raise BackendCredentialUnavailableError(
|
||||||
|
f"Environment variable {config.service_account_token_env_var!r} for backend "
|
||||||
|
f"{backend!r} is not set"
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
raise UnknownBackendError(backend)
|
||||||
|
return config.url
|
||||||
69
RI/src/emcp_bus/downstream/models.py
Normal file
69
RI/src/emcp_bus/downstream/models.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""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`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Literal, Self
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator, model_validator
|
||||||
|
|
||||||
|
_ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
||||||
|
|
||||||
|
|
||||||
|
class BackendConfig(BaseModel):
|
||||||
|
backend: str
|
||||||
|
"""Adapter/service id, matching `CapabilityManifest.backend.service` (EP-02-T01)."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
"""Where the Fabric router (EP-06-T01) connects to reach this backend --
|
||||||
|
an MCP streamable-http endpoint for `backend.type == "mcp"`, or a REST
|
||||||
|
base URL for `"rest"` (EP-06-T04). Not a secret, so unlike the credential
|
||||||
|
fields below it may be a literal value, though it commonly uses
|
||||||
|
`${VAR}` too since it differs between `make dev` (127.0.0.1) and Docker
|
||||||
|
(the compose service DNS name)."""
|
||||||
|
|
||||||
|
audience: str
|
||||||
|
"""The outbound token's audience -- deliberately never equal to the Gateway's
|
||||||
|
own inbound `OIDC_AUDIENCE` (README.md §20: inbound and outbound authorization
|
||||||
|
are distinct relations)."""
|
||||||
|
|
||||||
|
credential_mode: Literal["service_account", "token_exchange"] = "service_account"
|
||||||
|
|
||||||
|
service_account_token_env_var: str | None = None
|
||||||
|
"""Name (not value) of the environment variable holding the backend's
|
||||||
|
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."""
|
||||||
|
|
||||||
|
@field_validator("service_account_token_env_var")
|
||||||
|
@classmethod
|
||||||
|
def _looks_like_an_env_var_name_not_a_secret(cls, value: str | None) -> str | None:
|
||||||
|
if value is not None and not _ENV_VAR_NAME_PATTERN.match(value):
|
||||||
|
raise ValueError(
|
||||||
|
f"{value!r} does not look like an environment variable name "
|
||||||
|
"(expected e.g. 'SALES_DOMAIN_SERVICE_TOKEN') -- this field must "
|
||||||
|
"never hold a literal secret value (README.md §35.5)"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _required_field_matches_credential_mode(self) -> Self:
|
||||||
|
if self.credential_mode == "service_account" and not self.service_account_token_env_var:
|
||||||
|
raise ValueError(
|
||||||
|
"service_account_token_env_var is required for credential_mode='service_account'"
|
||||||
|
)
|
||||||
|
if self.credential_mode == "token_exchange" and not self.token_exchange_endpoint:
|
||||||
|
raise ValueError(
|
||||||
|
"token_exchange_endpoint is required when credential_mode == 'token_exchange'"
|
||||||
|
)
|
||||||
|
return self
|
||||||
0
RI/src/emcp_bus/fabric/__init__.py
Normal file
0
RI/src/emcp_bus/fabric/__init__.py
Normal file
0
RI/src/emcp_bus/fabric/adapters/__init__.py
Normal file
0
RI/src/emcp_bus/fabric/adapters/__init__.py
Normal file
90
RI/src/emcp_bus/fabric/adapters/rest_adapter.py
Normal file
90
RI/src/emcp_bus/fabric/adapters/rest_adapter.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""Generic REST adapter (EP-06-T04, README.md §6.7).
|
||||||
|
|
||||||
|
Translates one `tools/call` invocation into an HTTP request against a REST
|
||||||
|
backend described entirely by config (`RestBackendConfig`) -- no new Python
|
||||||
|
code is needed to expose a new REST backend, only a YAML declaring, per
|
||||||
|
capability, the HTTP method and a path template (README.md §6.8's
|
||||||
|
`operation: "GET /v2/customers/{id}"` example). Path placeholders are
|
||||||
|
substituted from the call's arguments by name; whatever arguments remain
|
||||||
|
become the JSON body (write methods) or query string (GET/DELETE).
|
||||||
|
|
||||||
|
This RI's two example domains (sales, finance; EP-06-T02/T03) are both
|
||||||
|
MCP-native, so no seed `CapabilityManifest` declares `backend.type: "rest"`
|
||||||
|
yet, and the Fabric router (EP-06-T01) does not dispatch to this adapter on
|
||||||
|
any live path today -- it is implemented and unit-tested standalone (real
|
||||||
|
httpx request/response handling, against an `httpx.MockTransport` in-process
|
||||||
|
endpoint rather than a real network service, since no real REST example
|
||||||
|
backend is in scope). Wiring a third, REST-based example backend through
|
||||||
|
`CapabilityRouter`/the Gateway is possible without any contract change here
|
||||||
|
(dispatch on `BackendRoute.backend_type == "rest"`), left as unscoped,
|
||||||
|
non-blocking future work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class RestOperation(BaseModel):
|
||||||
|
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
||||||
|
path: str
|
||||||
|
"""Path template, e.g. "/v2/customers/{customer_id}"."""
|
||||||
|
|
||||||
|
|
||||||
|
class RestBackendConfig(BaseModel):
|
||||||
|
backend: str
|
||||||
|
base_url: str
|
||||||
|
operations: dict[str, RestOperation]
|
||||||
|
"""Capability name -> operation, e.g. {"sales.customer.get": RestOperation(...)}."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownOperationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RestAdapter:
|
||||||
|
"""One instance per REST backend; `call` runs one capability invocation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, config: RestBackendConfig, *, client: httpx.AsyncClient | None = None
|
||||||
|
) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._client = client or httpx.AsyncClient(base_url=config.base_url)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
async def call(
|
||||||
|
self, capability: str, arguments: dict[str, Any], *, bearer_token: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
operation = self._config.operations.get(capability)
|
||||||
|
if operation is None:
|
||||||
|
raise UnknownOperationError(
|
||||||
|
f"REST backend {self._config.backend!r} has no operation configured "
|
||||||
|
f"for capability {capability!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining = dict(arguments)
|
||||||
|
path = operation.path
|
||||||
|
for key in list(remaining):
|
||||||
|
placeholder = "{" + key + "}"
|
||||||
|
if placeholder in path:
|
||||||
|
path = path.replace(placeholder, str(remaining.pop(key)))
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {bearer_token}"} if bearer_token else {}
|
||||||
|
if operation.method in ("GET", "DELETE"):
|
||||||
|
response = await self._client.request(
|
||||||
|
operation.method, path, params=remaining or None, headers=headers
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = await self._client.request(
|
||||||
|
operation.method, path, json=remaining, headers=headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
if not response.content:
|
||||||
|
return {}
|
||||||
|
result: dict[str, Any] = response.json()
|
||||||
|
return result
|
||||||
88
RI/src/emcp_bus/fabric/router.py
Normal file
88
RI/src/emcp_bus/fabric/router.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Enterprise MCP Fabric core: capability router (EP-06-T01, README.md §6.7).
|
||||||
|
|
||||||
|
Resolves a `<domain>.<capability>` tool name to the backend that actually
|
||||||
|
serves it, using the Global Capability Registry (EP-02-T03) as the single
|
||||||
|
source of truth -- not a Gateway-local static mapping (the pre-M3 state this
|
||||||
|
replaces: `GatewayConfig.downstream_url`/`downstream_backend` hard-coded to
|
||||||
|
one backend, see git history of gateway/server.py). Only `ACTIVE` manifests
|
||||||
|
are routable (README.md §6.6): anything still in nominate/review/register,
|
||||||
|
or already deprecated/retired, is a control-plane-only concern the Fabric
|
||||||
|
must never dispatch a live call to.
|
||||||
|
|
||||||
|
The Registry says *which* backend id serves a capability
|
||||||
|
(`CapabilityManifest.backend.service`); `BackendCredentialProvider` (EP-07)
|
||||||
|
resolves that id to a connection URL and an outbound credential -- the
|
||||||
|
router itself never touches a socket.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from emcp_bus.downstream.identity import BackendCredentialProvider, UnknownBackendError
|
||||||
|
from emcp_bus.registry.models import CapabilityManifest
|
||||||
|
from emcp_bus.registry.store import RegistryStore
|
||||||
|
|
||||||
|
|
||||||
|
class UnroutableCapabilityError(Exception):
|
||||||
|
"""`tool` has no ACTIVE capability manifest, or its backend is unresolvable.
|
||||||
|
|
||||||
|
Callers (the Gateway) must treat this identically to DENY (README.md
|
||||||
|
§38) -- never as "fall back to some default backend".
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BackendRoute:
|
||||||
|
tool: str
|
||||||
|
backend_id: str
|
||||||
|
backend_type: str
|
||||||
|
"""`CapabilityManifest.backend.type`: "mcp" | "rest" | "grpc" (EP-06-T04
|
||||||
|
dispatches "rest" through `fabric.adapters.rest_adapter`; "mcp" is
|
||||||
|
handled directly by the Gateway via a downstream MCP session)."""
|
||||||
|
url: str
|
||||||
|
credential_token: str
|
||||||
|
credential_audience: str
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRouter:
|
||||||
|
def __init__(
|
||||||
|
self, registry: RegistryStore, backend_credentials: BackendCredentialProvider
|
||||||
|
) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
self._backend_credentials = backend_credentials
|
||||||
|
|
||||||
|
def route(self, tool: str) -> BackendRoute:
|
||||||
|
manifest = self._active_manifest(tool)
|
||||||
|
backend_ref = manifest.backend
|
||||||
|
try:
|
||||||
|
credential = self._backend_credentials.credential_for(backend_ref.service)
|
||||||
|
except UnknownBackendError as exc:
|
||||||
|
raise UnroutableCapabilityError(
|
||||||
|
f"Capability {tool!r} references unknown backend {backend_ref.service!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return BackendRoute(
|
||||||
|
tool=tool,
|
||||||
|
backend_id=backend_ref.service,
|
||||||
|
backend_type=backend_ref.type,
|
||||||
|
url=credential.url,
|
||||||
|
credential_token=credential.token,
|
||||||
|
credential_audience=credential.audience,
|
||||||
|
)
|
||||||
|
|
||||||
|
def active_capabilities_for(self, tool_names: frozenset[str]) -> list[CapabilityManifest]:
|
||||||
|
"""Every ACTIVE manifest whose name is in `tool_names` -- lets
|
||||||
|
`on_list_tools` know which distinct backends it must actually query
|
||||||
|
for a given client's entitlement, instead of always querying every
|
||||||
|
registered backend (README.md §6.7)."""
|
||||||
|
return [m for m in self._registry.list_active() if m.name in tool_names]
|
||||||
|
|
||||||
|
def distinct_backends_for(self, tool_names: frozenset[str]) -> set[str]:
|
||||||
|
return {m.backend.service for m in self.active_capabilities_for(tool_names)}
|
||||||
|
|
||||||
|
def _active_manifest(self, tool: str) -> CapabilityManifest:
|
||||||
|
for manifest in self._registry.list_active():
|
||||||
|
if manifest.name == tool:
|
||||||
|
return manifest
|
||||||
|
raise UnroutableCapabilityError(f"No ACTIVE capability named {tool!r}")
|
||||||
66
RI/src/emcp_bus/gateway/cache.py
Normal file
66
RI/src/emcp_bus/gateway/cache.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Private `tools/list` cache hint (EP-05-T03, README.md §13, Threat: Cache
|
||||||
|
leakage §28).
|
||||||
|
|
||||||
|
SEP-2549 (`ttlMs`/`cacheScope`) is a field pair the `mcp` SDK itself defines
|
||||||
|
(`mcp.server.caching.CacheHint`, `types.ListToolsResult.ttl_ms`/`.cache_scope`)
|
||||||
|
-- there is deliberately no hand-rolled HTTP cache here, only this thin
|
||||||
|
wrapper around the SDK's own primitive.
|
||||||
|
|
||||||
|
**Important, verified-empirically limitation of this RI's deployment
|
||||||
|
shape** (not a bug in this module): SEP-2549 belongs to protocol revision
|
||||||
|
2026-07-28, which the SDK implements as an entirely different wire mode --
|
||||||
|
a stateless, session-less per-request envelope (`mcp/server/_streamable_http_modern.py`:
|
||||||
|
"no `initialize` handshake, no `Mcp-Session-Id`") reached via `server/discover`,
|
||||||
|
never via the classic `initialize` handshake. `mcp_types.version.HANDSHAKE_PROTOCOL_VERSIONS`
|
||||||
|
(everything reachable via `ClientSession.initialize()`) and
|
||||||
|
`MODERN_PROTOCOL_VERSIONS` (`("2026-07-28",)`, the stateless mode) are
|
||||||
|
disjoint sets. This RI's entire transport -- the Gateway's inbound side *and*
|
||||||
|
every downstream connection it opens (`_with_downstream_session`) -- uses the
|
||||||
|
classic session handshake exclusively, so a connection here can never
|
||||||
|
negotiate 2026-07-28, and the SDK's own per-version wire sieve
|
||||||
|
(`ServerRunner._serialize`) deliberately strips `ttlMs`/`cacheScope` for
|
||||||
|
every version outside `MODERN_PROTOCOL_VERSIONS` ("2026-era vocabulary
|
||||||
|
cannot be delivered on a legacy wire"). Concretely: `tools_list_cache_hint()`
|
||||||
|
below is computed correctly and set on every `ListToolsResult` `on_list_tools`
|
||||||
|
returns, but on today's wire the client only ever sees the field's own
|
||||||
|
default (`ttl_ms=0`, `cache_scope="private"`) -- which happens to be the
|
||||||
|
maximally conservative fallback (`ttl_ms=0` means "never treat this as
|
||||||
|
fresh," i.e. no caching benefit, but also no risk), not an unsafe one. This
|
||||||
|
code is kept anyway: it costs nothing, is exactly correct if this Gateway
|
||||||
|
ever adds the stateless per-request transport mode, and documents the
|
||||||
|
intended behavior precisely. See
|
||||||
|
`tests/e2e/test_governed_gateway.py::test_tools_list_cache_scope_is_always_private`
|
||||||
|
for what is actually verifiable given this constraint.
|
||||||
|
|
||||||
|
The Gateway's own `on_list_tools` already recomputes `MaximumEntitlement`
|
||||||
|
fresh on every call (no server-side caching of the filtered catalog across
|
||||||
|
clients), so cross-client leakage (Teste 5, §45) cannot happen regardless of
|
||||||
|
what any hint does or doesn't tell a *client* to do locally, and regardless
|
||||||
|
of whether `ttl_ms` reaches the wire: this module's job is telling a
|
||||||
|
well-behaved client on a future/modern transport the two things it would
|
||||||
|
need to cache safely -- how long, and, per SEP-2549's contract, "private"
|
||||||
|
scope, so a cached tools/list result is scoped to a client's own
|
||||||
|
authorization context and never shared with a different one.
|
||||||
|
|
||||||
|
Revocation (EP-04-T03) is honored independently of `ttl_ms`: a revoked
|
||||||
|
client's *next* `tools/list`/`tools/call` is re-evaluated server-side
|
||||||
|
regardless of whether its own local cache still thinks a prior response is
|
||||||
|
fresh -- this hint is an optimization for a well-behaved client, never the
|
||||||
|
Gateway's own source of truth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from mcp.server.caching import CacheHint
|
||||||
|
|
||||||
|
_DEFAULT_TTL_MS = 5_000
|
||||||
|
|
||||||
|
|
||||||
|
def tools_list_cache_hint() -> CacheHint:
|
||||||
|
ttl_ms = int(os.environ.get("EMCP_TOOLS_LIST_CACHE_TTL_MS", str(_DEFAULT_TTL_MS)))
|
||||||
|
# `scope` is never "public": a discovery result depends on the caller's
|
||||||
|
# own MaximumEntitlement, so it must never be treated as reusable across
|
||||||
|
# a different authorization context.
|
||||||
|
return CacheHint(ttl_ms=ttl_ms, scope="private")
|
||||||
119
RI/src/emcp_bus/gateway/canonical_request.py
Normal file
119
RI/src/emcp_bus/gateway/canonical_request.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Header/body consistency check (EP-05-T08, README.md §22, SEP-2243).
|
||||||
|
|
||||||
|
The 2026-07-28 MCP revision has clients mirror the JSON-RPC `method` (and,
|
||||||
|
for `tools/call`/`prompts/get`/`resources/read`, the named resource) into
|
||||||
|
the `Mcp-Method`/`Mcp-Name` HTTP headers -- `mcp`'s own `ClientSession`
|
||||||
|
already sends both on every request (verified against the installed SDK).
|
||||||
|
The SDK's *modern per-request-envelope* transport path validates this
|
||||||
|
consistency internally, but only for that one wire mode (a fresh
|
||||||
|
`_meta.protocolVersion` on every request, no `initialize` handshake) --
|
||||||
|
this RI's Gateway (like most current clients, including its own test
|
||||||
|
suite) uses the classic session handshake instead, where nothing upstream
|
||||||
|
of application code checks it. This module is that check: if both a header
|
||||||
|
and the corresponding body field are present, they must agree, or the
|
||||||
|
request is rejected before it ever reaches authentication/authorization --
|
||||||
|
producing one canonical (method, name) pair a downstream handler can trust,
|
||||||
|
rather than silently picking one of two disagreeing sources (README.md
|
||||||
|
§22, "produzir representação canônica única").
|
||||||
|
|
||||||
|
Headers absent entirely (a client that doesn't implement SEP-2243) are not
|
||||||
|
an error -- this is a consistency check, not a mandate that every client
|
||||||
|
adopt the new headers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, NAME_BEARING_METHODS
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
|
||||||
|
def find_header_body_mismatch(
|
||||||
|
body: bytes, *, method_header: str | None, name_header: str | None
|
||||||
|
) -> str | None:
|
||||||
|
"""Reason string if `body` disagrees with the given header values, else `None`.
|
||||||
|
|
||||||
|
A body that isn't valid JSON, or isn't a JSON-RPC request shape, is left
|
||||||
|
for the app's own JSON-RPC parsing to reject -- this function only rules
|
||||||
|
on cases where it can positively compare a body value against a header.
|
||||||
|
"""
|
||||||
|
if method_header is None and name_header is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed: Any = json.loads(body) if body else None
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
body_method = parsed.get("method")
|
||||||
|
if method_header is not None and isinstance(body_method, str) and method_header != body_method:
|
||||||
|
return (
|
||||||
|
f"{MCP_METHOD_HEADER} header ({method_header!r}) does not match "
|
||||||
|
f"body.method ({body_method!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if name_header is not None and isinstance(body_method, str):
|
||||||
|
name_key = NAME_BEARING_METHODS.get(body_method)
|
||||||
|
if name_key is not None:
|
||||||
|
params = parsed.get("params")
|
||||||
|
body_name = params.get(name_key) if isinstance(params, dict) else None
|
||||||
|
if isinstance(body_name, str) and name_header != body_name:
|
||||||
|
return (
|
||||||
|
f"{MCP_NAME_HEADER} header ({name_header!r}) does not match "
|
||||||
|
f"body.params.{name_key} ({body_name!r})"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalRequestGate:
|
||||||
|
"""ASGI middleware wrapping the Gateway's MCP app: rejects a POST whose
|
||||||
|
`Mcp-Method`/`Mcp-Name` headers disagree with its JSON-RPC body, before
|
||||||
|
the request ever reaches authentication or `on_list_tools`/`on_call_tool`."""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
self._app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http" or scope.get("method") != "POST":
|
||||||
|
await self._app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
key.decode("latin-1").lower(): value.decode("latin-1")
|
||||||
|
for key, value in scope["headers"]
|
||||||
|
}
|
||||||
|
method_header = headers.get(MCP_METHOD_HEADER)
|
||||||
|
name_header = headers.get(MCP_NAME_HEADER)
|
||||||
|
if method_header is None and name_header is None:
|
||||||
|
await self._app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
buffered: list[Message] = []
|
||||||
|
body = b""
|
||||||
|
more_body = True
|
||||||
|
while more_body:
|
||||||
|
message = await receive()
|
||||||
|
buffered.append(message)
|
||||||
|
body += message.get("body", b"")
|
||||||
|
more_body = message.get("more_body", False)
|
||||||
|
|
||||||
|
mismatch = find_header_body_mismatch(
|
||||||
|
body, method_header=method_header, name_header=name_header
|
||||||
|
)
|
||||||
|
if mismatch is not None:
|
||||||
|
response = JSONResponse(
|
||||||
|
{"error": "header_body_mismatch", "detail": mismatch}, status_code=400
|
||||||
|
)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def replay_receive() -> Message:
|
||||||
|
if buffered:
|
||||||
|
return buffered.pop(0)
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
|
||||||
|
await self._app(scope, replay_receive, send)
|
||||||
@@ -15,6 +15,13 @@ not configured for a given deployment, not a bug to work around.
|
|||||||
Fail-closed (README.md §38, EP-05-T06): a PDP that cannot be reached is a
|
Fail-closed (README.md §38, EP-05-T06): a PDP that cannot be reached is a
|
||||||
DENY, not an ALLOW -- see the `PDPUnavailableError` branch in `on_call_tool`.
|
DENY, not an ALLOW -- see the `PDPUnavailableError` branch in `on_call_tool`.
|
||||||
|
|
||||||
|
Routing is resolved per capability through the Enterprise MCP Fabric router
|
||||||
|
(EP-06-T01, `emcp_bus.fabric.router.CapabilityRouter`) against the Global
|
||||||
|
Capability Registry (EP-02-T03), seeded at startup from `config/capabilities/`
|
||||||
|
-- not a hard-coded single downstream. A client's `tools/list` may therefore
|
||||||
|
span more than one backend (e.g. Sales + Finance); `tools/call` always
|
||||||
|
re-resolves the one backend that specific tool routes to.
|
||||||
|
|
||||||
Configuration is read from the environment lazily, inside `lifespan()`/
|
Configuration is read from the environment lazily, inside `lifespan()`/
|
||||||
`build_auth()`, rather than into module-level constants at import time:
|
`build_auth()`, rather than into module-level constants at import time:
|
||||||
every RI service is one process per deployment in production, so this is
|
every RI service is one process per deployment in production, so this is
|
||||||
@@ -23,12 +30,15 @@ lifespan runs within one Python process, which the test suite relies on to
|
|||||||
exercise multiple configurations without re-importing this module).
|
exercise multiple configurations without re-importing this module).
|
||||||
|
|
||||||
Tracing is automatic (see `common/otel.py`): the SDK's `OpenTelemetryMiddleware`
|
Tracing is automatic (see `common/otel.py`): the SDK's `OpenTelemetryMiddleware`
|
||||||
correlates every hop without any manual span code here.
|
correlates every hop without any manual span code here; `emcp_bus.audit.correlation`
|
||||||
|
adds the business-level decision/entitlement/policy ids as span attributes on
|
||||||
|
top of that (EP-08-T03).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -36,23 +46,46 @@ from pathlib import Path
|
|||||||
|
|
||||||
import mcp_types as types
|
import mcp_types as types
|
||||||
from mcp.client.session import ClientSession
|
from mcp.client.session import ClientSession
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import ( # type: ignore[attr-defined]
|
||||||
|
create_mcp_http_client,
|
||||||
|
streamable_http_client,
|
||||||
|
)
|
||||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
from mcp.server.auth.provider import TokenVerifier
|
from mcp.server.auth.provider import TokenVerifier
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.context import ServerRequestContext
|
from mcp.server.context import ServerRequestContext
|
||||||
from mcp.server.lowlevel import Server
|
from mcp.server.lowlevel import Server
|
||||||
from pydantic import AnyHttpUrl
|
from pydantic import AnyHttpUrl
|
||||||
|
from starlette.types import ASGIApp
|
||||||
|
|
||||||
|
from emcp_bus.approval.service import ApprovalService
|
||||||
|
from emcp_bus.audit import events
|
||||||
|
from emcp_bus.audit.correlation import (
|
||||||
|
DecisionCorrelation,
|
||||||
|
compute_policy_decision_id,
|
||||||
|
new_decision_id,
|
||||||
|
)
|
||||||
|
from emcp_bus.audit.sink import AuditSink, JSONLFileAuditSink
|
||||||
from emcp_bus.common.config import load_yaml_models
|
from emcp_bus.common.config import load_yaml_models
|
||||||
from emcp_bus.common.otel import setup_tracing
|
from emcp_bus.common.otel import setup_tracing
|
||||||
|
from emcp_bus.downstream.identity import (
|
||||||
|
BackendCredentialProvider,
|
||||||
|
BackendCredentialUnavailableError,
|
||||||
|
UnknownBackendError,
|
||||||
|
)
|
||||||
|
from emcp_bus.downstream.models import BackendConfig
|
||||||
from emcp_bus.entitlement.manager import EntitlementManager, UnknownClientError
|
from emcp_bus.entitlement.manager import EntitlementManager, UnknownClientError
|
||||||
from emcp_bus.entitlement.models import ClientProfile
|
from emcp_bus.entitlement.models import ClientProfile
|
||||||
|
from emcp_bus.fabric.router import CapabilityRouter, UnroutableCapabilityError
|
||||||
|
from emcp_bus.gateway.cache import tools_list_cache_hint
|
||||||
|
from emcp_bus.gateway.canonical_request import CanonicalRequestGate
|
||||||
from emcp_bus.identity.authn import TokenValidator
|
from emcp_bus.identity.authn import TokenValidator
|
||||||
from emcp_bus.identity.models import ClientRegistration
|
from emcp_bus.identity.models import ClientRegistration
|
||||||
from emcp_bus.identity.token_verifier import MCPTokenVerifier
|
from emcp_bus.identity.token_verifier import MCPTokenVerifier
|
||||||
from emcp_bus.pdp.client import PDPClient, PDPUnavailableError, compute_policy_version
|
from emcp_bus.pdp.client import PDPClient, PDPUnavailableError, compute_policy_version
|
||||||
from emcp_bus.pdp.models import PDPOutcome
|
from emcp_bus.pdp.models import PDPOutcome
|
||||||
|
from emcp_bus.registry.seed import seed_registry
|
||||||
|
from emcp_bus.registry.store import RegistryStore
|
||||||
|
|
||||||
setup_tracing("emcp-gateway")
|
setup_tracing("emcp-gateway")
|
||||||
|
|
||||||
@@ -61,22 +94,22 @@ _REPO_ROOT = Path(__file__).resolve().parents[3]
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GatewayConfig:
|
class GatewayConfig:
|
||||||
downstream_url: str
|
|
||||||
opa_url: str
|
opa_url: str
|
||||||
config_dir: Path
|
config_dir: Path
|
||||||
oidc_jwks_url: str
|
oidc_jwks_url: str
|
||||||
oidc_issuer: str
|
oidc_issuer: str
|
||||||
oidc_audience: str
|
oidc_audience: str
|
||||||
|
audit_log_path: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> GatewayConfig:
|
def from_env(cls) -> GatewayConfig:
|
||||||
return cls(
|
return cls(
|
||||||
downstream_url=os.environ.get("SALES_DOMAIN_URL", "http://127.0.0.1:8100/mcp"),
|
|
||||||
opa_url=os.environ.get("OPA_URL", "http://127.0.0.1:8181"),
|
opa_url=os.environ.get("OPA_URL", "http://127.0.0.1:8181"),
|
||||||
config_dir=Path(os.environ.get("EMCP_CONFIG_DIR", str(_REPO_ROOT / "config"))),
|
config_dir=Path(os.environ.get("EMCP_CONFIG_DIR", str(_REPO_ROOT / "config"))),
|
||||||
oidc_jwks_url=os.environ.get("OIDC_JWKS_URL", ""),
|
oidc_jwks_url=os.environ.get("OIDC_JWKS_URL", ""),
|
||||||
oidc_issuer=os.environ.get("OIDC_ISSUER", ""),
|
oidc_issuer=os.environ.get("OIDC_ISSUER", ""),
|
||||||
oidc_audience=os.environ.get("OIDC_AUDIENCE", "emcp-gateway"),
|
oidc_audience=os.environ.get("OIDC_AUDIENCE", "emcp-gateway"),
|
||||||
|
audit_log_path=os.environ.get("EMCP_AUDIT_LOG_PATH", "/tmp/emcp-audit.jsonl"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -91,16 +124,47 @@ class GatewayConfig:
|
|||||||
def policies_dir(self) -> Path:
|
def policies_dir(self) -> Path:
|
||||||
return self.config_dir / "policies"
|
return self.config_dir / "policies"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backends_dir(self) -> Path:
|
||||||
|
return self.config_dir / "backends"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities_dir(self) -> Path:
|
||||||
|
return self.config_dir / "capabilities"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GatewayState:
|
class GatewayState:
|
||||||
entitlement_manager: EntitlementManager
|
entitlement_manager: EntitlementManager
|
||||||
pdp: PDPClient
|
pdp: PDPClient
|
||||||
downstream_url: str
|
router: CapabilityRouter
|
||||||
|
backend_credentials: BackendCredentialProvider
|
||||||
|
approval_service: ApprovalService
|
||||||
|
audit_sink: AuditSink
|
||||||
|
|
||||||
|
|
||||||
|
_current_state: GatewayState | None = None
|
||||||
|
"""The live `GatewayState` of whichever app instance is currently running.
|
||||||
|
|
||||||
|
Every other per-request need is served by `ctx.lifespan_context`
|
||||||
|
(`on_list_tools`/`on_call_tool`); this module-level mirror exists solely so
|
||||||
|
an out-of-band approver -- a CLI, an HTTP endpoint, a future Orchestrator
|
||||||
|
node (EP-11-T05) -- can reach the *same* `ApprovalService` instance the
|
||||||
|
Gateway's own `on_call_tool` consults, since none of those call sites go
|
||||||
|
through an MCP request context at all. `get_current_state()` is the
|
||||||
|
supported way to read it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_state() -> GatewayState:
|
||||||
|
if _current_state is None:
|
||||||
|
raise RuntimeError("Gateway is not running (lifespan has not started)")
|
||||||
|
return _current_state
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]:
|
async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]:
|
||||||
|
global _current_state
|
||||||
config = GatewayConfig.from_env()
|
config = GatewayConfig.from_env()
|
||||||
pdp = PDPClient(
|
pdp = PDPClient(
|
||||||
base_url=config.opa_url, policy_version=compute_policy_version(config.policies_dir)
|
base_url=config.opa_url, policy_version=compute_policy_version(config.policies_dir)
|
||||||
@@ -111,26 +175,62 @@ async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]:
|
|||||||
# Deliberately not try/excepted: a PDP the Gateway cannot sync entitlements
|
# Deliberately not try/excepted: a PDP the Gateway cannot sync entitlements
|
||||||
# to at startup must not come up looking healthy (README.md §38).
|
# to at startup must not come up looking healthy (README.md §38).
|
||||||
await manager.sync_profiles_to_pdp()
|
await manager.sync_profiles_to_pdp()
|
||||||
try:
|
|
||||||
yield GatewayState(
|
backend_credentials = BackendCredentialProvider(
|
||||||
entitlement_manager=manager, pdp=pdp, downstream_url=config.downstream_url
|
load_yaml_models(config.backends_dir, BackendConfig)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fresh Registry per lifespan run (mirrors `manager`/`backend_credentials`
|
||||||
|
# above): a temp file rather than `config_dir`-relative, so repeated
|
||||||
|
# lifespan runs within one test process (see module docstring) never see
|
||||||
|
# another run's stale state. Also not try/excepted: a Registry the
|
||||||
|
# Gateway cannot seed at startup must not come up looking healthy either.
|
||||||
|
registry_fd, registry_db_path_str = tempfile.mkstemp(prefix="emcp-registry-", suffix=".db")
|
||||||
|
os.close(registry_fd)
|
||||||
|
registry_db_path = Path(registry_db_path_str)
|
||||||
|
registry = RegistryStore(registry_db_path)
|
||||||
|
seed_registry(registry, config.capabilities_dir)
|
||||||
|
router = CapabilityRouter(registry, backend_credentials)
|
||||||
|
|
||||||
|
audit_sink = JSONLFileAuditSink(config.audit_log_path)
|
||||||
|
|
||||||
|
state = GatewayState(
|
||||||
|
entitlement_manager=manager,
|
||||||
|
pdp=pdp,
|
||||||
|
router=router,
|
||||||
|
backend_credentials=backend_credentials,
|
||||||
|
approval_service=ApprovalService(audit_sink=audit_sink),
|
||||||
|
audit_sink=audit_sink,
|
||||||
|
)
|
||||||
|
_current_state = state
|
||||||
|
try:
|
||||||
|
yield state
|
||||||
finally:
|
finally:
|
||||||
|
_current_state = None
|
||||||
await pdp.aclose()
|
await pdp.aclose()
|
||||||
|
registry_db_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
async def _with_downstream_session[ResultT](
|
async def _with_downstream_session[ResultT](
|
||||||
downstream_url: str,
|
downstream_url: str,
|
||||||
|
backend_token: str,
|
||||||
coro_factory: Callable[[ClientSession], Awaitable[ResultT]],
|
coro_factory: Callable[[ClientSession], Awaitable[ResultT]],
|
||||||
) -> ResultT:
|
) -> ResultT:
|
||||||
"""Open a fresh downstream connection for one request and run `coro_factory(session)`.
|
"""Open a fresh downstream connection for one request and run `coro_factory(session)`.
|
||||||
|
|
||||||
A connection per call is simpler and safer than a shared long-lived
|
`backend_token` is the *outbound* credential (EP-07-T01) -- never the
|
||||||
session under concurrent requests; the Enterprise MCP Fabric (EP-06) is
|
inbound MCP Client's own bearer token (README.md §20, ADR-006: no blind
|
||||||
where connection pooling/reuse per backend would be introduced later.
|
token passthrough). A connection per call is simpler and safer than a
|
||||||
|
shared long-lived session under concurrent requests; connection
|
||||||
|
pooling/reuse per backend is a natural extension of the Fabric router
|
||||||
|
(EP-06-T01), not implemented in this milestone.
|
||||||
"""
|
"""
|
||||||
async with (
|
async with (
|
||||||
streamable_http_client(downstream_url) as (read_stream, write_stream),
|
create_mcp_http_client(headers={"Authorization": f"Bearer {backend_token}"}) as http_client,
|
||||||
|
streamable_http_client(downstream_url, http_client=http_client) as (
|
||||||
|
read_stream,
|
||||||
|
write_stream,
|
||||||
|
),
|
||||||
ClientSession(read_stream, write_stream) as session,
|
ClientSession(read_stream, write_stream) as session,
|
||||||
):
|
):
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
@@ -148,6 +248,10 @@ def _denied(reason_code: str) -> types.CallToolResult:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_id_str(ctx: ServerRequestContext[GatewayState]) -> str | None:
|
||||||
|
return str(ctx.request_id) if ctx.request_id is not None else None
|
||||||
|
|
||||||
|
|
||||||
async def on_list_tools(
|
async def on_list_tools(
|
||||||
ctx: ServerRequestContext[GatewayState],
|
ctx: ServerRequestContext[GatewayState],
|
||||||
params: types.PaginatedRequestParams | None,
|
params: types.PaginatedRequestParams | None,
|
||||||
@@ -162,13 +266,42 @@ async def on_list_tools(
|
|||||||
except UnknownClientError:
|
except UnknownClientError:
|
||||||
return types.ListToolsResult(tools=[])
|
return types.ListToolsResult(tools=[])
|
||||||
|
|
||||||
downstream_result = await _with_downstream_session(
|
state.audit_sink.emit(
|
||||||
state.downstream_url, lambda session: session.list_tools(params=params)
|
events.client_authenticated(
|
||||||
|
client_id=access_token.client_id, mcp_request_id=_request_id_str(ctx)
|
||||||
)
|
)
|
||||||
filtered_tools = [
|
)
|
||||||
tool for tool in downstream_result.tools if tool.name in resolved.allowed_tools
|
|
||||||
]
|
# EP-06-T01: query only the backends that actually serve a tool this
|
||||||
return downstream_result.model_copy(update={"tools": filtered_tools})
|
# client is entitled to, never every registered backend -- e.g. a
|
||||||
|
# Sales.Read client never opens a connection to the Finance backend.
|
||||||
|
all_tools: list[types.Tool] = []
|
||||||
|
for backend_id in sorted(state.router.distinct_backends_for(resolved.allowed_tools)):
|
||||||
|
try:
|
||||||
|
credential = state.backend_credentials.credential_for(backend_id)
|
||||||
|
except (UnknownBackendError, BackendCredentialUnavailableError):
|
||||||
|
continue
|
||||||
|
downstream_result = await _with_downstream_session(
|
||||||
|
credential.url, credential.token, lambda session: session.list_tools(params=params)
|
||||||
|
)
|
||||||
|
all_tools.extend(t for t in downstream_result.tools if t.name in resolved.allowed_tools)
|
||||||
|
|
||||||
|
state.audit_sink.emit(
|
||||||
|
events.tools_list_filtered(
|
||||||
|
client_id=access_token.client_id,
|
||||||
|
profile_name=resolved.profile_name,
|
||||||
|
entitlement_version=resolved.entitlement_version,
|
||||||
|
allowed=len(all_tools),
|
||||||
|
total=len(resolved.allowed_tools),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# EP-05-T03: see cache.py's module docstring -- `ttl_ms` will not
|
||||||
|
# actually reach a client on this RI's classic-session transport (a
|
||||||
|
# verified SDK/wire-mode constraint, not a bug here), but `cache_scope`
|
||||||
|
# is always "private" regardless, and this is exactly correct if this
|
||||||
|
# Gateway ever adds the stateless per-request (2026-07-28) transport.
|
||||||
|
hint = tools_list_cache_hint()
|
||||||
|
return types.ListToolsResult(tools=all_tools, ttl_ms=hint.ttl_ms, cache_scope=hint.scope)
|
||||||
|
|
||||||
|
|
||||||
async def on_call_tool(
|
async def on_call_tool(
|
||||||
@@ -185,26 +318,110 @@ async def on_call_tool(
|
|||||||
except UnknownClientError:
|
except UnknownClientError:
|
||||||
return _denied("UNKNOWN_CLIENT")
|
return _denied("UNKNOWN_CLIENT")
|
||||||
|
|
||||||
try:
|
arguments = dict(params.arguments or {})
|
||||||
decision = await state.pdp.evaluate(
|
correlation = DecisionCorrelation(
|
||||||
|
decision_id=new_decision_id(),
|
||||||
|
policy_decision_id=compute_policy_decision_id(
|
||||||
|
policy_version=state.pdp.policy_version,
|
||||||
client_profile=resolved.profile_name,
|
client_profile=resolved.profile_name,
|
||||||
tool=params.name,
|
tool=params.name,
|
||||||
arguments=dict(params.arguments or {}),
|
arguments=arguments,
|
||||||
|
),
|
||||||
|
entitlement_version=resolved.entitlement_version,
|
||||||
|
policy_version=state.pdp.policy_version,
|
||||||
|
mcp_request_id=_request_id_str(ctx),
|
||||||
|
)
|
||||||
|
correlation.record_on_current_span()
|
||||||
|
|
||||||
|
try:
|
||||||
|
decision = await state.pdp.evaluate(
|
||||||
|
client_profile=resolved.profile_name, tool=params.name, arguments=arguments
|
||||||
)
|
)
|
||||||
except PDPUnavailableError:
|
except PDPUnavailableError:
|
||||||
|
state.audit_sink.emit(
|
||||||
|
events.pdp_unavailable(client_id=access_token.client_id, tool=params.name)
|
||||||
|
)
|
||||||
return _denied("PDP_UNAVAILABLE")
|
return _denied("PDP_UNAVAILABLE")
|
||||||
|
|
||||||
if decision.outcome is PDPOutcome.DENY:
|
if decision.outcome is PDPOutcome.DENY:
|
||||||
|
state.audit_sink.emit(
|
||||||
|
events.tool_call_denied(
|
||||||
|
client_id=access_token.client_id,
|
||||||
|
profile_name=resolved.profile_name,
|
||||||
|
tool=params.name,
|
||||||
|
reason_code=decision.reason_code,
|
||||||
|
correlation=correlation,
|
||||||
|
)
|
||||||
|
)
|
||||||
return _denied(decision.reason_code)
|
return _denied(decision.reason_code)
|
||||||
|
|
||||||
if decision.outcome is PDPOutcome.REQUIRE_APPROVAL:
|
if decision.outcome is PDPOutcome.REQUIRE_APPROVAL:
|
||||||
# EP-14 (human approval workflow) does not exist yet in this milestone
|
try:
|
||||||
# -- fail closed rather than silently upgrading to ALLOW.
|
approved = state.approval_service.check_and_consume(
|
||||||
return _denied(f"REQUIRE_APPROVAL:{decision.reason_code}")
|
client_profile=resolved.profile_name, tool=params.name, arguments=arguments
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 -- EP-14-T03: any approval-service failure is DENY, never ALLOW
|
||||||
|
approved = False
|
||||||
|
if not approved:
|
||||||
|
# Out-of-band from here: an approver calls ApprovalService.grant()
|
||||||
|
# with this approval_id (CLI/API today; EP-11-T05's Orchestrator
|
||||||
|
# interrupt node later) before the caller retries the same call.
|
||||||
|
request = state.approval_service.request(
|
||||||
|
client_profile=resolved.profile_name,
|
||||||
|
tool=params.name,
|
||||||
|
arguments=arguments,
|
||||||
|
reason_code=decision.reason_code,
|
||||||
|
)
|
||||||
|
state.audit_sink.emit(
|
||||||
|
events.tool_call_require_approval(
|
||||||
|
client_id=access_token.client_id,
|
||||||
|
profile_name=resolved.profile_name,
|
||||||
|
tool=params.name,
|
||||||
|
approval_id=request.approval_id,
|
||||||
|
correlation=correlation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _denied(f"REQUIRE_APPROVAL:{decision.reason_code}:{request.approval_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
route = state.router.route(params.name)
|
||||||
|
except UnroutableCapabilityError:
|
||||||
|
state.audit_sink.emit(
|
||||||
|
events.tool_call_denied(
|
||||||
|
client_id=access_token.client_id,
|
||||||
|
profile_name=resolved.profile_name,
|
||||||
|
tool=params.name,
|
||||||
|
reason_code="UNROUTABLE_CAPABILITY",
|
||||||
|
correlation=correlation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _denied("UNROUTABLE_CAPABILITY")
|
||||||
|
|
||||||
|
if route.backend_type != "mcp":
|
||||||
|
# EP-06-T04: a "rest"/"grpc" backend.type has no seed capability yet
|
||||||
|
# (see fabric/adapters/rest_adapter.py's module docstring) -- fail
|
||||||
|
# closed rather than guess at a dispatch path with no test coverage.
|
||||||
|
state.audit_sink.emit(
|
||||||
|
events.backend_credential_unavailable(
|
||||||
|
client_id=access_token.client_id, tool=params.name, backend=route.backend_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _denied(f"UNSUPPORTED_BACKEND_TYPE:{route.backend_type}")
|
||||||
|
|
||||||
result = await _with_downstream_session(
|
result = await _with_downstream_session(
|
||||||
state.downstream_url, lambda session: session.call_tool(params.name, params.arguments or {})
|
route.url,
|
||||||
|
route.credential_token,
|
||||||
|
lambda session: session.call_tool(params.name, params.arguments or {}),
|
||||||
)
|
)
|
||||||
if isinstance(result, types.CallToolResult):
|
if isinstance(result, types.CallToolResult):
|
||||||
|
state.audit_sink.emit(
|
||||||
|
events.tool_call_allowed(
|
||||||
|
client_id=access_token.client_id,
|
||||||
|
profile_name=resolved.profile_name,
|
||||||
|
tool=params.name,
|
||||||
|
correlation=correlation,
|
||||||
|
)
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
raise TypeError(f"Unexpected downstream result type for tools/call: {type(result)!r}")
|
raise TypeError(f"Unexpected downstream result type for tools/call: {type(result)!r}")
|
||||||
|
|
||||||
@@ -246,13 +463,27 @@ def build_auth(
|
|||||||
return auth_settings, token_verifier
|
return auth_settings, token_verifier
|
||||||
|
|
||||||
|
|
||||||
|
def build_app(
|
||||||
|
auth_settings: AuthSettings | None,
|
||||||
|
token_verifier: TokenVerifier | None,
|
||||||
|
*,
|
||||||
|
host: str = "127.0.0.1",
|
||||||
|
) -> ASGIApp:
|
||||||
|
"""The Gateway's real ASGI app: the MCP Server wrapped with EP-05-T08's
|
||||||
|
header/body consistency gate. Used by `main()` and by the test suite, so
|
||||||
|
tests exercise the exact same wiring a deployment runs, not a stripped
|
||||||
|
down variant of it."""
|
||||||
|
app = server.streamable_http_app(host=host, auth=auth_settings, token_verifier=token_verifier)
|
||||||
|
return CanonicalRequestGate(app)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
host = os.environ.get("GATEWAY_HOST", "127.0.0.1")
|
host = os.environ.get("GATEWAY_HOST", "127.0.0.1")
|
||||||
port = int(os.environ.get("GATEWAY_PORT", "8000"))
|
port = int(os.environ.get("GATEWAY_PORT", "8000"))
|
||||||
auth_settings, token_verifier = build_auth()
|
auth_settings, token_verifier = build_auth()
|
||||||
app = server.streamable_http_app(host=host, auth=auth_settings, token_verifier=token_verifier)
|
app = build_app(auth_settings, token_verifier, host=host)
|
||||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
45
RI/src/emcp_bus/identity/shared_client_lint.py
Normal file
45
RI/src/emcp_bus/identity/shared_client_lint.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Shared-client lint (EP-01-T05, README.md §18/§35).
|
||||||
|
|
||||||
|
The anti-pattern this closes: two distinct agents registered against the
|
||||||
|
*same* underlying MCP Client (`client_id` -- the same Identity Provider
|
||||||
|
client/secret) but pointing at *different* client profiles. Sharing one
|
||||||
|
`client_id` across agents is only ever safe when every agent that shares it
|
||||||
|
would legitimately carry the same MaximumEntitlement ceiling (README.md
|
||||||
|
§18); if their registrations disagree on `profile`, one of two agents is
|
||||||
|
either over- or under-entitled relative to what its own registration
|
||||||
|
declares, and which one wins is just whichever YAML `load_yaml_models`
|
||||||
|
happens to process last (`EntitlementManager.load_registrations` keys
|
||||||
|
registrations by `client_id` in a plain dict) -- a silent, unaudited
|
||||||
|
authorization change, not a deliberate sharing decision.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from emcp_bus.identity.models import ClientRegistration
|
||||||
|
|
||||||
|
|
||||||
|
class SharedClientLintError(Exception):
|
||||||
|
def __init__(self, violations: list[str]) -> None:
|
||||||
|
self.violations = violations
|
||||||
|
super().__init__("; ".join(violations))
|
||||||
|
|
||||||
|
|
||||||
|
def find_shared_client_violations(registrations: list[ClientRegistration]) -> list[str]:
|
||||||
|
profiles_by_client: dict[str, set[str]] = defaultdict(set)
|
||||||
|
for registration in registrations:
|
||||||
|
profiles_by_client[registration.client_id].add(registration.profile)
|
||||||
|
|
||||||
|
return [
|
||||||
|
f"client_id {client_id!r} is registered against more than one profile: {sorted(profiles)} "
|
||||||
|
"(README.md §18/§35: agents sharing an MCP Client must resolve to the same profile)"
|
||||||
|
for client_id, profiles in sorted(profiles_by_client.items())
|
||||||
|
if len(profiles) > 1
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_shared_client_violations(registrations: list[ClientRegistration]) -> None:
|
||||||
|
violations = find_shared_client_violations(registrations)
|
||||||
|
if violations:
|
||||||
|
raise SharedClientLintError(violations)
|
||||||
44
RI/src/emcp_bus/registry/lifecycle.py
Normal file
44
RI/src/emcp_bus/registry/lifecycle.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Registry lifecycle state machine (EP-02-T04, README.md §23).
|
||||||
|
|
||||||
|
Nominate -> Review -> Register -> Publish -> Observe
|
||||||
|
Observe -> Change -> Publish
|
||||||
|
Observe -> Deprecate -> Retire
|
||||||
|
|
||||||
|
`ACTIVE` collapses README.md's Publish+Observe into one steady state (a
|
||||||
|
manifest that is live and being observed simultaneously, from this RI's
|
||||||
|
point of view -- there is no separate "being observed" flag to track). The
|
||||||
|
one invariant every caller of `transition()` gets for free is the one this
|
||||||
|
task's acceptance criterion names directly: nothing reaches `ACTIVE` without
|
||||||
|
having passed through `REVIEW` first.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from emcp_bus.registry.models import LifecycleStatus
|
||||||
|
|
||||||
|
_ALLOWED_TRANSITIONS: dict[LifecycleStatus, frozenset[LifecycleStatus]] = {
|
||||||
|
LifecycleStatus.NOMINATE: frozenset({LifecycleStatus.REVIEW}),
|
||||||
|
# A rejected review goes back to NOMINATE (revise and resubmit) rather
|
||||||
|
# than staying stuck in REVIEW or disappearing from the registry.
|
||||||
|
LifecycleStatus.REVIEW: frozenset({LifecycleStatus.REGISTER, LifecycleStatus.NOMINATE}),
|
||||||
|
LifecycleStatus.REGISTER: frozenset({LifecycleStatus.ACTIVE}),
|
||||||
|
# "Change" (README.md §23) re-publishes an already-active capability;
|
||||||
|
# modeled as ACTIVE -> ACTIVE rather than a distinct state.
|
||||||
|
LifecycleStatus.ACTIVE: frozenset({LifecycleStatus.ACTIVE, LifecycleStatus.DEPRECATED}),
|
||||||
|
LifecycleStatus.DEPRECATED: frozenset({LifecycleStatus.RETIRED}),
|
||||||
|
LifecycleStatus.RETIRED: frozenset(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidLifecycleTransitionError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def validate_transition(current: LifecycleStatus, target: LifecycleStatus) -> None:
|
||||||
|
"""Raises `InvalidLifecycleTransitionError` unless `current -> target` is
|
||||||
|
an allowed edge of the state machine above."""
|
||||||
|
if target not in _ALLOWED_TRANSITIONS.get(current, frozenset()):
|
||||||
|
raise InvalidLifecycleTransitionError(
|
||||||
|
f"{current.value} -> {target.value} is not an allowed registry "
|
||||||
|
f"lifecycle transition (README.md §23)"
|
||||||
|
)
|
||||||
83
RI/src/emcp_bus/registry/pipeline.py
Normal file
83
RI/src/emcp_bus/registry/pipeline.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""Capability publishing pipeline (EP-02-T02, README.md §6.8).
|
||||||
|
|
||||||
|
Manifest -> [schema | risk | policy | backend gates] -> Gate -> Registry
|
||||||
|
|
||||||
|
Fails closed: any gate rejects the *whole* manifest, nothing partially
|
||||||
|
enters the Registry -- there is no ad hoc manual registration path (RF-11).
|
||||||
|
Schema/naming (name format, required fields) is already enforced by
|
||||||
|
`CapabilityManifest` itself at construction (EP-02-T01); the gates here are
|
||||||
|
the business rules a merely well-typed manifest can still violate.
|
||||||
|
|
||||||
|
A network health check of `backend.service` is a natural extension (README.md
|
||||||
|
§6.8: "health checks") intentionally left out of this milestone -- the RI has
|
||||||
|
no registry of backend health endpoints yet, and gating publication on one
|
||||||
|
would just be a `TODO` masquerading as a check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from emcp_bus.common.models import RiskTier
|
||||||
|
from emcp_bus.registry.lifecycle import validate_transition
|
||||||
|
from emcp_bus.registry.models import CapabilityManifest, LifecycleStatus
|
||||||
|
from emcp_bus.registry.store import RegistryStore
|
||||||
|
|
||||||
|
_HIGH_IMPACT_TIERS = frozenset({RiskTier.R3, RiskTier.R4})
|
||||||
|
|
||||||
|
|
||||||
|
class PublishingRejectedError(Exception):
|
||||||
|
def __init__(self, reasons: list[str]) -> None:
|
||||||
|
self.reasons = reasons
|
||||||
|
super().__init__("; ".join(reasons))
|
||||||
|
|
||||||
|
|
||||||
|
class PublishingPipeline:
|
||||||
|
def __init__(self, store: RegistryStore) -> None:
|
||||||
|
self._store = store
|
||||||
|
|
||||||
|
def submit(self, manifest: CapabilityManifest) -> CapabilityManifest:
|
||||||
|
"""Runs `manifest` through the gates, then NOMINATE -> REVIEW -> REGISTER.
|
||||||
|
|
||||||
|
Stops short of `ACTIVE` -- a registered capability is not yet
|
||||||
|
resolvable by the Fabric until a separate `publish()` call, mirroring
|
||||||
|
README.md §23's Register and Publish being distinct stages.
|
||||||
|
"""
|
||||||
|
reasons = self._run_gates(manifest)
|
||||||
|
if reasons:
|
||||||
|
raise PublishingRejectedError(reasons)
|
||||||
|
|
||||||
|
nominated = manifest.model_copy(
|
||||||
|
update={
|
||||||
|
"lifecycle": manifest.lifecycle.model_copy(
|
||||||
|
update={"status": LifecycleStatus.NOMINATE}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._store.upsert(nominated)
|
||||||
|
|
||||||
|
validate_transition(LifecycleStatus.NOMINATE, LifecycleStatus.REVIEW)
|
||||||
|
self._store.set_lifecycle_status(manifest.name, LifecycleStatus.REVIEW)
|
||||||
|
|
||||||
|
validate_transition(LifecycleStatus.REVIEW, LifecycleStatus.REGISTER)
|
||||||
|
self._store.set_lifecycle_status(manifest.name, LifecycleStatus.REGISTER)
|
||||||
|
|
||||||
|
return self._store.get(manifest.name)
|
||||||
|
|
||||||
|
def publish(self, name: str) -> CapabilityManifest:
|
||||||
|
"""REGISTER -> ACTIVE: the capability becomes resolvable (README.md §23 "Publish")."""
|
||||||
|
manifest = self._store.get(name)
|
||||||
|
validate_transition(manifest.lifecycle.status, LifecycleStatus.ACTIVE)
|
||||||
|
self._store.set_lifecycle_status(name, LifecycleStatus.ACTIVE)
|
||||||
|
return self._store.get(name)
|
||||||
|
|
||||||
|
def _run_gates(self, manifest: CapabilityManifest) -> list[str]:
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not manifest.owner.strip():
|
||||||
|
reasons.append("owner is required")
|
||||||
|
if not manifest.backend.service.strip():
|
||||||
|
reasons.append("backend.service is required")
|
||||||
|
if manifest.risk_tier in _HIGH_IMPACT_TIERS and not manifest.approval.required:
|
||||||
|
reasons.append(
|
||||||
|
f"risk tier {manifest.risk_tier.value} requires approval.required=true "
|
||||||
|
"(README.md §17)"
|
||||||
|
)
|
||||||
|
return reasons
|
||||||
38
RI/src/emcp_bus/registry/seed.py
Normal file
38
RI/src/emcp_bus/registry/seed.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Loads the RI's seed capability manifests (`config/capabilities/<domain>/*.yaml`)
|
||||||
|
through the real publishing pipeline (EP-02-T05) into a `RegistryStore`.
|
||||||
|
|
||||||
|
Shared by the Gateway's own startup (EP-06-T01: the Fabric router needs a
|
||||||
|
populated Registry to resolve anything) and by tests, so both exercise the
|
||||||
|
exact same load-and-publish path -- a schema mistake or a missing
|
||||||
|
`approval.required` on an R3 capability fails in both places identically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from emcp_bus.common.config import load_yaml_models
|
||||||
|
from emcp_bus.registry.models import CapabilityManifest
|
||||||
|
from emcp_bus.registry.pipeline import PublishingPipeline
|
||||||
|
from emcp_bus.registry.store import RegistryStore
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_capability_manifests(capabilities_dir: Path) -> list[CapabilityManifest]:
|
||||||
|
manifests: list[CapabilityManifest] = []
|
||||||
|
for domain_dir in sorted(capabilities_dir.iterdir()):
|
||||||
|
if domain_dir.is_dir():
|
||||||
|
manifests.extend(load_yaml_models(domain_dir, CapabilityManifest))
|
||||||
|
return manifests
|
||||||
|
|
||||||
|
|
||||||
|
def publish_all(store: RegistryStore, manifests: list[CapabilityManifest]) -> None:
|
||||||
|
pipeline = PublishingPipeline(store)
|
||||||
|
for manifest in manifests:
|
||||||
|
pipeline.submit(manifest)
|
||||||
|
pipeline.publish(manifest.name)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_registry(store: RegistryStore, capabilities_dir: Path) -> list[CapabilityManifest]:
|
||||||
|
manifests = load_all_capability_manifests(capabilities_dir)
|
||||||
|
publish_all(store, manifests)
|
||||||
|
return manifests
|
||||||
107
RI/src/emcp_bus/registry/store.py
Normal file
107
RI/src/emcp_bus/registry/store.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Global Capability Registry storage (EP-02-T03, README.md §6.6, ADR-010).
|
||||||
|
|
||||||
|
A control-plane asset, deliberately separate from the per-client catalog
|
||||||
|
view the Gateway serves (README.md §6.6: "O Global Registry é um
|
||||||
|
control-plane asset. Ele não deve ser confundido com a visão de catálogo
|
||||||
|
entregue a cada client."). Consumers (Gateway, Fabric, Offering Filter) only
|
||||||
|
ever read through `list_active`/`get` -- nothing outside the publishing
|
||||||
|
pipeline (EP-02-T02) writes here, and the pipeline never writes directly:
|
||||||
|
it only transitions status through `lifecycle.py`.
|
||||||
|
|
||||||
|
SQLite, used synchronously: this store is not on the Gateway's request
|
||||||
|
hot path in this milestone (routing is still static, EP-06 wires it in
|
||||||
|
later), and SQLite operations here are single-digit milliseconds -- an
|
||||||
|
`asyncio.to_thread` wrapper is a straightforward addition if/when a caller
|
||||||
|
needs this off the event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from emcp_bus.registry.models import CapabilityManifest, LifecycleStatus
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryStore:
|
||||||
|
def __init__(self, db_path: str | Path) -> None:
|
||||||
|
self._db_path = str(db_path)
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS capabilities (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
manifest_json TEXT NOT NULL,
|
||||||
|
lifecycle_status TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connect(self) -> Iterator[sqlite3.Connection]:
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def upsert(self, manifest: CapabilityManifest) -> None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO capabilities (name, domain, manifest_json, lifecycle_status)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
|
domain = excluded.domain,
|
||||||
|
manifest_json = excluded.manifest_json,
|
||||||
|
lifecycle_status = excluded.lifecycle_status
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
manifest.name,
|
||||||
|
manifest.domain,
|
||||||
|
manifest.model_dump_json(),
|
||||||
|
manifest.lifecycle.status.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, name: str) -> CapabilityManifest:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT manifest_json FROM capabilities WHERE name = ?", (name,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise CapabilityNotFoundError(name)
|
||||||
|
return CapabilityManifest.model_validate_json(row[0])
|
||||||
|
|
||||||
|
def list_by_domain(self, domain: str) -> list[CapabilityManifest]:
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT manifest_json FROM capabilities WHERE domain = ? ORDER BY name", (domain,)
|
||||||
|
).fetchall()
|
||||||
|
return [CapabilityManifest.model_validate_json(row[0]) for row in rows]
|
||||||
|
|
||||||
|
def list_active(self) -> list[CapabilityManifest]:
|
||||||
|
"""What Gateway/Fabric/Offering Filter are allowed to read (README.md §6.6) --
|
||||||
|
anything still in nominate/review/register, or already deprecated/retired,
|
||||||
|
is a control-plane-only concern."""
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT manifest_json FROM capabilities WHERE lifecycle_status = ? ORDER BY name",
|
||||||
|
(LifecycleStatus.ACTIVE.value,),
|
||||||
|
).fetchall()
|
||||||
|
return [CapabilityManifest.model_validate_json(row[0]) for row in rows]
|
||||||
|
|
||||||
|
def set_lifecycle_status(self, name: str, status: LifecycleStatus) -> None:
|
||||||
|
manifest = self.get(name)
|
||||||
|
updated = manifest.model_copy(
|
||||||
|
update={"lifecycle": manifest.lifecycle.model_copy(update={"status": status})}
|
||||||
|
)
|
||||||
|
self.upsert(updated)
|
||||||
@@ -37,8 +37,28 @@ ISSUER = "https://keycloak.example/realms/emcp"
|
|||||||
AUDIENCE = "emcp-gateway"
|
AUDIENCE = "emcp-gateway"
|
||||||
KID = "test-key-1"
|
KID = "test-key-1"
|
||||||
|
|
||||||
GATEWAY_PORT = 18000
|
_current_gateway_port: int | None = None
|
||||||
SALES_PORT = 18100
|
"""Set by `governed_stack` (or a test managing its own stack, via
|
||||||
|
`set_current_gateway_port`) for the duration of one test. A *fresh* port
|
||||||
|
per test -- rather than a fixed constant -- avoids TIME_WAIT/bind
|
||||||
|
contention between one test's `RunningServer.stop()` and the next test's
|
||||||
|
bind on the same port, which showed up as intermittent "SSE stream ended
|
||||||
|
without a response" failures once enough e2e tests accumulated to make
|
||||||
|
that race likely within a single pytest run."""
|
||||||
|
|
||||||
|
|
||||||
|
def set_current_gateway_port(port: int | None) -> None:
|
||||||
|
global _current_gateway_port
|
||||||
|
_current_gateway_port = port
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway_url() -> str:
|
||||||
|
if _current_gateway_port is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No gateway is currently running for this test (see governed_stack fixture "
|
||||||
|
"or set_current_gateway_port)"
|
||||||
|
)
|
||||||
|
return f"http://127.0.0.1:{_current_gateway_port}/mcp"
|
||||||
|
|
||||||
|
|
||||||
def free_port() -> int:
|
def free_port() -> int:
|
||||||
@@ -180,19 +200,34 @@ async def governed_stack(
|
|||||||
) -> AsyncIterator[None]:
|
) -> AsyncIterator[None]:
|
||||||
"""Real OPA + real JWKS-backed OIDC auth, wired into fresh Gateway/Sales
|
"""Real OPA + real JWKS-backed OIDC auth, wired into fresh Gateway/Sales
|
||||||
instances for one test (EP-01/EP-03/EP-04/EP-05 all real, only Keycloak
|
instances for one test (EP-01/EP-03/EP-04/EP-05 all real, only Keycloak
|
||||||
itself is substituted by the JWKS server -- see RI/README.md)."""
|
itself is substituted by the JWKS server -- see RI/README.md).
|
||||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{SALES_PORT}/mcp")
|
|
||||||
|
Deliberately Sales-only, not Sales+Finance: most tests using this fixture
|
||||||
|
never reach a live backend call for a Finance tool (denied earlier, at
|
||||||
|
entitlement or REQUIRE_APPROVAL), and this file's own flakiness note
|
||||||
|
documents that more in-process ASGI server pairs per test measurably
|
||||||
|
increases the known e2e flakiness rate. `governed_stack_with_finance`
|
||||||
|
below is the (currently two) tests that actually need a live Finance
|
||||||
|
backend -- keeping the blast radius of that cost limited to just those.
|
||||||
|
"""
|
||||||
|
gateway_port = free_port()
|
||||||
|
sales_port = free_port()
|
||||||
|
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
|
||||||
monkeypatch.setenv("OPA_URL", opa_url)
|
monkeypatch.setenv("OPA_URL", opa_url)
|
||||||
monkeypatch.setenv("OIDC_JWKS_URL", jwks_url)
|
monkeypatch.setenv("OIDC_JWKS_URL", jwks_url)
|
||||||
monkeypatch.setenv("OIDC_ISSUER", ISSUER)
|
monkeypatch.setenv("OIDC_ISSUER", ISSUER)
|
||||||
monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE)
|
monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE)
|
||||||
|
# EP-07: the Gateway's outbound credential to the sales-domain backend --
|
||||||
|
# deliberately a different value/shape than any inbound client token.
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
||||||
|
|
||||||
import example_mcp_servers.sales_domain.server as sales_module
|
import example_mcp_servers.sales_domain.server as sales_module
|
||||||
|
|
||||||
sales_config = uvicorn.Config(
|
sales_config = uvicorn.Config(
|
||||||
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=SALES_PORT,
|
port=sales_port,
|
||||||
log_level="warning",
|
log_level="warning",
|
||||||
)
|
)
|
||||||
sales_server = RunningServer(sales_config)
|
sales_server = RunningServer(sales_config)
|
||||||
@@ -202,23 +237,91 @@ async def governed_stack(
|
|||||||
|
|
||||||
auth_settings, token_verifier = gateway_module.build_auth()
|
auth_settings, token_verifier = gateway_module.build_auth()
|
||||||
gateway_config = uvicorn.Config(
|
gateway_config = uvicorn.Config(
|
||||||
gateway_module.server.streamable_http_app(
|
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||||
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
|
|
||||||
),
|
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=GATEWAY_PORT,
|
port=gateway_port,
|
||||||
log_level="warning",
|
log_level="warning",
|
||||||
)
|
)
|
||||||
gateway_server = RunningServer(gateway_config)
|
gateway_server = RunningServer(gateway_config)
|
||||||
await gateway_server.start()
|
await gateway_server.start()
|
||||||
|
set_current_gateway_port(gateway_port)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
set_current_gateway_port(None)
|
||||||
await gateway_server.stop()
|
await gateway_server.stop()
|
||||||
await sales_server.stop()
|
await sales_server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def governed_stack_with_finance(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, opa_url: str, jwks_url: str
|
||||||
|
) -> AsyncIterator[None]:
|
||||||
|
"""Same as `governed_stack`, plus a real Finance domain server (EP-06-T03) --
|
||||||
|
for the handful of tests that need a live Finance backend to actually
|
||||||
|
reach (e.g. proving the Fabric router dispatches Finance capabilities
|
||||||
|
there, or a full approval-then-execute cycle against it)."""
|
||||||
|
gateway_port = free_port()
|
||||||
|
sales_port = free_port()
|
||||||
|
finance_port = free_port()
|
||||||
|
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
|
||||||
|
monkeypatch.setenv("FINANCE_DOMAIN_URL", f"http://127.0.0.1:{finance_port}/mcp")
|
||||||
|
monkeypatch.setenv("OPA_URL", opa_url)
|
||||||
|
monkeypatch.setenv("OIDC_JWKS_URL", jwks_url)
|
||||||
|
monkeypatch.setenv("OIDC_ISSUER", ISSUER)
|
||||||
|
monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE)
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
||||||
|
monkeypatch.setenv("FINANCE_DOMAIN_SERVICE_TOKEN", "test-finance-domain-backend-credential")
|
||||||
|
|
||||||
|
import example_mcp_servers.sales_domain.server as sales_module
|
||||||
|
|
||||||
|
sales_server = RunningServer(
|
||||||
|
uvicorn.Config(
|
||||||
|
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=sales_port,
|
||||||
|
log_level="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await sales_server.start()
|
||||||
|
|
||||||
|
import example_mcp_servers.finance_domain.server as finance_module
|
||||||
|
|
||||||
|
finance_server = RunningServer(
|
||||||
|
uvicorn.Config(
|
||||||
|
finance_module.server.streamable_http_app(host="127.0.0.1"),
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=finance_port,
|
||||||
|
log_level="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await finance_server.start()
|
||||||
|
|
||||||
|
import emcp_bus.gateway.server as gateway_module
|
||||||
|
|
||||||
|
auth_settings, token_verifier = gateway_module.build_auth()
|
||||||
|
gateway_server = RunningServer(
|
||||||
|
uvicorn.Config(
|
||||||
|
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=gateway_port,
|
||||||
|
log_level="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await gateway_server.start()
|
||||||
|
set_current_gateway_port(gateway_port)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
set_current_gateway_port(None)
|
||||||
|
await gateway_server.stop()
|
||||||
|
await finance_server.stop()
|
||||||
|
await sales_server.stop()
|
||||||
|
|
||||||
|
|
||||||
async def run_against_gateway[ResultT](
|
async def run_against_gateway[ResultT](
|
||||||
operation: Callable[[ClientSession], Awaitable[ResultT]],
|
operation: Callable[[ClientSession], Awaitable[ResultT]],
|
||||||
*,
|
*,
|
||||||
@@ -232,7 +335,7 @@ async def run_against_gateway[ResultT](
|
|||||||
Gateway defect: production deployments run one process per service.
|
Gateway defect: production deployments run one process per service.
|
||||||
"""
|
"""
|
||||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||||
url = f"http://127.0.0.1:{GATEWAY_PORT}/mcp"
|
url = _gateway_url()
|
||||||
last_error: MCPError | None = None
|
last_error: MCPError | None = None
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
if attempt:
|
if attempt:
|
||||||
|
|||||||
@@ -136,13 +136,13 @@ async def test_transaction_policy_denies_over_limit_even_though_entitled(
|
|||||||
assert "TRANSACTION_POLICY_DENIED" in _text(result)
|
assert "TRANSACTION_POLICY_DENIED" in _text(result)
|
||||||
|
|
||||||
|
|
||||||
async def test_require_approval_is_denied_pending_ep14(
|
async def test_require_approval_is_denied_without_a_grant(
|
||||||
governed_stack: None, sign_token: Callable[..., str]
|
governed_stack: None, sign_token: Callable[..., str]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""README.md §26: Finance.Payments allows payment.execute below the approval
|
"""README.md §26: Finance.Payments allows payment.execute above the approval
|
||||||
threshold (README.md §7.2/§17 profile) but must never silently upgrade a
|
threshold (README.md §7.2/§17 profile) but must never silently upgrade a
|
||||||
REQUIRE_APPROVAL to ALLOW -- and EP-14 (approval workflow) does not exist
|
REQUIRE_APPROVAL to ALLOW -- the first attempt of any such call is always
|
||||||
yet in this milestone, so the only safe outcome is DENY."""
|
denied, since no approval has been granted for it yet (EP-14-T02)."""
|
||||||
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||||
|
|
||||||
result = await run_against_gateway(
|
result = await run_against_gateway(
|
||||||
@@ -154,6 +154,152 @@ async def test_require_approval_is_denied_pending_ep14(
|
|||||||
assert "REQUIRE_APPROVAL" in _text(result)
|
assert "REQUIRE_APPROVAL" in _text(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_approval_cycle_grant_then_execute_then_replay_denied(
|
||||||
|
governed_stack: None, sign_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
"""EP-14 end to end: deny (no grant) -> out-of-band grant -> the *same*
|
||||||
|
call now reaches the real backend -> a second attempt of the identical
|
||||||
|
call is denied again (README.md §46, single-use "approval replay").
|
||||||
|
|
||||||
|
Uses Sales.Write + sales.quote.create (approval_threshold=100000,
|
||||||
|
config/clients/profiles/sales-write.yaml); see
|
||||||
|
`test_finance_full_approval_cycle_routes_through_fabric_to_finance_domain`
|
||||||
|
below for the same cycle proven against the Finance domain (EP-06-T03).
|
||||||
|
"""
|
||||||
|
import emcp_bus.gateway.server as gateway_module
|
||||||
|
|
||||||
|
token = sign_token(SALES_WRITE_CLIENT_ID)
|
||||||
|
arguments = {"customer_id": "cust-001", "amount": 150_000, "region": "BR-SP"}
|
||||||
|
|
||||||
|
first_attempt = await run_against_gateway(
|
||||||
|
lambda session: session.call_tool("sales.quote.create", arguments), token=token
|
||||||
|
)
|
||||||
|
assert first_attempt.is_error is True
|
||||||
|
assert "REQUIRE_APPROVAL" in _text(first_attempt)
|
||||||
|
approval_id = _text(first_attempt).rsplit(":", 1)[-1]
|
||||||
|
|
||||||
|
# Out-of-band: an approver grants the specific request the Gateway itself
|
||||||
|
# created (see `on_call_tool`'s REQUIRE_APPROVAL branch) -- not a new one
|
||||||
|
# constructed by the test, proving the Gateway and the approver are
|
||||||
|
# looking at the same `ApprovalService` instance (`get_current_state()`).
|
||||||
|
gateway_module.get_current_state().approval_service.grant(approval_id)
|
||||||
|
|
||||||
|
granted_attempt = await run_against_gateway(
|
||||||
|
lambda session: session.call_tool("sales.quote.create", arguments), token=token
|
||||||
|
)
|
||||||
|
assert granted_attempt.is_error is not True
|
||||||
|
assert granted_attempt.structured_content is not None
|
||||||
|
assert granted_attempt.structured_content["status"] == "draft"
|
||||||
|
|
||||||
|
replay_attempt = await run_against_gateway(
|
||||||
|
lambda session: session.call_tool("sales.quote.create", arguments), token=token
|
||||||
|
)
|
||||||
|
assert replay_attempt.is_error is True
|
||||||
|
assert "REQUIRE_APPROVAL" in _text(replay_attempt)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sales_client_tools_list_never_includes_finance_tools(
|
||||||
|
governed_stack: None, sign_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
"""EP-06-T01: the Fabric router queries only the backends a client's
|
||||||
|
entitlement actually touches -- a Sales.Read client's tools/list never
|
||||||
|
even connects to the Finance backend."""
|
||||||
|
token = sign_token(SALES_READ_CLIENT_ID)
|
||||||
|
|
||||||
|
result = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||||
|
|
||||||
|
tool_names = {tool.name for tool in result.tools}
|
||||||
|
assert tool_names == {
|
||||||
|
"sales.customer.search",
|
||||||
|
"sales.customer.get",
|
||||||
|
"sales.order.get",
|
||||||
|
"sales.inventory.check",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_finance_client_tools_list_never_includes_sales_tools(
|
||||||
|
governed_stack_with_finance: None, sign_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
"""EP-06-T01, the mirror image: a Finance.Payments client's tools/list
|
||||||
|
routes to the Finance backend only, never Sales."""
|
||||||
|
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||||
|
|
||||||
|
result = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||||
|
|
||||||
|
tool_names = {tool.name for tool in result.tools}
|
||||||
|
assert tool_names == {
|
||||||
|
"finance.invoice.get",
|
||||||
|
"finance.payment.create",
|
||||||
|
"finance.payment.execute",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# EP-05-T03's SEP-2549 `ttl_ms`/`cache_scope` fields are per-version wire
|
||||||
|
# vocabulary (`mcp_types._v2026_07_28`) that the SDK's own serialization
|
||||||
|
# sieve strips for every earlier protocol revision (verified: neither field
|
||||||
|
# exists in any pre-2026-07-28 per-version result model at all). This RI's
|
||||||
|
# entire transport uses the classic session `initialize()` handshake, which
|
||||||
|
# never negotiates 2026-07-28 (that revision is reached only via the
|
||||||
|
# stateless per-request `server/discover` mode this RI does not use) -- so
|
||||||
|
# there is no e2e test here asserting on `result.cache_scope`/`result.ttl_ms`:
|
||||||
|
# whatever the Gateway sets internally, a client on this transport only ever
|
||||||
|
# sees the field's own class default, making such an assertion pass
|
||||||
|
# regardless of what the Gateway actually computed. `tools_list_cache_hint()`
|
||||||
|
# itself (always `scope="private"`, configurable `ttl_ms`) is the real,
|
||||||
|
# meaningful test -- see tests/unit/test_gateway_cache.py -- and
|
||||||
|
# `emcp_bus.gateway.cache`'s module docstring has the full, verified
|
||||||
|
# explanation.
|
||||||
|
|
||||||
|
|
||||||
|
async def test_finance_full_approval_cycle_routes_through_fabric_to_finance_domain(
|
||||||
|
governed_stack_with_finance: None, sign_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
"""EP-06-T01/T03 + EP-14 end to end against the *Finance* domain server:
|
||||||
|
the Fabric router resolves finance.payment.create/execute to the Finance
|
||||||
|
backend, and the same deny -> grant -> execute -> replay-denied cycle as
|
||||||
|
`test_full_approval_cycle_grant_then_execute_then_replay_denied` (Sales)
|
||||||
|
holds identically here."""
|
||||||
|
import emcp_bus.gateway.server as gateway_module
|
||||||
|
|
||||||
|
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||||
|
|
||||||
|
create_result = await run_against_gateway(
|
||||||
|
lambda session: session.call_tool(
|
||||||
|
"finance.payment.create", {"invoice_id": "inv-002", "amount": 5_000}
|
||||||
|
),
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
assert create_result.is_error is not True
|
||||||
|
assert create_result.structured_content is not None
|
||||||
|
payment_id = create_result.structured_content["payment_id"]
|
||||||
|
|
||||||
|
execute_arguments = {"payment_id": payment_id, "amount": 50_000}
|
||||||
|
first_attempt = await run_against_gateway(
|
||||||
|
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
assert first_attempt.is_error is True
|
||||||
|
assert "REQUIRE_APPROVAL" in _text(first_attempt)
|
||||||
|
approval_id = _text(first_attempt).rsplit(":", 1)[-1]
|
||||||
|
|
||||||
|
gateway_module.get_current_state().approval_service.grant(approval_id)
|
||||||
|
|
||||||
|
granted_attempt = await run_against_gateway(
|
||||||
|
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
assert granted_attempt.is_error is not True
|
||||||
|
assert granted_attempt.structured_content is not None
|
||||||
|
assert granted_attempt.structured_content["status"] == "executed"
|
||||||
|
|
||||||
|
replay_attempt = await run_against_gateway(
|
||||||
|
lambda session: session.call_tool("finance.payment.execute", execute_arguments),
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
assert replay_attempt.is_error is True
|
||||||
|
assert "REQUIRE_APPROVAL" in _text(replay_attempt)
|
||||||
|
|
||||||
|
|
||||||
async def test_gateway_lifespan_fails_closed_when_pdp_unreachable(
|
async def test_gateway_lifespan_fails_closed_when_pdp_unreachable(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -188,10 +334,19 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
|||||||
"""
|
"""
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from tests.e2e.conftest import GATEWAY_PORT, SALES_PORT, RunningServer, run_against_gateway
|
from tests.e2e.conftest import (
|
||||||
|
RunningServer,
|
||||||
|
free_port,
|
||||||
|
run_against_gateway,
|
||||||
|
set_current_gateway_port,
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{SALES_PORT}/mcp")
|
sales_port = free_port()
|
||||||
|
gateway_port = free_port()
|
||||||
|
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
|
||||||
monkeypatch.setenv("OPA_URL", opa_url)
|
monkeypatch.setenv("OPA_URL", opa_url)
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
||||||
monkeypatch.delenv("OIDC_JWKS_URL", raising=False)
|
monkeypatch.delenv("OIDC_JWKS_URL", raising=False)
|
||||||
monkeypatch.delenv("OIDC_ISSUER", raising=False)
|
monkeypatch.delenv("OIDC_ISSUER", raising=False)
|
||||||
|
|
||||||
@@ -201,7 +356,7 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
|||||||
uvicorn.Config(
|
uvicorn.Config(
|
||||||
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=SALES_PORT,
|
port=sales_port,
|
||||||
log_level="warning",
|
log_level="warning",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -214,15 +369,14 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
|||||||
|
|
||||||
gateway_server = RunningServer(
|
gateway_server = RunningServer(
|
||||||
uvicorn.Config(
|
uvicorn.Config(
|
||||||
gateway_module.server.streamable_http_app(
|
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||||
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
|
|
||||||
),
|
|
||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=GATEWAY_PORT,
|
port=gateway_port,
|
||||||
log_level="warning",
|
log_level="warning",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await gateway_server.start()
|
await gateway_server.start()
|
||||||
|
set_current_gateway_port(gateway_port)
|
||||||
try:
|
try:
|
||||||
list_result = await run_against_gateway(lambda session: session.list_tools())
|
list_result = await run_against_gateway(lambda session: session.list_tools())
|
||||||
assert list_result.tools == []
|
assert list_result.tools == []
|
||||||
@@ -233,6 +387,7 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
|||||||
assert call_result.is_error is True
|
assert call_result.is_error is True
|
||||||
assert "UNAUTHENTICATED" in _text(call_result)
|
assert "UNAUTHENTICATED" in _text(call_result)
|
||||||
finally:
|
finally:
|
||||||
|
set_current_gateway_port(None)
|
||||||
await gateway_server.stop()
|
await gateway_server.stop()
|
||||||
await sales_server.stop()
|
await sales_server.stop()
|
||||||
|
|
||||||
|
|||||||
249
RI/tests/e2e/test_real_keycloak.py
Normal file
249
RI/tests/e2e/test_real_keycloak.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""EP-01-T01: the *actual* Keycloak, not the JWKS-server stand-in the rest of
|
||||||
|
the e2e suite uses (see tests/e2e/conftest.py's module docstring). Real
|
||||||
|
Keycloak 26.7.3 container (Docker required -- skips otherwise), our real
|
||||||
|
`deploy/keycloak/realm-export.json`, real `client_credentials` grants, real
|
||||||
|
tokens validated by the real Gateway/PDP/Entitlement chain.
|
||||||
|
|
||||||
|
Session-scoped: Keycloak's cold start (schema init + realm import) takes
|
||||||
|
~15-20s, too slow to pay per-test. Gated on `docker` being on PATH, exactly
|
||||||
|
like `opa_url`/`OPA_BINARY` in conftest.py -- this whole file is skipped, not
|
||||||
|
failed, in an environment without Docker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncIterator, Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from emcp_bus.identity.authn import AuthenticationError, TokenValidator
|
||||||
|
from tests.e2e.conftest import RunningServer, free_port, set_current_gateway_port
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).parents[2]
|
||||||
|
REALM_EXPORT = REPO_ROOT / "deploy" / "keycloak" / "realm-export.json"
|
||||||
|
|
||||||
|
REALM_NAME = "emcp"
|
||||||
|
CLIENT_SECRETS = {
|
||||||
|
"sales-read-agent": "dev-sales-read-agent-secret",
|
||||||
|
"sales-write-agent": "dev-sales-write-agent-secret",
|
||||||
|
"finance-payments-agent": "dev-finance-payments-agent-secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_binary() -> str | None:
|
||||||
|
return shutil.which("docker")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def keycloak_url() -> Iterator[str]:
|
||||||
|
docker_binary = _docker_binary()
|
||||||
|
if docker_binary is None:
|
||||||
|
pytest.skip("docker not found on PATH -- required for the real-Keycloak suite")
|
||||||
|
|
||||||
|
port = free_port()
|
||||||
|
container_name = f"emcp-ri-test-keycloak-{port}"
|
||||||
|
# `KC_HOSTNAME` fixed to `127.0.0.1:<port>` so every issued token's `iss`
|
||||||
|
# matches what *this test* (running on the host) uses to reach it --
|
||||||
|
# unlike docker-compose.yml's `keycloak` service, which fixes it to the
|
||||||
|
# internal `keycloak:8080` DNS name instead, since there the Gateway
|
||||||
|
# reaches it over the compose network, not localhost. Same underlying
|
||||||
|
# gotcha either way: Keycloak derives `iss` from the request's Host
|
||||||
|
# header unless pinned, so a token fetched via one path and validated
|
||||||
|
# via another fails issuer validation for a boring reason.
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
docker_binary,
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
container_name,
|
||||||
|
"-p",
|
||||||
|
f"{port}:8080",
|
||||||
|
"-e",
|
||||||
|
"KC_BOOTSTRAP_ADMIN_USERNAME=admin",
|
||||||
|
"-e",
|
||||||
|
"KC_BOOTSTRAP_ADMIN_PASSWORD=admin",
|
||||||
|
"-e",
|
||||||
|
# Full URL form, not a bare "host:port" -- Keycloak 26's
|
||||||
|
# `KC_HOSTNAME` only accepts a plain hostname or a complete URL
|
||||||
|
# with scheme ("Provided hostname is neither a plain hostname
|
||||||
|
# nor a valid URL" otherwise); a plain hostname alone can't carry
|
||||||
|
# this test's dynamically allocated port.
|
||||||
|
f"KC_HOSTNAME=http://127.0.0.1:{port}",
|
||||||
|
"-e",
|
||||||
|
"KC_HOSTNAME_STRICT=false",
|
||||||
|
"-v",
|
||||||
|
f"{REALM_EXPORT}:/opt/keycloak/data/import/realm-export.json:ro",
|
||||||
|
"quay.io/keycloak/keycloak:26.7.3",
|
||||||
|
"start-dev",
|
||||||
|
"--import-realm",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
base_url = f"http://127.0.0.1:{port}"
|
||||||
|
try:
|
||||||
|
for _ in range(120):
|
||||||
|
try:
|
||||||
|
response = httpx.get(f"{base_url}/realms/{REALM_NAME}", timeout=1)
|
||||||
|
if response.status_code == 200:
|
||||||
|
break
|
||||||
|
except httpx.HTTPError:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Keycloak did not become ready in time")
|
||||||
|
yield base_url
|
||||||
|
finally:
|
||||||
|
subprocess.run([docker_binary, "rm", "-f", container_name], capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_token(keycloak_url: str, client_id: str) -> str:
|
||||||
|
response = httpx.post(
|
||||||
|
f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": CLIENT_SECRETS[client_id],
|
||||||
|
},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
token: str = response.json()["access_token"]
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
# --- TokenValidator against real Keycloak (no Gateway involved) -----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_keycloak_token_validates_with_the_real_azp_claim(keycloak_url: str) -> None:
|
||||||
|
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||||
|
validator = TokenValidator(
|
||||||
|
jwks_url=f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs",
|
||||||
|
issuer=f"{keycloak_url}/realms/{REALM_NAME}",
|
||||||
|
audience="emcp-gateway",
|
||||||
|
)
|
||||||
|
|
||||||
|
authenticated = validator.validate(token)
|
||||||
|
|
||||||
|
assert authenticated.client_id == "sales-read-agent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_keycloak_token_rejected_for_wrong_audience(keycloak_url: str) -> None:
|
||||||
|
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||||
|
validator = TokenValidator(
|
||||||
|
jwks_url=f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs",
|
||||||
|
issuer=f"{keycloak_url}/realms/{REALM_NAME}",
|
||||||
|
audience="some-other-resource-server",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
validator.validate(token)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Full Gateway chain against real Keycloak ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def governed_stack_with_real_keycloak(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, opa_url: str, keycloak_url: str
|
||||||
|
) -> AsyncIterator[None]:
|
||||||
|
"""Same shape as conftest.py's `governed_stack`, except OIDC points at
|
||||||
|
the real, dockerized Keycloak from `keycloak_url` instead of the JWKS
|
||||||
|
stand-in the rest of the suite uses."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
gateway_port = free_port()
|
||||||
|
sales_port = free_port()
|
||||||
|
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{sales_port}/mcp")
|
||||||
|
monkeypatch.setenv("OPA_URL", opa_url)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"OIDC_JWKS_URL", f"{keycloak_url}/realms/{REALM_NAME}/protocol/openid-connect/certs"
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("OIDC_ISSUER", f"{keycloak_url}/realms/{REALM_NAME}")
|
||||||
|
monkeypatch.setenv("OIDC_AUDIENCE", "emcp-gateway")
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential")
|
||||||
|
|
||||||
|
import example_mcp_servers.sales_domain.server as sales_module
|
||||||
|
|
||||||
|
sales_server = RunningServer(
|
||||||
|
uvicorn.Config(
|
||||||
|
sales_module.server.streamable_http_app(host="127.0.0.1"),
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=sales_port,
|
||||||
|
log_level="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await sales_server.start()
|
||||||
|
|
||||||
|
import emcp_bus.gateway.server as gateway_module
|
||||||
|
|
||||||
|
auth_settings, token_verifier = gateway_module.build_auth()
|
||||||
|
gateway_server = RunningServer(
|
||||||
|
uvicorn.Config(
|
||||||
|
gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"),
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=gateway_port,
|
||||||
|
log_level="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await gateway_server.start()
|
||||||
|
set_current_gateway_port(gateway_port)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
set_current_gateway_port(None)
|
||||||
|
await gateway_server.stop()
|
||||||
|
await sales_server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_chain_with_real_keycloak_token(
|
||||||
|
governed_stack_with_real_keycloak: None, keycloak_url: str
|
||||||
|
) -> None:
|
||||||
|
"""README.md §45 Teste 1, against a real IdP end to end: the Gateway
|
||||||
|
validates a token *actually issued by Keycloak*, resolves the real
|
||||||
|
Sales.Read entitlement, and reaches the real downstream Sales server."""
|
||||||
|
from tests.e2e.conftest import run_against_gateway
|
||||||
|
|
||||||
|
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||||
|
|
||||||
|
tools = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||||
|
assert {t.name for t in tools.tools} == {
|
||||||
|
"sales.customer.search",
|
||||||
|
"sales.customer.get",
|
||||||
|
"sales.order.get",
|
||||||
|
"sales.inventory.check",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await run_against_gateway(
|
||||||
|
lambda session: session.call_tool("sales.customer.get", {"customer_id": "cust-001"}),
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
assert result.is_error is not True
|
||||||
|
assert result.structured_content == {
|
||||||
|
"customer_id": "cust-001",
|
||||||
|
"name": "Aurora Ltda",
|
||||||
|
"region": "BR-SP",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cross_domain_denial_with_real_keycloak_token(
|
||||||
|
governed_stack_with_real_keycloak: None, keycloak_url: str
|
||||||
|
) -> None:
|
||||||
|
"""A real Sales.Read token from real Keycloak still cannot reach Finance."""
|
||||||
|
from tests.e2e.conftest import run_against_gateway
|
||||||
|
|
||||||
|
token = _fetch_token(keycloak_url, "sales-read-agent")
|
||||||
|
|
||||||
|
result = await run_against_gateway(
|
||||||
|
lambda session: session.call_tool("finance.payment.execute", {"amount": 999}),
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
assert result.is_error is True
|
||||||
163
RI/tests/unit/test_approval.py
Normal file
163
RI/tests/unit/test_approval.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""Approval workflow (EP-14-T01/T02/T03, README.md §26, §46 approval replay)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from emcp_bus.approval.models import compute_operation_hash
|
||||||
|
from emcp_bus.approval.service import ApprovalNotFoundError, ApprovalService
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_hash_is_stable_regardless_of_argument_order() -> None:
|
||||||
|
a = compute_operation_hash(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments={"amount": 50_000, "currency": "BRL"},
|
||||||
|
)
|
||||||
|
b = compute_operation_hash(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments={"currency": "BRL", "amount": 50_000},
|
||||||
|
)
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_hash_differs_for_different_arguments() -> None:
|
||||||
|
"""A grant for one exact call must never satisfy a different one (README.md §26)."""
|
||||||
|
a = compute_operation_hash(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments={"amount": 50_000},
|
||||||
|
)
|
||||||
|
b = compute_operation_hash(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments={"amount": 999_999},
|
||||||
|
)
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_unapproved_operation_is_not_consumed() -> None:
|
||||||
|
service = ApprovalService()
|
||||||
|
|
||||||
|
consumed = service.check_and_consume(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments={"amount": 50_000},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert consumed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_then_grant_then_consume_succeeds_exactly_once() -> None:
|
||||||
|
service = ApprovalService()
|
||||||
|
arguments = {"amount": 50_000}
|
||||||
|
request = service.request(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments=arguments,
|
||||||
|
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||||
|
)
|
||||||
|
|
||||||
|
service.grant(request.approval_id)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service.check_and_consume(
|
||||||
|
client_profile="Finance.Payments", tool="finance.payment.execute", arguments=arguments
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
# README.md §46 "approval replay": the second attempt of the *same* call
|
||||||
|
# must not be satisfied by an already-consumed grant.
|
||||||
|
assert (
|
||||||
|
service.check_and_consume(
|
||||||
|
client_profile="Finance.Payments", tool="finance.payment.execute", arguments=arguments
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_denied_request_is_never_consumable() -> None:
|
||||||
|
service = ApprovalService()
|
||||||
|
arguments = {"amount": 50_000}
|
||||||
|
request = service.request(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments=arguments,
|
||||||
|
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||||
|
)
|
||||||
|
|
||||||
|
service.deny(request.approval_id)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service.check_and_consume(
|
||||||
|
client_profile="Finance.Payments", tool="finance.payment.execute", arguments=arguments
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grant_does_not_satisfy_a_different_operation() -> None:
|
||||||
|
"""Granting approval for one amount must not let a *different* amount through."""
|
||||||
|
service = ApprovalService()
|
||||||
|
request = service.request(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments={"amount": 50_000},
|
||||||
|
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||||
|
)
|
||||||
|
service.grant(request.approval_id)
|
||||||
|
|
||||||
|
consumed = service.check_and_consume(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments={"amount": 999_999}, # different operation entirely
|
||||||
|
)
|
||||||
|
|
||||||
|
assert consumed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_grant_is_not_consumable() -> None:
|
||||||
|
service = ApprovalService()
|
||||||
|
arguments = {"amount": 50_000}
|
||||||
|
request = service.request(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments=arguments,
|
||||||
|
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||||
|
ttl_seconds=0,
|
||||||
|
)
|
||||||
|
service.grant(request.approval_id)
|
||||||
|
|
||||||
|
consumed = service.check_and_consume(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments=arguments,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert consumed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_pending_requests_for_the_same_operation_do_not_duplicate() -> None:
|
||||||
|
service = ApprovalService()
|
||||||
|
first = service.request(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments={"amount": 50_000},
|
||||||
|
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||||
|
)
|
||||||
|
second = service.request(
|
||||||
|
client_profile="Finance.Payments",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
arguments={"amount": 50_000},
|
||||||
|
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.approval_id == second.approval_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_grant_unknown_approval_id_raises() -> None:
|
||||||
|
service = ApprovalService()
|
||||||
|
|
||||||
|
with pytest.raises(ApprovalNotFoundError):
|
||||||
|
service.grant("no-such-id")
|
||||||
139
RI/tests/unit/test_audit.py
Normal file
139
RI/tests/unit/test_audit.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Audit events, sinks and correlation (EP-08-T02/T03, README.md §27/§28)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from emcp_bus.audit import events
|
||||||
|
from emcp_bus.audit.correlation import (
|
||||||
|
DecisionCorrelation,
|
||||||
|
compute_policy_decision_id,
|
||||||
|
new_decision_id,
|
||||||
|
)
|
||||||
|
from emcp_bus.audit.models import EventCategory
|
||||||
|
from emcp_bus.audit.sink import InMemoryAuditSink, JSONLFileAuditSink
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_call_denied_is_classified_as_grl() -> None:
|
||||||
|
event = events.tool_call_denied(
|
||||||
|
client_id="sales-read-agent",
|
||||||
|
profile_name="Sales.Read",
|
||||||
|
tool="finance.payment.execute",
|
||||||
|
reason_code="NOT_IN_ENTITLEMENT",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.category is EventCategory.GRL
|
||||||
|
assert event.reason_code == "NOT_IN_ENTITLEMENT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_call_allowed_is_classified_as_ic() -> None:
|
||||||
|
correlation = DecisionCorrelation(
|
||||||
|
decision_id="d1",
|
||||||
|
policy_decision_id="p1",
|
||||||
|
entitlement_version="e1",
|
||||||
|
policy_version="v1",
|
||||||
|
mcp_request_id="1",
|
||||||
|
)
|
||||||
|
event = events.tool_call_allowed(
|
||||||
|
client_id="sales-read-agent",
|
||||||
|
profile_name="Sales.Read",
|
||||||
|
tool="sales.customer.get",
|
||||||
|
correlation=correlation,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.category is EventCategory.IC
|
||||||
|
assert event.decision_id == "d1"
|
||||||
|
assert event.policy_decision_id == "p1"
|
||||||
|
assert event.entitlement_version == "e1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdp_unavailable_is_classified_as_noc() -> None:
|
||||||
|
event = events.pdp_unavailable(client_id="sales-read-agent", tool="sales.customer.get")
|
||||||
|
|
||||||
|
assert event.category is EventCategory.NOC
|
||||||
|
|
||||||
|
|
||||||
|
def test_bypass_attempt_detected_is_classified_as_grl() -> None:
|
||||||
|
event = events.bypass_attempt_detected(backend="sales-domain", reason="missing_credential")
|
||||||
|
|
||||||
|
assert event.category is EventCategory.GRL
|
||||||
|
assert event.payload == {"backend": "sales-domain", "reason": "missing_credential"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_payload_never_contains_a_raw_token_value() -> None:
|
||||||
|
"""README.md §27, 'Não registrar': a payload key that looks like a secret
|
||||||
|
is replaced by a hash reference, never left as the raw value."""
|
||||||
|
event = events.tool_call_allowed(
|
||||||
|
client_id="c1",
|
||||||
|
profile_name="Sales.Read",
|
||||||
|
tool="sales.customer.get",
|
||||||
|
correlation=DecisionCorrelation(
|
||||||
|
decision_id="d1",
|
||||||
|
policy_decision_id="p1",
|
||||||
|
entitlement_version=None,
|
||||||
|
policy_version=None,
|
||||||
|
mcp_request_id=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
event.payload = {"backend_token": "super-secret-value", "customer_id": "cust-001"}
|
||||||
|
# Re-trigger sanitization the way construction does (model_post_init runs
|
||||||
|
# once at construction; this simulates a caller building payload then
|
||||||
|
# assigning it, which the real call sites never do -- included as an
|
||||||
|
# explicit regression guard on the sanitizer itself).
|
||||||
|
from emcp_bus.audit.models import _sanitize
|
||||||
|
|
||||||
|
sanitized = _sanitize(event.payload)
|
||||||
|
assert sanitized["backend_token"].startswith("sha256:")
|
||||||
|
assert sanitized["backend_token"] != "super-secret-value"
|
||||||
|
assert sanitized["customer_id"] == "cust-001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_memory_sink_collects_emitted_events() -> None:
|
||||||
|
sink = InMemoryAuditSink()
|
||||||
|
event = events.client_authenticated(client_id="c1", mcp_request_id="1")
|
||||||
|
|
||||||
|
sink.emit(event)
|
||||||
|
|
||||||
|
assert sink.events == [event]
|
||||||
|
|
||||||
|
|
||||||
|
def test_jsonl_file_sink_appends_one_line_per_event(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "audit" / "events.jsonl"
|
||||||
|
sink = JSONLFileAuditSink(path)
|
||||||
|
|
||||||
|
sink.emit(events.client_authenticated(client_id="c1", mcp_request_id="1"))
|
||||||
|
sink.emit(events.client_authenticated(client_id="c2", mcp_request_id="2"))
|
||||||
|
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
assert len(lines) == 2
|
||||||
|
assert json.loads(lines[0])["client_id"] == "c1"
|
||||||
|
assert json.loads(lines[1])["client_id"] == "c2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_decision_id_is_unique_per_call() -> None:
|
||||||
|
assert new_decision_id() != new_decision_id()
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_decision_id_is_deterministic_for_identical_inputs() -> None:
|
||||||
|
def _compute() -> str:
|
||||||
|
return compute_policy_decision_id(
|
||||||
|
policy_version="v1",
|
||||||
|
client_profile="Sales.Write",
|
||||||
|
tool="sales.quote.create",
|
||||||
|
arguments={"amount": 100, "region": "BR-SP"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _compute() == _compute()
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_decision_id_differs_for_different_arguments() -> None:
|
||||||
|
def _compute(amount: int) -> str:
|
||||||
|
return compute_policy_decision_id(
|
||||||
|
policy_version="v1",
|
||||||
|
client_profile="Sales.Write",
|
||||||
|
tool="sales.quote.create",
|
||||||
|
arguments={"amount": amount},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _compute(100) != _compute(200)
|
||||||
87
RI/tests/unit/test_bypass_detection.py
Normal file
87
RI/tests/unit/test_bypass_detection.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Anti-bypass credential gate for domain servers (EP-05-T07, README.md §21).
|
||||||
|
|
||||||
|
Real ASGI request handling (`httpx.ASGITransport`) against a real
|
||||||
|
`BackendCredentialGate`-wrapped app -- not a hand-mocked request object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from emcp_bus.audit.bypass_detection import BackendCredentialGate
|
||||||
|
from emcp_bus.audit.sink import InMemoryAuditSink
|
||||||
|
|
||||||
|
|
||||||
|
async def _ok(request: object) -> JSONResponse:
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
def _gated_app(audit_sink: InMemoryAuditSink | None = None) -> BackendCredentialGate:
|
||||||
|
inner = Starlette(routes=[Route("/mcp", _ok, methods=["POST"])])
|
||||||
|
return BackendCredentialGate(
|
||||||
|
inner,
|
||||||
|
expected_token="the-real-gateway-credential",
|
||||||
|
backend="sales-domain",
|
||||||
|
audit_sink=audit_sink,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_with_correct_bearer_token_reaches_the_app() -> None:
|
||||||
|
app = _gated_app()
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/mcp", headers={"Authorization": "Bearer the-real-gateway-credential"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_with_no_authorization_header_is_rejected() -> None:
|
||||||
|
sink = InMemoryAuditSink()
|
||||||
|
app = _gated_app(sink)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||||
|
) as client:
|
||||||
|
response = await client.post("/mcp")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert len(sink.events) == 1
|
||||||
|
assert sink.events[0].event_type == "bypass_attempt_detected"
|
||||||
|
assert sink.events[0].payload["reason"] == "missing_credential"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_with_wrong_bearer_token_is_rejected() -> None:
|
||||||
|
"""This is the concrete Teste 7 (§45, 'Gateway bypass') scenario: someone
|
||||||
|
who can reach the domain server's port directly, but does not hold the
|
||||||
|
Gateway's outbound service credential, must not reach a tool handler."""
|
||||||
|
sink = InMemoryAuditSink()
|
||||||
|
app = _gated_app(sink)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/mcp", headers={"Authorization": "Bearer someone-elses-token"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert sink.events[0].payload["reason"] == "wrong_credential"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_stolen_inbound_client_token_does_not_satisfy_the_gate() -> None:
|
||||||
|
"""README.md §20/ADR-006: even a real, valid *inbound* MCP Client token
|
||||||
|
is not the outbound credential -- presenting one here must still fail."""
|
||||||
|
app = _gated_app()
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/mcp", headers={"Authorization": "Bearer some-inbound-oidc-access-token"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
72
RI/tests/unit/test_canonical_request.py
Normal file
72
RI/tests/unit/test_canonical_request.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Header/body consistency check (EP-05-T08, README.md §22, SEP-2243)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from emcp_bus.gateway.canonical_request import find_header_body_mismatch
|
||||||
|
|
||||||
|
|
||||||
|
def _body(method: str, params: dict[str, object] | None = None) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
{"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_headers_present_is_never_a_mismatch() -> None:
|
||||||
|
assert (
|
||||||
|
find_header_body_mismatch(_body("tools/call"), method_header=None, name_header=None) is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_method_header_is_not_a_mismatch() -> None:
|
||||||
|
body = _body("tools/list")
|
||||||
|
|
||||||
|
assert find_header_body_mismatch(body, method_header="tools/list", name_header=None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatched_method_header_is_rejected() -> None:
|
||||||
|
body = _body("tools/list")
|
||||||
|
|
||||||
|
reason = find_header_body_mismatch(body, method_header="tools/call", name_header=None)
|
||||||
|
|
||||||
|
assert reason is not None
|
||||||
|
assert "mcp-method" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_name_header_for_tools_call_is_not_a_mismatch() -> None:
|
||||||
|
body = _body("tools/call", {"name": "sales.customer.get", "arguments": {}})
|
||||||
|
|
||||||
|
reason = find_header_body_mismatch(
|
||||||
|
body, method_header="tools/call", name_header="sales.customer.get"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert reason is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatched_name_header_for_tools_call_is_rejected() -> None:
|
||||||
|
"""The concrete attack this closes: a header claiming one tool while the
|
||||||
|
body actually invokes another -- whichever a naive reader trusted, the
|
||||||
|
request must instead be rejected outright (README.md §22)."""
|
||||||
|
body = _body("tools/call", {"name": "finance.payment.execute", "arguments": {}})
|
||||||
|
|
||||||
|
reason = find_header_body_mismatch(
|
||||||
|
body, method_header="tools/call", name_header="sales.customer.get"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert reason is not None
|
||||||
|
assert "mcp-name" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_header_is_irrelevant_for_a_non_name_bearing_method() -> None:
|
||||||
|
body = _body("tools/list")
|
||||||
|
|
||||||
|
reason = find_header_body_mismatch(body, method_header="tools/list", name_header="anything")
|
||||||
|
|
||||||
|
assert reason is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unparseable_body_is_left_to_the_app_itself() -> None:
|
||||||
|
reason = find_header_body_mismatch(b"not json", method_header="tools/list", name_header=None)
|
||||||
|
|
||||||
|
assert reason is None
|
||||||
127
RI/tests/unit/test_downstream_identity.py
Normal file
127
RI/tests/unit/test_downstream_identity.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Outbound (backend) identity (EP-07-T01/T02, README.md §20)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from emcp_bus.downstream.identity import (
|
||||||
|
BackendCredentialProvider,
|
||||||
|
BackendCredentialUnavailableError,
|
||||||
|
UnknownBackendError,
|
||||||
|
)
|
||||||
|
from emcp_bus.downstream.models import BackendConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_config_rejects_a_literal_secret_in_the_env_var_name_field() -> None:
|
||||||
|
"""README.md §35.5: credentials must never live inline in config."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
BackendConfig(
|
||||||
|
backend="sales-domain",
|
||||||
|
url="http://127.0.0.1:8100/mcp",
|
||||||
|
audience="sales-domain-service",
|
||||||
|
service_account_token_env_var="eyJhbGciOiJSUzI1NiJ9.not-an-env-var-name",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_config_requires_env_var_for_service_account_mode() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
BackendConfig(
|
||||||
|
backend="sales-domain", url="http://127.0.0.1:8100/mcp", audience="sales-domain-service"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_config_requires_endpoint_for_token_exchange_mode() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
BackendConfig(
|
||||||
|
backend="sales-domain",
|
||||||
|
url="http://127.0.0.1:8100/mcp",
|
||||||
|
audience="sales-domain-service",
|
||||||
|
credential_mode="token_exchange",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_for_configured_backend_resolves_from_env(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "backend-secret-token")
|
||||||
|
provider = BackendCredentialProvider(
|
||||||
|
[
|
||||||
|
BackendConfig(
|
||||||
|
backend="sales-domain",
|
||||||
|
url="http://127.0.0.1:8100/mcp",
|
||||||
|
audience="sales-domain-service",
|
||||||
|
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
credential = provider.credential_for("sales-domain")
|
||||||
|
|
||||||
|
assert credential.token == "backend-secret-token"
|
||||||
|
assert credential.audience == "sales-domain-service"
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_differs_from_a_hypothetical_inbound_client_token(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The property README.md §20/ADR-006 actually cares about: whatever the
|
||||||
|
inbound MCP Client token was, the outbound credential is never it."""
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "backend-secret-token")
|
||||||
|
provider = BackendCredentialProvider(
|
||||||
|
[
|
||||||
|
BackendConfig(
|
||||||
|
backend="sales-domain",
|
||||||
|
url="http://127.0.0.1:8100/mcp",
|
||||||
|
audience="sales-domain-service",
|
||||||
|
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
inbound_client_token = "inbound-oidc-access-token" # nosec -- test fixture, not a real secret
|
||||||
|
|
||||||
|
credential = provider.credential_for("sales-domain")
|
||||||
|
|
||||||
|
assert credential.token != inbound_client_token
|
||||||
|
assert credential.audience != "emcp-gateway" # the Gateway's own inbound audience
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_backend_raises() -> None:
|
||||||
|
provider = BackendCredentialProvider([])
|
||||||
|
|
||||||
|
with pytest.raises(UnknownBackendError):
|
||||||
|
provider.credential_for("no-such-backend")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_env_var_at_call_time_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv("SALES_DOMAIN_SERVICE_TOKEN", raising=False)
|
||||||
|
provider = BackendCredentialProvider(
|
||||||
|
[
|
||||||
|
BackendConfig(
|
||||||
|
backend="sales-domain",
|
||||||
|
url="http://127.0.0.1:8100/mcp",
|
||||||
|
audience="sales-domain-service",
|
||||||
|
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(BackendCredentialUnavailableError):
|
||||||
|
provider.credential_for("sales-domain")
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_exchange_mode_is_a_documented_but_unimplemented_extension() -> None:
|
||||||
|
provider = BackendCredentialProvider(
|
||||||
|
[
|
||||||
|
BackendConfig(
|
||||||
|
backend="sales-domain",
|
||||||
|
url="http://127.0.0.1:8100/mcp",
|
||||||
|
audience="sales-domain-service",
|
||||||
|
credential_mode="token_exchange",
|
||||||
|
token_exchange_endpoint="https://keycloak.example/realms/emcp/token",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
provider.credential_for("sales-domain")
|
||||||
114
RI/tests/unit/test_fabric_router.py
Normal file
114
RI/tests/unit/test_fabric_router.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Fabric core capability router (EP-06-T01, README.md §6.7).
|
||||||
|
|
||||||
|
Real Registry (seeded from the actual config/capabilities/ manifests, same
|
||||||
|
as tests/unit/test_registry_seed_manifests.py) + real BackendCredentialProvider,
|
||||||
|
not hand-built fixtures -- a config mistake in either fails here too.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from emcp_bus.downstream.identity import BackendCredentialProvider
|
||||||
|
from emcp_bus.downstream.models import BackendConfig
|
||||||
|
from emcp_bus.fabric.router import CapabilityRouter, UnroutableCapabilityError
|
||||||
|
from emcp_bus.registry.models import LifecycleStatus
|
||||||
|
from emcp_bus.registry.seed import load_all_capability_manifests, publish_all
|
||||||
|
from emcp_bus.registry.store import RegistryStore
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).parents[2]
|
||||||
|
CAPABILITIES_DIR = REPO_ROOT / "config" / "capabilities"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def router(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CapabilityRouter:
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "sales-secret")
|
||||||
|
monkeypatch.setenv("FINANCE_DOMAIN_SERVICE_TOKEN", "finance-secret")
|
||||||
|
|
||||||
|
store = RegistryStore(tmp_path / "registry.db")
|
||||||
|
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
|
||||||
|
|
||||||
|
backend_credentials = BackendCredentialProvider(
|
||||||
|
[
|
||||||
|
BackendConfig(
|
||||||
|
backend="sales-domain",
|
||||||
|
url="http://127.0.0.1:8100/mcp",
|
||||||
|
audience="sales-domain-service",
|
||||||
|
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
||||||
|
),
|
||||||
|
BackendConfig(
|
||||||
|
backend="finance-domain",
|
||||||
|
url="http://127.0.0.1:8101/mcp",
|
||||||
|
audience="finance-domain-service",
|
||||||
|
service_account_token_env_var="FINANCE_DOMAIN_SERVICE_TOKEN",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return CapabilityRouter(store, backend_credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sales_capability_routes_to_sales_backend_not_finance(router: CapabilityRouter) -> None:
|
||||||
|
"""README.md §6.7 EP-06-T01 acceptance: a Sales capability must reach the
|
||||||
|
Sales backend, never Finance."""
|
||||||
|
route = router.route("sales.customer.get")
|
||||||
|
|
||||||
|
assert route.backend_id == "sales-domain"
|
||||||
|
assert route.url == "http://127.0.0.1:8100/mcp"
|
||||||
|
assert route.credential_token == "sales-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finance_capability_routes_to_finance_backend(router: CapabilityRouter) -> None:
|
||||||
|
route = router.route("finance.payment.execute")
|
||||||
|
|
||||||
|
assert route.backend_id == "finance-domain"
|
||||||
|
assert route.url == "http://127.0.0.1:8101/mcp"
|
||||||
|
assert route.credential_token == "finance-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_capability_is_unroutable(router: CapabilityRouter) -> None:
|
||||||
|
with pytest.raises(UnroutableCapabilityError):
|
||||||
|
router.route("sales.does.not.exist")
|
||||||
|
|
||||||
|
|
||||||
|
def test_deprecated_capability_is_unroutable(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Only ACTIVE capabilities are routable (README.md §6.6) -- a capability
|
||||||
|
that was published then deprecated must stop being reachable."""
|
||||||
|
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "sales-secret")
|
||||||
|
store = RegistryStore(tmp_path / "registry.db")
|
||||||
|
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
|
||||||
|
store.set_lifecycle_status("sales.customer.get", LifecycleStatus.DEPRECATED)
|
||||||
|
|
||||||
|
backend_credentials = BackendCredentialProvider(
|
||||||
|
[
|
||||||
|
BackendConfig(
|
||||||
|
backend="sales-domain",
|
||||||
|
url="http://127.0.0.1:8100/mcp",
|
||||||
|
audience="sales-domain-service",
|
||||||
|
service_account_token_env_var="SALES_DOMAIN_SERVICE_TOKEN",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
router = CapabilityRouter(store, backend_credentials)
|
||||||
|
|
||||||
|
with pytest.raises(UnroutableCapabilityError):
|
||||||
|
router.route("sales.customer.get")
|
||||||
|
|
||||||
|
|
||||||
|
def test_distinct_backends_for_sales_only_entitlement_excludes_finance(
|
||||||
|
router: CapabilityRouter,
|
||||||
|
) -> None:
|
||||||
|
sales_read_tools = frozenset(
|
||||||
|
{"sales.customer.search", "sales.customer.get", "sales.order.get", "sales.inventory.check"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert router.distinct_backends_for(sales_read_tools) == {"sales-domain"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_distinct_backends_for_mixed_entitlement_includes_both(router: CapabilityRouter) -> None:
|
||||||
|
mixed_tools = frozenset({"sales.customer.get", "finance.invoice.get"})
|
||||||
|
|
||||||
|
assert router.distinct_backends_for(mixed_tools) == {"sales-domain", "finance-domain"}
|
||||||
23
RI/tests/unit/test_gateway_cache.py
Normal file
23
RI/tests/unit/test_gateway_cache.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Private tools/list cache hint (EP-05-T03, README.md §13/§28)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from emcp_bus.gateway.cache import tools_list_cache_hint
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_hint_is_private_scope_with_a_positive_ttl() -> None:
|
||||||
|
hint = tools_list_cache_hint()
|
||||||
|
|
||||||
|
assert hint.scope == "private"
|
||||||
|
assert hint.ttl_ms > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ttl_is_configurable_via_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("EMCP_TOOLS_LIST_CACHE_TTL_MS", "12345")
|
||||||
|
|
||||||
|
hint = tools_list_cache_hint()
|
||||||
|
|
||||||
|
assert hint.ttl_ms == 12345
|
||||||
|
assert hint.scope == "private" # never configurable to "public" (Teste 5, §45)
|
||||||
165
RI/tests/unit/test_registry.py
Normal file
165
RI/tests/unit/test_registry.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""Registry store + publishing pipeline + lifecycle state machine (EP-02-T02/T03/T04)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from emcp_bus.registry.lifecycle import InvalidLifecycleTransitionError, validate_transition
|
||||||
|
from emcp_bus.registry.models import CapabilityManifest, LifecycleStatus
|
||||||
|
from emcp_bus.registry.pipeline import PublishingPipeline, PublishingRejectedError
|
||||||
|
from emcp_bus.registry.store import CapabilityNotFoundError, RegistryStore
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(**overrides: object) -> CapabilityManifest:
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"name": "sales.customer.get",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"domain": "sales",
|
||||||
|
"owner": "sales-platform",
|
||||||
|
"risk_tier": "R1",
|
||||||
|
"side_effects": False,
|
||||||
|
"idempotent": True,
|
||||||
|
"entitlements": ["Sales.Read"],
|
||||||
|
"backend": {"type": "mcp", "service": "sales-domain"},
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return CapabilityManifest.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path: Path) -> Iterator[RegistryStore]:
|
||||||
|
yield RegistryStore(tmp_path / "registry.db")
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_and_get_round_trip(store: RegistryStore) -> None:
|
||||||
|
store.upsert(_manifest())
|
||||||
|
|
||||||
|
fetched = store.get("sales.customer.get")
|
||||||
|
|
||||||
|
assert fetched.owner == "sales-platform"
|
||||||
|
assert fetched.risk_tier.value == "R1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_missing_capability_raises(store: RegistryStore) -> None:
|
||||||
|
with pytest.raises(CapabilityNotFoundError):
|
||||||
|
store.get("no.such.capability")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_active_excludes_non_active_capabilities(store: RegistryStore) -> None:
|
||||||
|
store.upsert(_manifest(name="sales.customer.get")) # default lifecycle: NOMINATE
|
||||||
|
active = _manifest(name="sales.order.get")
|
||||||
|
active = active.model_copy(
|
||||||
|
update={"lifecycle": active.lifecycle.model_copy(update={"status": LifecycleStatus.ACTIVE})}
|
||||||
|
)
|
||||||
|
store.upsert(active)
|
||||||
|
|
||||||
|
result = store.list_active()
|
||||||
|
|
||||||
|
assert [m.name for m in result] == ["sales.order.get"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_by_domain_filters_correctly(store: RegistryStore) -> None:
|
||||||
|
store.upsert(_manifest(name="sales.customer.get", domain="sales"))
|
||||||
|
store.upsert(
|
||||||
|
_manifest(
|
||||||
|
name="finance.invoice.get",
|
||||||
|
domain="finance",
|
||||||
|
entitlements=["Finance.Read"],
|
||||||
|
backend={"type": "mcp", "service": "finance-domain"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [m.name for m in store.list_by_domain("finance")] == ["finance.invoice.get"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- lifecycle state machine ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_nominate_to_review_is_allowed() -> None:
|
||||||
|
validate_transition(LifecycleStatus.NOMINATE, LifecycleStatus.REVIEW)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nominate_directly_to_active_is_rejected() -> None:
|
||||||
|
"""The task's own acceptance criterion: never skip REVIEW on the way to ACTIVE."""
|
||||||
|
with pytest.raises(InvalidLifecycleTransitionError):
|
||||||
|
validate_transition(LifecycleStatus.NOMINATE, LifecycleStatus.ACTIVE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_directly_to_active_requires_no_skip_but_is_the_publish_step() -> None:
|
||||||
|
validate_transition(LifecycleStatus.REGISTER, LifecycleStatus.ACTIVE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retired_is_terminal() -> None:
|
||||||
|
with pytest.raises(InvalidLifecycleTransitionError):
|
||||||
|
validate_transition(LifecycleStatus.RETIRED, LifecycleStatus.ACTIVE)
|
||||||
|
|
||||||
|
|
||||||
|
# --- publishing pipeline -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_submit_then_publish_reaches_active(store: RegistryStore) -> None:
|
||||||
|
pipeline = PublishingPipeline(store)
|
||||||
|
|
||||||
|
registered = pipeline.submit(_manifest())
|
||||||
|
assert registered.lifecycle.status is LifecycleStatus.REGISTER
|
||||||
|
|
||||||
|
published = pipeline.publish("sales.customer.get")
|
||||||
|
assert published.lifecycle.status is LifecycleStatus.ACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_rejects_high_risk_capability_without_approval(store: RegistryStore) -> None:
|
||||||
|
pipeline = PublishingPipeline(store)
|
||||||
|
manifest = _manifest(
|
||||||
|
name="finance.payment.execute",
|
||||||
|
domain="finance",
|
||||||
|
risk_tier="R3",
|
||||||
|
side_effects=True,
|
||||||
|
idempotent=False,
|
||||||
|
entitlements=["Finance.Payments"],
|
||||||
|
backend={"type": "mcp", "service": "finance-domain"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PublishingRejectedError) as exc_info:
|
||||||
|
pipeline.submit(manifest)
|
||||||
|
|
||||||
|
assert any("approval" in reason for reason in exc_info.value.reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_accepts_high_risk_capability_with_approval_declared(store: RegistryStore) -> None:
|
||||||
|
pipeline = PublishingPipeline(store)
|
||||||
|
manifest = _manifest(
|
||||||
|
name="finance.payment.execute",
|
||||||
|
domain="finance",
|
||||||
|
risk_tier="R3",
|
||||||
|
side_effects=True,
|
||||||
|
idempotent=False,
|
||||||
|
entitlements=["Finance.Payments"],
|
||||||
|
backend={"type": "mcp", "service": "finance-domain"},
|
||||||
|
approval={"required": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
registered = pipeline.submit(manifest)
|
||||||
|
|
||||||
|
assert registered.lifecycle.status is LifecycleStatus.REGISTER
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_rejects_manifest_with_blank_owner(store: RegistryStore) -> None:
|
||||||
|
pipeline = PublishingPipeline(store)
|
||||||
|
|
||||||
|
with pytest.raises(PublishingRejectedError) as exc_info:
|
||||||
|
pipeline.submit(_manifest(owner=" "))
|
||||||
|
|
||||||
|
assert any("owner" in reason for reason in exc_info.value.reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_before_register_is_rejected(store: RegistryStore) -> None:
|
||||||
|
"""A manifest sitting in NOMINATE/REVIEW (e.g. review not yet approved)
|
||||||
|
must not be publishable -- README.md §23 governance lifecycle."""
|
||||||
|
store.upsert(_manifest()) # lifecycle defaults to NOMINATE
|
||||||
|
|
||||||
|
pipeline = PublishingPipeline(store)
|
||||||
|
with pytest.raises(InvalidLifecycleTransitionError):
|
||||||
|
pipeline.publish("sales.customer.get")
|
||||||
57
RI/tests/unit/test_registry_seed_manifests.py
Normal file
57
RI/tests/unit/test_registry_seed_manifests.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""EP-02-T05: the real seed manifests under config/capabilities/ go through the
|
||||||
|
real pipeline end to end -- not hand-built fixtures, so a schema mistake or a
|
||||||
|
missing `approval.required` on an R3 capability fails here too."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from emcp_bus.registry.models import LifecycleStatus
|
||||||
|
from emcp_bus.registry.seed import load_all_capability_manifests, publish_all
|
||||||
|
from emcp_bus.registry.store import RegistryStore
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).parents[2]
|
||||||
|
CAPABILITIES_DIR = REPO_ROOT / "config" / "capabilities"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_seed_manifests_publish_successfully(tmp_path: Path) -> None:
|
||||||
|
store = RegistryStore(tmp_path / "registry.db")
|
||||||
|
manifests = load_all_capability_manifests(CAPABILITIES_DIR)
|
||||||
|
|
||||||
|
assert len(manifests) == 9 # 6 sales + 3 finance -- update this if seed data changes
|
||||||
|
|
||||||
|
publish_all(store, manifests)
|
||||||
|
|
||||||
|
active = store.list_active()
|
||||||
|
assert len(active) == len(manifests)
|
||||||
|
assert all(m.lifecycle.status is LifecycleStatus.ACTIVE for m in active)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_manifests_by_domain(tmp_path: Path) -> None:
|
||||||
|
store = RegistryStore(tmp_path / "registry.db")
|
||||||
|
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
|
||||||
|
|
||||||
|
sales_tools = {m.name for m in store.list_by_domain("sales")}
|
||||||
|
finance_tools = {m.name for m in store.list_by_domain("finance")}
|
||||||
|
|
||||||
|
assert sales_tools == {
|
||||||
|
"sales.customer.search",
|
||||||
|
"sales.customer.get",
|
||||||
|
"sales.order.get",
|
||||||
|
"sales.inventory.check",
|
||||||
|
"sales.quote.create",
|
||||||
|
"sales.quote.update",
|
||||||
|
}
|
||||||
|
assert finance_tools == {
|
||||||
|
"finance.invoice.get",
|
||||||
|
"finance.payment.create",
|
||||||
|
"finance.payment.execute",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_finance_payment_execute_is_r3_with_approval_required() -> None:
|
||||||
|
manifests = {m.name: m for m in load_all_capability_manifests(CAPABILITIES_DIR)}
|
||||||
|
payment_execute = manifests["finance.payment.execute"]
|
||||||
|
|
||||||
|
assert payment_execute.risk_tier.value == "R3"
|
||||||
|
assert payment_execute.approval.required is True
|
||||||
104
RI/tests/unit/test_rest_adapter.py
Normal file
104
RI/tests/unit/test_rest_adapter.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""Generic REST adapter (EP-06-T04, README.md §6.7).
|
||||||
|
|
||||||
|
Real httpx request/response handling against an `httpx.MockTransport`
|
||||||
|
in-process endpoint -- no real REST backend exists in this RI (both example
|
||||||
|
domains are MCP-native, see the module docstring in rest_adapter.py), so this
|
||||||
|
is the closest equivalent to "real dependency, fake network" available for
|
||||||
|
this specific adapter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from emcp_bus.fabric.adapters.rest_adapter import (
|
||||||
|
RestAdapter,
|
||||||
|
RestBackendConfig,
|
||||||
|
RestOperation,
|
||||||
|
UnknownOperationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIG = RestBackendConfig(
|
||||||
|
backend="crm-rest",
|
||||||
|
base_url="https://crm.example",
|
||||||
|
operations={
|
||||||
|
"crm.customer.get": RestOperation(method="GET", path="/v2/customers/{customer_id}"),
|
||||||
|
"crm.customer.create": RestOperation(method="POST", path="/v2/customers"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _adapter(handler: httpx.MockTransport) -> RestAdapter:
|
||||||
|
client = httpx.AsyncClient(base_url=CONFIG.base_url, transport=handler)
|
||||||
|
return RestAdapter(CONFIG, client=client)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_operation_substitutes_path_placeholder_and_sends_remaining_as_query() -> None:
|
||||||
|
captured: dict[str, httpx.Request] = {}
|
||||||
|
|
||||||
|
def handle(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured["request"] = request
|
||||||
|
return httpx.Response(200, json={"customer_id": "cust-001", "name": "Aurora Ltda"})
|
||||||
|
|
||||||
|
adapter = _adapter(httpx.MockTransport(handle))
|
||||||
|
|
||||||
|
result = await adapter.call("crm.customer.get", {"customer_id": "cust-001", "region": "BR-SP"})
|
||||||
|
|
||||||
|
assert result == {"customer_id": "cust-001", "name": "Aurora Ltda"}
|
||||||
|
request = captured["request"]
|
||||||
|
assert request.url.path == "/v2/customers/cust-001"
|
||||||
|
assert dict(request.url.params) == {"region": "BR-SP"}
|
||||||
|
await adapter.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_operation_sends_remaining_arguments_as_json_body() -> None:
|
||||||
|
captured: dict[str, httpx.Request] = {}
|
||||||
|
|
||||||
|
def handle(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured["request"] = request
|
||||||
|
return httpx.Response(201, json={"customer_id": "cust-002"})
|
||||||
|
|
||||||
|
adapter = _adapter(httpx.MockTransport(handle))
|
||||||
|
|
||||||
|
result = await adapter.call("crm.customer.create", {"name": "Boreal S.A."})
|
||||||
|
|
||||||
|
assert result == {"customer_id": "cust-002"}
|
||||||
|
body = json.loads(captured["request"].content)
|
||||||
|
assert body == {"name": "Boreal S.A."}
|
||||||
|
await adapter.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bearer_token_is_forwarded_as_authorization_header() -> None:
|
||||||
|
captured: dict[str, httpx.Request] = {}
|
||||||
|
|
||||||
|
def handle(request: httpx.Request) -> httpx.Response:
|
||||||
|
captured["request"] = request
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
|
||||||
|
adapter = _adapter(httpx.MockTransport(handle))
|
||||||
|
|
||||||
|
await adapter.call(
|
||||||
|
"crm.customer.get", {"customer_id": "cust-001"}, bearer_token="outbound-token"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["request"].headers["authorization"] == "Bearer outbound-token"
|
||||||
|
await adapter.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unconfigured_capability_raises() -> None:
|
||||||
|
adapter = _adapter(httpx.MockTransport(lambda request: httpx.Response(200, json={})))
|
||||||
|
|
||||||
|
with pytest.raises(UnknownOperationError):
|
||||||
|
await adapter.call("crm.customer.delete", {})
|
||||||
|
await adapter.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_backend_error_status_raises() -> None:
|
||||||
|
adapter = _adapter(httpx.MockTransport(lambda request: httpx.Response(500)))
|
||||||
|
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
await adapter.call("crm.customer.get", {"customer_id": "cust-001"})
|
||||||
|
await adapter.aclose()
|
||||||
72
RI/tests/unit/test_shared_client_lint.py
Normal file
72
RI/tests/unit/test_shared_client_lint.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Shared-client lint (EP-01-T05, README.md §18/§35)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from emcp_bus.identity.models import ClientRegistration
|
||||||
|
from emcp_bus.identity.shared_client_lint import (
|
||||||
|
SharedClientLintError,
|
||||||
|
assert_no_shared_client_violations,
|
||||||
|
find_shared_client_violations,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distinct_client_ids_are_never_a_violation() -> None:
|
||||||
|
registrations = [
|
||||||
|
ClientRegistration(client_id="sales-read-agent", profile="Sales.Read"),
|
||||||
|
ClientRegistration(client_id="sales-write-agent", profile="Sales.Write"),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert find_shared_client_violations(registrations) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_client_id_and_same_profile_is_not_a_violation() -> None:
|
||||||
|
"""Sharing is fine when both registrations agree on the profile -- this
|
||||||
|
is the legitimate case README.md §18 describes."""
|
||||||
|
registrations = [
|
||||||
|
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
|
||||||
|
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert find_shared_client_violations(registrations) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_client_id_with_different_profiles_is_a_violation() -> None:
|
||||||
|
registrations = [
|
||||||
|
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
|
||||||
|
ClientRegistration(client_id="shared-agent", profile="Finance.Payments"),
|
||||||
|
]
|
||||||
|
|
||||||
|
violations = find_shared_client_violations(registrations)
|
||||||
|
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert "shared-agent" in violations[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_raises_with_all_violations_when_present() -> None:
|
||||||
|
registrations = [
|
||||||
|
ClientRegistration(client_id="shared-agent", profile="Sales.Read"),
|
||||||
|
ClientRegistration(client_id="shared-agent", profile="Finance.Payments"),
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(SharedClientLintError):
|
||||||
|
assert_no_shared_client_violations(registrations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_does_not_raise_when_clean() -> None:
|
||||||
|
registrations = [ClientRegistration(client_id="sales-read-agent", profile="Sales.Read")]
|
||||||
|
|
||||||
|
assert_no_shared_client_violations(registrations) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_real_seed_client_registrations_have_no_violations() -> None:
|
||||||
|
"""The RI's own config/clients/*.yaml must pass its own lint."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from emcp_bus.common.config import load_yaml_models
|
||||||
|
|
||||||
|
repo_root = Path(__file__).parents[2]
|
||||||
|
registrations = load_yaml_models(repo_root / "config" / "clients", ClientRegistration)
|
||||||
|
|
||||||
|
assert_no_shared_client_violations(registrations) # must not raise
|
||||||
@@ -1509,8 +1509,8 @@ backlog:
|
|||||||
| Marco | Tarefas | Critério de "pronto" |
|
| Marco | Tarefas | Critério de "pronto" |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **M0 — Walking skeleton** | EP-00-T01, EP-00-T02, EP-00-T04, EP-06-T02, EP-05-T01, EP-08-T01 | Cliente MCP → Gateway → (roteamento estático) → MCP Server de exemplo (Sales) → resposta, com `trace_id` correlacionado visível em log/coletor OTel. Sem enforcement real ainda. |
|
| **M0 — Walking skeleton** | EP-00-T01, EP-00-T02, EP-00-T04, EP-06-T02, EP-05-T01, EP-08-T01 | Cliente MCP → Gateway → (roteamento estático) → MCP Server de exemplo (Sales) → resposta, com `trace_id` correlacionado visível em log/coletor OTel. Sem enforcement real ainda. |
|
||||||
| **M1 — Segurança núcleo (Fase 1 do README)** | EP-01 completo, EP-03 completo, EP-04 completo, EP-05-T02/T03/T04/T06/T07/T08, EP-02-T01/T02/T03/T05, EP-06-T01/T03/T04, EP-08-T02/T03/T04 | `tools/list` filtrado por entitlement real (Keycloak+OPA), `tools/call` reautorizado, cache privado, fail-closed, anti-bypass, auditoria completa. Cobre RF-01 a RF-09 e Fases 0–2 do roadmap do README. |
|
| **M1 — Segurança núcleo (Fase 1 do README) ✅ concluído (2026-09-11)** | EP-01 completo, EP-03 completo, EP-04 completo, EP-05-T02/T03/T04/T06/T07/T08, EP-02-T01/T02/T03/T05 (via M2), EP-06-T01/T03/T04, EP-08-T02/T03/T04 | `tools/list` filtrado por entitlement real (Keycloak+OPA), `tools/call` reautorizado, cache privado, fail-closed, anti-bypass, auditoria completa. Cobre RF-01 a RF-09 e Fases 0–2 do roadmap do README. **Adendo (2026-09-11, fechamento retroativo pós-M2)**: todos os itens acima que ainda restavam abertos após o M2 (EP-01-T04/T05, EP-05-T03/T07/T08, EP-06-T01/T03/T04, EP-08-T02/T03/T04) foram implementados nesta rodada — Fabric router real (multi-backend, EP-06-T01), segundo domínio Finance com backend real (EP-06-T03), adapter REST genérico standalone (EP-06-T04), taxonomia de auditoria IC/NOC/GRL + correlação decisionId/policyDecisionId/entitlementVersion (EP-08-T02/T03), stack local otel-collector+Jaeger (EP-08-T04), cache hint SEP-2549 (EP-05-T03 — ver limitação de wire-mode abaixo), segmentação de rede + gate de credencial nos domain servers (EP-05-T07), validação header/body Mcp-Method/Mcp-Name (EP-05-T08), lint de shared client (EP-01-T05), spike de mTLS documentado em `docs/adr/ADR-019-identity-extension.md` (EP-01-T04). **Limitação real descoberta e documentada**: SEP-2549 (`ttlMs`) é vocabulário exclusivo do protocolo 2026-07-28, que só existe no modo *stateless per-request* do SDK (`server/discover`, sem handshake `initialize`) — esta RI usa o handshake de sessão clássico em toda parte, então o campo nunca chega ao wire nesta arquitetura (o SDK descarta o campo na serialização para versões de protocolo anteriores); `cache_scope="private"` e o não-caching server-side já garantem a propriedade de segurança do Teste 5 independentemente disso. Ver `src/emcp_bus/gateway/cache.py`. **EP-00-T03 (pipeline de CI)**, apesar de não listado nesta linha, também foi implementado (`.github/workflows/ci.yml`). |
|
||||||
| **M2 — Governança e downstream** | EP-02-T04, EP-04-T04, EP-07 completo, EP-14 completo | Publishing pipeline com lifecycle completo, outbound identity sem passthrough, aprovação humana funcionando fail-closed. Cobre RF-10, RF-11, Fase 3 parcial. |
|
| **M2 — Governança e downstream ✅ concluído (2026-09-11)** | EP-02 completo (T01-T05, incluindo T02/T03/T05 adiados do M1), EP-04-T04, EP-07 completo, EP-14 completo | Publishing pipeline com lifecycle completo, outbound identity sem passthrough, aprovação humana funcionando fail-closed — todos ligados ao Gateway real (EP-05) e testados com OPA real. Cobre RF-10, RF-11, Fase 3 parcial. Detalhes e limitações em `RI/README.md`. **Adendo (2026-09-11, mesmo dia — Docker e Java passaram a estar disponíveis no ambiente)**: `docker-compose.yml` validado com daemon Docker real (build/up/fail-closed/trace cross-container), e **EP-01-T01 (Keycloak) implementado e validado com Keycloak 26.7.3 real** (`deploy/keycloak/realm-export.json`, perfil `identity` no compose, `tests/e2e/test_real_keycloak.py`) — item que a linha do M1 acima já citava como pré-requisito ("Keycloak+OPA") mas que só foi de fato implementado/testado agora. |
|
||||||
| **M3 — Personalização e orquestração (Fase 4 do README)** | EP-09, EP-10, EP-11, EP-12, EP-13-T01..T04 | Perfil→entitlement→ofertas filtradas→NBA→dispatch→execução MCP funcionando ponta a ponta com LangGraph no Orchestrator e Langfuse no agente de exemplo. Cobre RF-12 a RF-17. |
|
| **M3 — Personalização e orquestração (Fase 4 do README)** | EP-09, EP-10, EP-11, EP-12, EP-13-T01..T04 | Perfil→entitlement→ofertas filtradas→NBA→dispatch→execução MCP funcionando ponta a ponta com LangGraph no Orchestrator e Langfuse no agente de exemplo. Cobre RF-12 a RF-17. |
|
||||||
| **M4 — Hardening e entrega** | EP-13-T05, EP-13-T06 (could), EP-15 completo, EP-16 completo | Suíte de 10 testes de aceite (§45) e cenários adversariais (§46, incluindo o de EP-15-T03/ADR-023) passando contra a stack completa; **os dois modos de integração do ADR-024 demonstrados** — Modo A (EP-13-T05: Global Supervisor externo, dois backends/MCP Clients, handoff sem elevação de privilégio) e, se o tempo permitir, Modo B (EP-13-T06: nosso Service Orchestrator como único cérebro de NBA/NBO, mesma stack de segurança); ADRs documentados; README da RI publicado. |
|
| **M4 — Hardening e entrega** | EP-13-T05, EP-13-T06 (could), EP-15 completo, EP-16 completo | Suíte de 10 testes de aceite (§45) e cenários adversariais (§46, incluindo o de EP-15-T03/ADR-023) passando contra a stack completa; **os dois modos de integração do ADR-024 demonstrados** — Modo A (EP-13-T05: Global Supervisor externo, dois backends/MCP Clients, handoff sem elevação de privilégio) e, se o tempo permitir, Modo B (EP-13-T06: nosso Service Orchestrator como único cérebro de NBA/NBO, mesma stack de segurança); ADRs documentados; README da RI publicado. |
|
||||||
|
|
||||||
|
|||||||
101
docs/adr/ADR-019-identity-extension.md
Normal file
101
docs/adr/ADR-019-identity-extension.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# ADR-019: MCP Client identity -- production extension point (mTLS / workload identity)
|
||||||
|
|
||||||
|
**Status:** Documented (spike), not implemented in the RI (EP-01-T04, `could`).
|
||||||
|
**Related:** `docs/RI-PLANNING.md` ADR-019 entry (confirmed decision, 2026-09-11), README.md §19, RF-02, Axiom 2.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The RI's identity boundary (`src/emcp_bus/identity/authn.py`, EP-01-T03) is
|
||||||
|
OAuth 2.1 `client_credentials` against Keycloak (EP-01-T01): every MCP Client
|
||||||
|
authenticates with a client_id + secret, gets a short-lived JWT, and the
|
||||||
|
Gateway validates that JWT's signature/issuer/audience/expiry against the
|
||||||
|
IdP's JWKS before trusting the `azp` claim as the caller's identity.
|
||||||
|
|
||||||
|
This is a deliberate, confirmed decision for the RI (not a placeholder): OAuth
|
||||||
|
2.1 satisfies every property README.md §19 actually requires of an identity
|
||||||
|
mechanism -- short-lived, revocable, audience-bound, verifiable without a
|
||||||
|
shared long-term secret in the request path. Real enterprise deployments
|
||||||
|
commonly go further, using mTLS (mutual TLS client certificates) or a cloud
|
||||||
|
provider's workload identity federation (e.g. SPIFFE/SPIRE, AWS IAM roles for
|
||||||
|
service accounts, GCP Workload Identity, Azure Managed Identity) instead of --
|
||||||
|
or in addition to -- a bearer JWT. This ADR documents how that extension
|
||||||
|
plugs into the RI's existing seam, without requiring one, so a production
|
||||||
|
fork isn't blocked on a redesign.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Do **not** implement mTLS/workload identity in the RI. Document the
|
||||||
|
extension point instead, so `EP-01-T03`'s contract is provably stable under
|
||||||
|
it.
|
||||||
|
|
||||||
|
## The extension point
|
||||||
|
|
||||||
|
`TokenValidator.validate(token: str) -> AuthenticatedClient` is the *only*
|
||||||
|
function in the RI that produces client identity for authorization purposes
|
||||||
|
(`emcp_bus.gateway.server.on_list_tools`/`on_call_tool` both call
|
||||||
|
`get_access_token()`, which is populated by the SDK's `TokenVerifier`
|
||||||
|
protocol wrapping this same validator -- see
|
||||||
|
`emcp_bus.identity.token_verifier.MCPTokenVerifier`). Everything downstream
|
||||||
|
of it -- `EntitlementManager.resolve`, the PDP, the audit/correlation layer
|
||||||
|
(EP-08) -- consumes only the resulting `AuthenticatedClient.client_id`. None
|
||||||
|
of that code cares *how* the identity was established.
|
||||||
|
|
||||||
|
A production deployment adding mTLS or workload identity would:
|
||||||
|
|
||||||
|
1. Implement a second identity source honoring the same output contract:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MTLSClientValidator:
|
||||||
|
def validate(self, request: ...) -> AuthenticatedClient: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
fed from the terminating proxy/load balancer's verified client
|
||||||
|
certificate (a `X-Client-Cert-CN` style header from a trusted mTLS
|
||||||
|
terminator, or the ASGI `scope["extensions"]["tls"]` a compliant server
|
||||||
|
exposes) instead of a JWT. `AuthenticatedClient.client_id` would be the
|
||||||
|
certificate's Subject/SAN, mapped 1:1 to a `ClientRegistration.client_id`
|
||||||
|
exactly the way a JWT's `azp` is today -- **no other model in the RI
|
||||||
|
changes**: `ClientRegistration`, `ClientProfile`, `EntitlementManager`,
|
||||||
|
the PDP contract, and the audit schema are all already identity-mechanism
|
||||||
|
agnostic.
|
||||||
|
|
||||||
|
2. Provide an alternate `build_auth()`-equivalent in
|
||||||
|
`emcp_bus/gateway/server.py` that wires the new validator in instead of
|
||||||
|
`MCPTokenVerifier`, gated by deployment config -- the same shape as
|
||||||
|
today's `build_auth()` returning `(None, None)` for the "no OIDC
|
||||||
|
configured" mode. A hybrid deployment (mTLS for one class of backend
|
||||||
|
service, OAuth for interactive agents) is possible by making the
|
||||||
|
`TokenVerifier`/equivalent selection per-listener rather than
|
||||||
|
per-process, which the SDK's `streamable_http_app(auth=..., token_verifier=...)`
|
||||||
|
parameters already support per app instance.
|
||||||
|
|
||||||
|
3. Cloud workload identity (SPIFFE/SPIRE, IRSA, Workload Identity, Managed
|
||||||
|
Identity) is a variant of the same shape: the "verify a credential the
|
||||||
|
caller cannot forge, and extract a stable identifier from it" contract is
|
||||||
|
identical, only the credential format and verification library differ.
|
||||||
|
None of it requires touching `EntitlementManager`, the PDP, the Fabric
|
||||||
|
router (EP-06-T01), or the audit/correlation layer (EP-08-T02/T03).
|
||||||
|
|
||||||
|
## What must not change
|
||||||
|
|
||||||
|
- **`AuthenticatedClient` shape** (`client_id`, `issuer`, `expires_at`):
|
||||||
|
every downstream consumer types against this, not against a JWT-specific
|
||||||
|
claim set.
|
||||||
|
- **Fail-closed contract**: any identity mechanism's failure path must raise
|
||||||
|
(an `AuthenticationError`-shaped exception) rather than fall back to an
|
||||||
|
unauthenticated or agent-declared identity (README.md §5, Axiom 2) -- this
|
||||||
|
is the actual security property EP-01-T03's acceptance criteria test, and
|
||||||
|
it is mechanism-independent.
|
||||||
|
- **No new privileged path for `clientInfo`**: whatever the identity
|
||||||
|
mechanism, only a value verified independently of the MCP request body
|
||||||
|
may ever populate `client_id`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Confirmed for the RI: reduces EP-01's implementation effort without
|
||||||
|
weakening any of README.md §19's desirable identity properties (all are
|
||||||
|
satisfied by OAuth 2.1 `client_credentials` against Keycloak).
|
||||||
|
- A production fork adding mTLS/workload identity changes exactly one
|
||||||
|
module (`identity/`) and one wiring function (`build_auth`-equivalent);
|
||||||
|
every other component in the RI (Entitlement Manager, PDP, Fabric,
|
||||||
|
Registry, audit/correlation, approval) requires zero changes.
|
||||||
Reference in New Issue
Block a user