From f3efcc3414ce1f9eb2d9c2c54f0f4546d37bee83 Mon Sep 17 00:00:00 2001 From: Marcos Lohmann Date: Mon, 14 Sep 2026 08:18:53 -0300 Subject: [PATCH] M0 e M1 revisados e fixes implementados --- .github/workflows/ci.yml | 52 ++++ RI/.env.example | 49 ++- RI/Makefile | 19 +- RI/README.md | 142 ++++++--- RI/config/backends/finance-domain.yaml | 5 + RI/config/backends/sales-domain.yaml | 8 + .../capabilities/finance/invoice-get.yaml | 12 + .../capabilities/finance/payment-create.yaml | 12 + .../capabilities/finance/payment-execute.yaml | 16 + .../capabilities/sales/customer-get.yaml | 12 + .../capabilities/sales/customer-search.yaml | 12 + .../capabilities/sales/inventory-check.yaml | 12 + RI/config/capabilities/sales/order-get.yaml | 12 + .../capabilities/sales/quote-create.yaml | 12 + .../capabilities/sales/quote-update.yaml | 12 + RI/config/clients/profiles/sales-write.yaml | 1 + RI/deploy/docker/Dockerfile | 16 +- RI/deploy/keycloak/realm-export.json | 75 +++++ RI/deploy/otel/collector-config.yaml | 29 ++ RI/docker-compose.yml | 140 ++++++++- RI/pyproject.toml | 7 + RI/schemas/approval-request.schema.json | 60 ++++ RI/schemas/backend-credentials.schema.json | 56 ++++ RI/schemas/capability-manifest.schema.json | 199 ++++++++++++ RI/schemas/client-profile.schema.json | 148 +++++++++ RI/schemas/client-registration.schema.json | 40 +++ RI/schemas/pdp-decision.schema.json | 33 ++ RI/scripts/export_schemas.py | 44 +++ RI/scripts/validate_config.py | 67 +++++ .../finance_domain/__init__.py | 0 .../finance_domain/server.py | 111 +++++++ .../sales_domain/server.py | 60 +++- RI/src/emcp_bus/approval/__init__.py | 0 RI/src/emcp_bus/approval/models.py | 52 ++++ RI/src/emcp_bus/approval/service.py | 113 +++++++ RI/src/emcp_bus/audit/__init__.py | 0 RI/src/emcp_bus/audit/bypass_detection.py | 80 +++++ RI/src/emcp_bus/audit/correlation.py | 76 +++++ RI/src/emcp_bus/audit/events.py | 154 ++++++++++ RI/src/emcp_bus/audit/models.py | 89 ++++++ RI/src/emcp_bus/audit/sink.py | 38 +++ RI/src/emcp_bus/downstream/__init__.py | 0 RI/src/emcp_bus/downstream/identity.py | 75 +++++ RI/src/emcp_bus/downstream/models.py | 69 +++++ RI/src/emcp_bus/fabric/__init__.py | 0 RI/src/emcp_bus/fabric/adapters/__init__.py | 0 .../emcp_bus/fabric/adapters/rest_adapter.py | 90 ++++++ RI/src/emcp_bus/fabric/router.py | 88 ++++++ RI/src/emcp_bus/gateway/cache.py | 66 ++++ RI/src/emcp_bus/gateway/canonical_request.py | 119 ++++++++ RI/src/emcp_bus/gateway/server.py | 283 ++++++++++++++++-- .../emcp_bus/identity/shared_client_lint.py | 45 +++ RI/src/emcp_bus/registry/lifecycle.py | 44 +++ RI/src/emcp_bus/registry/pipeline.py | 83 +++++ RI/src/emcp_bus/registry/seed.py | 38 +++ RI/src/emcp_bus/registry/store.py | 107 +++++++ RI/tests/e2e/conftest.py | 123 +++++++- RI/tests/e2e/test_governed_gateway.py | 177 ++++++++++- RI/tests/e2e/test_real_keycloak.py | 249 +++++++++++++++ RI/tests/unit/test_approval.py | 163 ++++++++++ RI/tests/unit/test_audit.py | 139 +++++++++ RI/tests/unit/test_bypass_detection.py | 87 ++++++ RI/tests/unit/test_canonical_request.py | 72 +++++ RI/tests/unit/test_downstream_identity.py | 127 ++++++++ RI/tests/unit/test_fabric_router.py | 114 +++++++ RI/tests/unit/test_gateway_cache.py | 23 ++ RI/tests/unit/test_registry.py | 165 ++++++++++ RI/tests/unit/test_registry_seed_manifests.py | 57 ++++ RI/tests/unit/test_rest_adapter.py | 104 +++++++ RI/tests/unit/test_shared_client_lint.py | 72 +++++ docs/RI-PLANNING.md | 4 +- docs/adr/ADR-019-identity-extension.md | 101 +++++++ 72 files changed, 4831 insertions(+), 128 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 RI/config/backends/finance-domain.yaml create mode 100644 RI/config/backends/sales-domain.yaml create mode 100644 RI/config/capabilities/finance/invoice-get.yaml create mode 100644 RI/config/capabilities/finance/payment-create.yaml create mode 100644 RI/config/capabilities/finance/payment-execute.yaml create mode 100644 RI/config/capabilities/sales/customer-get.yaml create mode 100644 RI/config/capabilities/sales/customer-search.yaml create mode 100644 RI/config/capabilities/sales/inventory-check.yaml create mode 100644 RI/config/capabilities/sales/order-get.yaml create mode 100644 RI/config/capabilities/sales/quote-create.yaml create mode 100644 RI/config/capabilities/sales/quote-update.yaml create mode 100644 RI/deploy/keycloak/realm-export.json create mode 100644 RI/deploy/otel/collector-config.yaml create mode 100644 RI/schemas/approval-request.schema.json create mode 100644 RI/schemas/backend-credentials.schema.json create mode 100644 RI/schemas/capability-manifest.schema.json create mode 100644 RI/schemas/client-profile.schema.json create mode 100644 RI/schemas/client-registration.schema.json create mode 100644 RI/schemas/pdp-decision.schema.json create mode 100644 RI/scripts/export_schemas.py create mode 100644 RI/scripts/validate_config.py create mode 100644 RI/services/example_mcp_servers/finance_domain/__init__.py create mode 100644 RI/services/example_mcp_servers/finance_domain/server.py create mode 100644 RI/src/emcp_bus/approval/__init__.py create mode 100644 RI/src/emcp_bus/approval/models.py create mode 100644 RI/src/emcp_bus/approval/service.py create mode 100644 RI/src/emcp_bus/audit/__init__.py create mode 100644 RI/src/emcp_bus/audit/bypass_detection.py create mode 100644 RI/src/emcp_bus/audit/correlation.py create mode 100644 RI/src/emcp_bus/audit/events.py create mode 100644 RI/src/emcp_bus/audit/models.py create mode 100644 RI/src/emcp_bus/audit/sink.py create mode 100644 RI/src/emcp_bus/downstream/__init__.py create mode 100644 RI/src/emcp_bus/downstream/identity.py create mode 100644 RI/src/emcp_bus/downstream/models.py create mode 100644 RI/src/emcp_bus/fabric/__init__.py create mode 100644 RI/src/emcp_bus/fabric/adapters/__init__.py create mode 100644 RI/src/emcp_bus/fabric/adapters/rest_adapter.py create mode 100644 RI/src/emcp_bus/fabric/router.py create mode 100644 RI/src/emcp_bus/gateway/cache.py create mode 100644 RI/src/emcp_bus/gateway/canonical_request.py create mode 100644 RI/src/emcp_bus/identity/shared_client_lint.py create mode 100644 RI/src/emcp_bus/registry/lifecycle.py create mode 100644 RI/src/emcp_bus/registry/pipeline.py create mode 100644 RI/src/emcp_bus/registry/seed.py create mode 100644 RI/src/emcp_bus/registry/store.py create mode 100644 RI/tests/e2e/test_real_keycloak.py create mode 100644 RI/tests/unit/test_approval.py create mode 100644 RI/tests/unit/test_audit.py create mode 100644 RI/tests/unit/test_bypass_detection.py create mode 100644 RI/tests/unit/test_canonical_request.py create mode 100644 RI/tests/unit/test_downstream_identity.py create mode 100644 RI/tests/unit/test_fabric_router.py create mode 100644 RI/tests/unit/test_gateway_cache.py create mode 100644 RI/tests/unit/test_registry.py create mode 100644 RI/tests/unit/test_registry_seed_manifests.py create mode 100644 RI/tests/unit/test_rest_adapter.py create mode 100644 RI/tests/unit/test_shared_client_lint.py create mode 100644 docs/adr/ADR-019-identity-extension.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..988ada8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/RI/.env.example b/RI/.env.example index 0c38570..f2cafc3 100644 --- a/RI/.env.example +++ b/RI/.env.example @@ -12,8 +12,53 @@ SALES_DOMAIN_PORT=8100 # use 127.0.0.1 for `make dev`, which runs both processes on the host). 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) --- # Unset (default): traces print to stdout via ConsoleSpanExporter. -# Set to enable OTLP/HTTP export instead (requires the `emcp-bus[otlp]` extra -# and a running collector -- see EP-08-T04). +# Set to "http://otel-collector:4318" and run with `--profile observability` +# 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 + +# 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--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 diff --git a/RI/Makefile b/RI/Makefile index 9c11bde..95f7e78 100644 --- a/RI/Makefile +++ b/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 PYTHON := $(VENV)/bin/python3 @@ -19,16 +19,21 @@ format: $(VENV)/bin/ruff check --fix . 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: - $(VENV)/bin/python3 -m pytest + $(VENV)/bin/python3 -m pytest --forked validate-schemas: - # Placeholder until EP-02/EP-04 introduce the first JSON Schema-backed - # YAML config types; will validate every file under config/ against its - # schema (see docs/RI-PLANNING.md, EP-00-T03). - @echo "No schema-validated config types yet (see EP-02-T01, EP-04-T01)." + $(VENV)/bin/python3 scripts/validate_config.py + +export-schemas: + $(VENV)/bin/python3 scripts/export_schemas.py # Runs both M0 services directly on the host (no Docker required) -- # handy for local development and for environments without a Docker daemon. diff --git a/RI/README.md b/RI/README.md index af6d70b..3183d41 100644 --- a/RI/README.md +++ b/RI/README.md @@ -2,18 +2,24 @@ 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) ```bash -make install # cria .venv e instala o pacote em modo editável (dev extras inclusos) -make lint # ruff check + ruff format --check -make typecheck # mypy --strict -make test # pytest (38 testes: unit + e2e) +make install # cria .venv e instala o pacote em modo editável (dev extras inclusos) +make lint # ruff check + ruff format --check +make typecheck # mypy --strict +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) @@ -33,21 +39,54 @@ Versão usada durante o desenvolvimento desta RI: **OPA v1.20.2** (Rego v1 é o ```bash source .venv/bin/activate 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.finance_domain.server & # 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). 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) ```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:` no teste automatizado, já que `KC_HOSTNAME` não aceita `host:porta` sem esquema). ## 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 | |---|---|---| -| EP-00 (completo) | Scaffold, config loader, docker-compose base | ✅ (ver README anterior/git history) | -| EP-01-T02 | Schema de registro de MCP Client | ✅ `src/emcp_bus/identity/models.py` | -| EP-01-T03 | Middleware de autenticação OIDC/JWT | ✅ validado com JWKS real + tokens RS256 reais (assinatura, expiração, issuer, audience) | -| 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-01-T05 | Lint de shared client | ❌ não implementado (prioridade `could`) | -| EP-02 (Registry) | Schema de capability manifest | ✅ `src/emcp_bus/registry/models.py` (schema apenas — pipeline/storage/API ainda não) | -| EP-02-T02/T03/T05 | Publishing pipeline, storage, manifests de exemplo | ❌ não implementado | -| 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-04 (completo) | Entitlement Manager: resolve, revoke, versionamento | ✅ validado com OPA real + YAML reais de `config/clients/` | -| EP-05-T01 | Gateway skeleton | ✅ (M0) | -| EP-05-T02 | `tools/list` filtrado por entitlement | ✅ testado (README.md §45 Teste 1) | -| 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-00 (completo) | Scaffold, config loader, docker-compose base, **CI** | ✅ `.github/workflows/ci.yml` (EP-00-T03) | +| 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-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-03 (completo) | PDP/OPA: entitlement.rego, transaction.rego, cliente, contrato de decisão | ✅ validado com **OPA real** — 9 testes Rego + 6 Python | +| EP-04 (completo) | Entitlement Manager: resolve, revoke, versionamento | ✅ validado com OPA real + YAML reais | +| 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-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-06-T02 | MCP Server de exemplo — Sales | ✅ 6 tools (R1 + R2), agora com `BackendCredentialGate` (EP-05-T07) | +| 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-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-07 (completo) | Outbound/downstream identity: credencial de backend distinta da do client | ✅ `src/emcp_bus/downstream/{models,identity}.py`, ligado ao Gateway, testado | | 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: - -- **Docker**: não validado (ver nota acima). Risco considerado baixo — o Dockerfile usa a mesma instalação (`pip install -e .`) já validada localmente. -- **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. +- **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. +- **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. +- **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 @@ -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`) - `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) +- `pytest-forked` 1.7.x (resolve a flakiness da suíte e2e, ver acima) ## Estrutura @@ -110,21 +145,38 @@ Ver `../docs/RI-PLANNING.md`, seção 8.5, para a árvore completa planejada. O RI/ ├── pyproject.toml ├── 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 ├── 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/ -│ ├── policies/ # EP-03: entitlement.rego, transaction.rego (+ testes opa test) -│ └── clients/ # EP-01/EP-04: registrations + profiles/*.yaml +│ ├── policies/ # EP-03: entitlement.rego, transaction.rego (+ opa test) +│ ├── 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/ │ ├── 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 -│ ├── registry/models.py # EP-02-T01 (schema apenas) +│ ├── registry/{models,store,pipeline,lifecycle,seed}.py # EP-02 │ ├── pdp/{client,models}.py # EP-03 -│ └── gateway/server.py # EP-05-T01/T02/T04/T06 -├── services/example_mcp_servers/sales_domain/server.py # EP-06-T02 +│ ├── fabric/router.py # EP-06-T01 +│ ├── 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/ - ├── unit/ # schemas, PDP client, entitlement manager, authn — todos contra dependências reais - └── e2e/ # conftest.py (fixtures compartilhados), walking skeleton, governed gateway + ├── unit/ # schemas, PDP, entitlement, authn, registry, downstream identity, approval, + │ # 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) ``` diff --git a/RI/config/backends/finance-domain.yaml b/RI/config/backends/finance-domain.yaml new file mode 100644 index 0000000..ce6ad31 --- /dev/null +++ b/RI/config/backends/finance-domain.yaml @@ -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 diff --git a/RI/config/backends/sales-domain.yaml b/RI/config/backends/sales-domain.yaml new file mode 100644 index 0000000..b569164 --- /dev/null +++ b/RI/config/backends/sales-domain.yaml @@ -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 diff --git a/RI/config/capabilities/finance/invoice-get.yaml b/RI/config/capabilities/finance/invoice-get.yaml new file mode 100644 index 0000000..4d5c1a9 --- /dev/null +++ b/RI/config/capabilities/finance/invoice-get.yaml @@ -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 diff --git a/RI/config/capabilities/finance/payment-create.yaml b/RI/config/capabilities/finance/payment-create.yaml new file mode 100644 index 0000000..18e8fe8 --- /dev/null +++ b/RI/config/capabilities/finance/payment-create.yaml @@ -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 diff --git a/RI/config/capabilities/finance/payment-execute.yaml b/RI/config/capabilities/finance/payment-execute.yaml new file mode 100644 index 0000000..7b10a38 --- /dev/null +++ b/RI/config/capabilities/finance/payment-execute.yaml @@ -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 diff --git a/RI/config/capabilities/sales/customer-get.yaml b/RI/config/capabilities/sales/customer-get.yaml new file mode 100644 index 0000000..283106b --- /dev/null +++ b/RI/config/capabilities/sales/customer-get.yaml @@ -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 diff --git a/RI/config/capabilities/sales/customer-search.yaml b/RI/config/capabilities/sales/customer-search.yaml new file mode 100644 index 0000000..8295c03 --- /dev/null +++ b/RI/config/capabilities/sales/customer-search.yaml @@ -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 diff --git a/RI/config/capabilities/sales/inventory-check.yaml b/RI/config/capabilities/sales/inventory-check.yaml new file mode 100644 index 0000000..5a97ee1 --- /dev/null +++ b/RI/config/capabilities/sales/inventory-check.yaml @@ -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 diff --git a/RI/config/capabilities/sales/order-get.yaml b/RI/config/capabilities/sales/order-get.yaml new file mode 100644 index 0000000..8ec3718 --- /dev/null +++ b/RI/config/capabilities/sales/order-get.yaml @@ -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 diff --git a/RI/config/capabilities/sales/quote-create.yaml b/RI/config/capabilities/sales/quote-create.yaml new file mode 100644 index 0000000..185effe --- /dev/null +++ b/RI/config/capabilities/sales/quote-create.yaml @@ -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 diff --git a/RI/config/capabilities/sales/quote-update.yaml b/RI/config/capabilities/sales/quote-update.yaml new file mode 100644 index 0000000..9616652 --- /dev/null +++ b/RI/config/capabilities/sales/quote-update.yaml @@ -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 diff --git a/RI/config/clients/profiles/sales-write.yaml b/RI/config/clients/profiles/sales-write.yaml index c61147e..00045bd 100644 --- a/RI/config/clients/profiles/sales-write.yaml +++ b/RI/config/clients/profiles/sales-write.yaml @@ -13,6 +13,7 @@ allowed_tools: restrictions: max_transaction_value: 500000 allowed_regions: ["BR-SP", "BR-RJ"] + approval_threshold: 100000 credential_policy: workload_identity: true short_lived: true diff --git a/RI/deploy/docker/Dockerfile b/RI/deploy/docker/Dockerfile index 742241a..5e9e32e 100644 --- a/RI/deploy/docker/Dockerfile +++ b/RI/deploy/docker/Dockerfile @@ -1,10 +1,11 @@ # Shared image for every RI service (Gateway, example MCP servers, ...). # 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 -# daemon was available in the sandbox this was authored in (see RI/README.md, -# "Validado nesta sessão" for exactly what *was* run). Please verify on first -# use in an environment with Docker and report back if anything needs fixing. +# Verified with a real `docker build`/`docker compose up` (RI/README.md, "O +# que foi validado com Docker") -- this image originally omitted `config ./config` +# below, which built fine but made the Gateway fail closed at startup +# (`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 WORKDIR /app @@ -14,16 +15,19 @@ WORKDIR /app COPY pyproject.toml ./ COPY src ./src 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 \ - && 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 # container network namespace, never the host network directly. RUN useradd --create-home --uid 10001 emcp USER emcp -EXPOSE 8000 8100 +EXPOSE 8000 8100 8101 # Overridden per-service by docker-compose.yml `command:`. CMD ["python3", "-m", "emcp_bus.gateway.server"] diff --git a/RI/deploy/keycloak/realm-export.json b/RI/deploy/keycloak/realm-export.json new file mode 100644 index 0000000..8a24c8b --- /dev/null +++ b/RI/deploy/keycloak/realm-export.json @@ -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" + } + } + ] + } + ] +} diff --git a/RI/deploy/otel/collector-config.yaml b/RI/deploy/otel/collector-config.yaml new file mode 100644 index 0000000..2ce057a --- /dev/null +++ b/RI/deploy/otel/collector-config.yaml @@ -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] diff --git a/RI/docker-compose.yml b/RI/docker-compose.yml index 3f2a8b0..c25ed1f 100644 --- a/RI/docker-compose.yml +++ b/RI/docker-compose.yml @@ -1,19 +1,74 @@ # EP-00-T04 (walking skeleton) + EP-16-T01 (grows into the full stack). # -# `core` profile = M0: gateway + one example MCP server, static routing, no -# enforcement yet. Later milestones add services under additional profiles -# (`identity`, `policy`, `observability`, `llm-observability`, ...) so `docker -# compose --profile core up` stays cheap through M0/M1 instead of requiring -# the whole stack (Keycloak, OPA, Langfuse, ...) just to see the walking -# skeleton trace. +# `core` profile = the minimum that actually runs: gateway + sales-domain + +# finance-domain + OPA. OPA stopped being optional as of M1 (EP-03/EP-05-T06): +# the Gateway's `lifespan()` fails closed at startup if it cannot sync +# entitlements to a PDP, so "gateway without OPA" is not a smaller working +# stack, it's a Gateway that refuses to start (README.md §38) -- by design, +# 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 -# in (no `docker` binary in that sandbox) -- validated instead by running the -# same two processes directly on the host (see RI/README.md). Please run -# `docker compose --profile core up --build` on first use and report back if -# anything needs adjusting. +# EP-05-T07 (anti-bypass): `opa`, `sales-domain` and `finance-domain` +# deliberately publish no host port below -- on a real Docker host, that +# already means nothing outside the compose network can reach them at all +# (Docker only exposes a container port past the host via `ports:`); only +# `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: + 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: build: context: . @@ -22,9 +77,10 @@ services: environment: SALES_DOMAIN_HOST: "0.0.0.0" SALES_DOMAIN_PORT: "8100" - ports: - - "8100:8100" + SALES_DOMAIN_SERVICE_TOKEN: "dev-sales-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: # A bare TCP connect, not an HTTP/MCP-protocol check: streamable-http # rejects a plain GET, so this only confirms the process is listening. @@ -33,6 +89,24 @@ services: timeout: 3s 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: build: context: . @@ -42,9 +116,49 @@ services: GATEWAY_HOST: "0.0.0.0" GATEWAY_PORT: "8000" 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: - "8000:8000" profiles: ["core"] + restart: on-failure:5 depends_on: sales-domain: 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"] diff --git a/RI/pyproject.toml b/RI/pyproject.toml index 8b697aa..519458b 100644 --- a/RI/pyproject.toml +++ b/RI/pyproject.toml @@ -25,6 +25,13 @@ dev = [ "mypy>=2.3.1,<3", "pytest>=9.1.1,<10", "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", ] diff --git a/RI/schemas/approval-request.schema.json b/RI/schemas/approval-request.schema.json new file mode 100644 index 0000000..ae64809 --- /dev/null +++ b/RI/schemas/approval-request.schema.json @@ -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" +} diff --git a/RI/schemas/backend-credentials.schema.json b/RI/schemas/backend-credentials.schema.json new file mode 100644 index 0000000..9dcf870 --- /dev/null +++ b/RI/schemas/backend-credentials.schema.json @@ -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" +} diff --git a/RI/schemas/capability-manifest.schema.json b/RI/schemas/capability-manifest.schema.json new file mode 100644 index 0000000..98bbef9 --- /dev/null +++ b/RI/schemas/capability-manifest.schema.json @@ -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 '.' 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" +} diff --git a/RI/schemas/client-profile.schema.json b/RI/schemas/client-profile.schema.json new file mode 100644 index 0000000..6281730 --- /dev/null +++ b/RI/schemas/client-profile.schema.json @@ -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 . 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" +} diff --git a/RI/schemas/client-registration.schema.json b/RI/schemas/client-registration.schema.json new file mode 100644 index 0000000..937a744 --- /dev/null +++ b/RI/schemas/client-registration.schema.json @@ -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" +} diff --git a/RI/schemas/pdp-decision.schema.json b/RI/schemas/pdp-decision.schema.json new file mode 100644 index 0000000..647b852 --- /dev/null +++ b/RI/schemas/pdp-decision.schema.json @@ -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" +} diff --git a/RI/scripts/export_schemas.py b/RI/scripts/export_schemas.py new file mode 100644 index 0000000..9dad308 --- /dev/null +++ b/RI/scripts/export_schemas.py @@ -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() diff --git a/RI/scripts/validate_config.py b/RI/scripts/validate_config.py new file mode 100644 index 0000000..943cf98 --- /dev/null +++ b/RI/scripts/validate_config.py @@ -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()) diff --git a/RI/services/example_mcp_servers/finance_domain/__init__.py b/RI/services/example_mcp_servers/finance_domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/RI/services/example_mcp_servers/finance_domain/server.py b/RI/services/example_mcp_servers/finance_domain/server.py new file mode 100644 index 0000000..6b9e1c4 --- /dev/null +++ b/RI/services/example_mcp_servers/finance_domain/server.py @@ -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() diff --git a/RI/services/example_mcp_servers/sales_domain/server.py b/RI/services/example_mcp_servers/sales_domain/server.py index 1f8bdbe..6b085ec 100644 --- a/RI/services/example_mcp_servers/sales_domain/server.py +++ b/RI/services/example_mcp_servers/sales_domain/server.py @@ -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, -customer.get, order.get, inventory.check. Backed by static mock data -- this -server is deliberately "dumb": it never authorizes anything (ADR-023 in -docs/RI-PLANNING.md). Authorization happens exclusively upstream, in the -Gateway/PEP (EP-05) before a call ever reaches this process. +customer.get, order.get, inventory.check. Write tools (R2): quote.create, +quote.update. Backed by static mock data -- this server is deliberately +"dumb": it never authorizes anything (ADR-023 in docs/RI-PLANNING.md). +Authorization happens exclusively upstream, in the Gateway/PEP (EP-05) +before a call ever reaches this process. Run directly for local development: @@ -13,10 +14,14 @@ Run directly for local development: 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("sales-domain-mcp-server") @@ -37,6 +42,9 @@ _INVENTORY: dict[str, int] = { "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.") 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)} +@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: host = os.environ.get("SALES_DOMAIN_HOST", "127.0.0.1") 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__": diff --git a/RI/src/emcp_bus/approval/__init__.py b/RI/src/emcp_bus/approval/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/RI/src/emcp_bus/approval/models.py b/RI/src/emcp_bus/approval/models.py new file mode 100644 index 0000000..f8ef2fc --- /dev/null +++ b/RI/src/emcp_bus/approval/models.py @@ -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 diff --git a/RI/src/emcp_bus/approval/service.py b/RI/src/emcp_bus/approval/service.py new file mode 100644 index 0000000..f9d3d18 --- /dev/null +++ b/RI/src/emcp_bus/approval/service.py @@ -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 diff --git a/RI/src/emcp_bus/audit/__init__.py b/RI/src/emcp_bus/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/RI/src/emcp_bus/audit/bypass_detection.py b/RI/src/emcp_bus/audit/bypass_detection.py new file mode 100644 index 0000000..d2d4c97 --- /dev/null +++ b/RI/src/emcp_bus/audit/bypass_detection.py @@ -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 diff --git a/RI/src/emcp_bus/audit/correlation.py b/RI/src/emcp_bus/audit/correlation.py new file mode 100644 index 0000000..77fd8ce --- /dev/null +++ b/RI/src/emcp_bus/audit/correlation.py @@ -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") diff --git a/RI/src/emcp_bus/audit/events.py b/RI/src/emcp_bus/audit/events.py new file mode 100644 index 0000000..4b526a3 --- /dev/null +++ b/RI/src/emcp_bus/audit/events.py @@ -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}, + ) diff --git a/RI/src/emcp_bus/audit/models.py b/RI/src/emcp_bus/audit/models.py new file mode 100644 index 0000000..7f0fd79 --- /dev/null +++ b/RI/src/emcp_bus/audit/models.py @@ -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:` 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) diff --git a/RI/src/emcp_bus/audit/sink.py b/RI/src/emcp_bus/audit/sink.py new file mode 100644 index 0000000..be0961c --- /dev/null +++ b/RI/src/emcp_bus/audit/sink.py @@ -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") diff --git a/RI/src/emcp_bus/downstream/__init__.py b/RI/src/emcp_bus/downstream/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/RI/src/emcp_bus/downstream/identity.py b/RI/src/emcp_bus/downstream/identity.py new file mode 100644 index 0000000..0cc085e --- /dev/null +++ b/RI/src/emcp_bus/downstream/identity.py @@ -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 diff --git a/RI/src/emcp_bus/downstream/models.py b/RI/src/emcp_bus/downstream/models.py new file mode 100644 index 0000000..33bd88a --- /dev/null +++ b/RI/src/emcp_bus/downstream/models.py @@ -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 diff --git a/RI/src/emcp_bus/fabric/__init__.py b/RI/src/emcp_bus/fabric/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/RI/src/emcp_bus/fabric/adapters/__init__.py b/RI/src/emcp_bus/fabric/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/RI/src/emcp_bus/fabric/adapters/rest_adapter.py b/RI/src/emcp_bus/fabric/adapters/rest_adapter.py new file mode 100644 index 0000000..7735e84 --- /dev/null +++ b/RI/src/emcp_bus/fabric/adapters/rest_adapter.py @@ -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 diff --git a/RI/src/emcp_bus/fabric/router.py b/RI/src/emcp_bus/fabric/router.py new file mode 100644 index 0000000..ad8dccb --- /dev/null +++ b/RI/src/emcp_bus/fabric/router.py @@ -0,0 +1,88 @@ +"""Enterprise MCP Fabric core: capability router (EP-06-T01, README.md §6.7). + +Resolves a `.` 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}") diff --git a/RI/src/emcp_bus/gateway/cache.py b/RI/src/emcp_bus/gateway/cache.py new file mode 100644 index 0000000..b5c731b --- /dev/null +++ b/RI/src/emcp_bus/gateway/cache.py @@ -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") diff --git a/RI/src/emcp_bus/gateway/canonical_request.py b/RI/src/emcp_bus/gateway/canonical_request.py new file mode 100644 index 0000000..90376f4 --- /dev/null +++ b/RI/src/emcp_bus/gateway/canonical_request.py @@ -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) diff --git a/RI/src/emcp_bus/gateway/server.py b/RI/src/emcp_bus/gateway/server.py index 7ea51ce..952ef94 100644 --- a/RI/src/emcp_bus/gateway/server.py +++ b/RI/src/emcp_bus/gateway/server.py @@ -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 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()`/ `build_auth()`, rather than into module-level constants at import time: 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). 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 import os +import tempfile from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from dataclasses import dataclass @@ -36,23 +46,46 @@ from pathlib import Path import mcp_types as types 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.provider import TokenVerifier from mcp.server.auth.settings import AuthSettings from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server 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.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.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.models import ClientRegistration from emcp_bus.identity.token_verifier import MCPTokenVerifier from emcp_bus.pdp.client import PDPClient, PDPUnavailableError, compute_policy_version 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") @@ -61,22 +94,22 @@ _REPO_ROOT = Path(__file__).resolve().parents[3] @dataclass(frozen=True) class GatewayConfig: - downstream_url: str opa_url: str config_dir: Path oidc_jwks_url: str oidc_issuer: str oidc_audience: str + audit_log_path: str @classmethod def from_env(cls) -> GatewayConfig: 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"), config_dir=Path(os.environ.get("EMCP_CONFIG_DIR", str(_REPO_ROOT / "config"))), oidc_jwks_url=os.environ.get("OIDC_JWKS_URL", ""), oidc_issuer=os.environ.get("OIDC_ISSUER", ""), oidc_audience=os.environ.get("OIDC_AUDIENCE", "emcp-gateway"), + audit_log_path=os.environ.get("EMCP_AUDIT_LOG_PATH", "/tmp/emcp-audit.jsonl"), ) @property @@ -91,16 +124,47 @@ class GatewayConfig: def policies_dir(self) -> Path: 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 class GatewayState: entitlement_manager: EntitlementManager 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 async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]: + global _current_state config = GatewayConfig.from_env() pdp = PDPClient( 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 # to at startup must not come up looking healthy (README.md §38). await manager.sync_profiles_to_pdp() + + backend_credentials = BackendCredentialProvider( + 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 GatewayState( - entitlement_manager=manager, pdp=pdp, downstream_url=config.downstream_url - ) + yield state finally: + _current_state = None await pdp.aclose() + registry_db_path.unlink(missing_ok=True) async def _with_downstream_session[ResultT]( downstream_url: str, + backend_token: str, coro_factory: Callable[[ClientSession], Awaitable[ResultT]], ) -> ResultT: """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 - session under concurrent requests; the Enterprise MCP Fabric (EP-06) is - where connection pooling/reuse per backend would be introduced later. + `backend_token` is the *outbound* credential (EP-07-T01) -- never the + inbound MCP Client's own bearer token (README.md §20, ADR-006: no blind + 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 ( - 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, ): 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( ctx: ServerRequestContext[GatewayState], params: types.PaginatedRequestParams | None, @@ -162,13 +266,42 @@ async def on_list_tools( except UnknownClientError: return types.ListToolsResult(tools=[]) - downstream_result = await _with_downstream_session( - state.downstream_url, lambda session: session.list_tools(params=params) + state.audit_sink.emit( + 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 - ] - return downstream_result.model_copy(update={"tools": filtered_tools}) + + # EP-06-T01: query only the backends that actually serve a tool this + # 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( @@ -185,26 +318,110 @@ async def on_call_tool( except UnknownClientError: return _denied("UNKNOWN_CLIENT") - try: - decision = await state.pdp.evaluate( + arguments = dict(params.arguments or {}) + 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, 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: + state.audit_sink.emit( + events.pdp_unavailable(client_id=access_token.client_id, tool=params.name) + ) return _denied("PDP_UNAVAILABLE") 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) + if decision.outcome is PDPOutcome.REQUIRE_APPROVAL: - # EP-14 (human approval workflow) does not exist yet in this milestone - # -- fail closed rather than silently upgrading to ALLOW. - return _denied(f"REQUIRE_APPROVAL:{decision.reason_code}") + try: + approved = state.approval_service.check_and_consume( + 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( - 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): + 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 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 +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: import uvicorn host = os.environ.get("GATEWAY_HOST", "127.0.0.1") port = int(os.environ.get("GATEWAY_PORT", "8000")) 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") diff --git a/RI/src/emcp_bus/identity/shared_client_lint.py b/RI/src/emcp_bus/identity/shared_client_lint.py new file mode 100644 index 0000000..86e5c85 --- /dev/null +++ b/RI/src/emcp_bus/identity/shared_client_lint.py @@ -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) diff --git a/RI/src/emcp_bus/registry/lifecycle.py b/RI/src/emcp_bus/registry/lifecycle.py new file mode 100644 index 0000000..0cd73bc --- /dev/null +++ b/RI/src/emcp_bus/registry/lifecycle.py @@ -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)" + ) diff --git a/RI/src/emcp_bus/registry/pipeline.py b/RI/src/emcp_bus/registry/pipeline.py new file mode 100644 index 0000000..ffce1df --- /dev/null +++ b/RI/src/emcp_bus/registry/pipeline.py @@ -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 diff --git a/RI/src/emcp_bus/registry/seed.py b/RI/src/emcp_bus/registry/seed.py new file mode 100644 index 0000000..a2f70bc --- /dev/null +++ b/RI/src/emcp_bus/registry/seed.py @@ -0,0 +1,38 @@ +"""Loads the RI's seed capability manifests (`config/capabilities//*.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 diff --git a/RI/src/emcp_bus/registry/store.py b/RI/src/emcp_bus/registry/store.py new file mode 100644 index 0000000..c9a3462 --- /dev/null +++ b/RI/src/emcp_bus/registry/store.py @@ -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) diff --git a/RI/tests/e2e/conftest.py b/RI/tests/e2e/conftest.py index aa93569..1d9bd33 100644 --- a/RI/tests/e2e/conftest.py +++ b/RI/tests/e2e/conftest.py @@ -37,8 +37,28 @@ ISSUER = "https://keycloak.example/realms/emcp" AUDIENCE = "emcp-gateway" KID = "test-key-1" -GATEWAY_PORT = 18000 -SALES_PORT = 18100 +_current_gateway_port: int | None = None +"""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: @@ -180,19 +200,34 @@ async def governed_stack( ) -> AsyncIterator[None]: """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 - 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") + itself is substituted by the JWKS server -- see RI/README.md). + + 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("OIDC_JWKS_URL", jwks_url) monkeypatch.setenv("OIDC_ISSUER", ISSUER) 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 sales_config = uvicorn.Config( sales_module.server.streamable_http_app(host="127.0.0.1"), host="127.0.0.1", - port=SALES_PORT, + port=sales_port, log_level="warning", ) sales_server = RunningServer(sales_config) @@ -202,23 +237,91 @@ async def governed_stack( auth_settings, token_verifier = gateway_module.build_auth() gateway_config = uvicorn.Config( - gateway_module.server.streamable_http_app( - host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier - ), + gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"), host="127.0.0.1", - port=GATEWAY_PORT, + port=gateway_port, log_level="warning", ) gateway_server = RunningServer(gateway_config) 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() +@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]( operation: Callable[[ClientSession], Awaitable[ResultT]], *, @@ -232,7 +335,7 @@ async def run_against_gateway[ResultT]( Gateway defect: production deployments run one process per service. """ 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 for attempt in range(3): if attempt: diff --git a/RI/tests/e2e/test_governed_gateway.py b/RI/tests/e2e/test_governed_gateway.py index a25d0ea..00c8087 100644 --- a/RI/tests/e2e/test_governed_gateway.py +++ b/RI/tests/e2e/test_governed_gateway.py @@ -136,13 +136,13 @@ async def test_transaction_policy_denies_over_limit_even_though_entitled( 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] ) -> 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 - REQUIRE_APPROVAL to ALLOW -- and EP-14 (approval workflow) does not exist - yet in this milestone, so the only safe outcome is DENY.""" + REQUIRE_APPROVAL to ALLOW -- the first attempt of any such call is always + denied, since no approval has been granted for it yet (EP-14-T02).""" token = sign_token(FINANCE_PAYMENTS_CLIENT_ID) result = await run_against_gateway( @@ -154,6 +154,152 @@ async def test_require_approval_is_denied_pending_ep14( 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( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -188,10 +334,19 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured( """ 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("SALES_DOMAIN_SERVICE_TOKEN", "test-sales-domain-backend-credential") monkeypatch.delenv("OIDC_JWKS_URL", 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( sales_module.server.streamable_http_app(host="127.0.0.1"), host="127.0.0.1", - port=SALES_PORT, + port=sales_port, log_level="warning", ) ) @@ -214,15 +369,14 @@ async def test_deny_by_default_when_no_oidc_provider_is_configured( gateway_server = RunningServer( uvicorn.Config( - gateway_module.server.streamable_http_app( - host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier - ), + gateway_module.build_app(auth_settings, token_verifier, host="127.0.0.1"), host="127.0.0.1", - port=GATEWAY_PORT, + port=gateway_port, log_level="warning", ) ) await gateway_server.start() + set_current_gateway_port(gateway_port) try: list_result = await run_against_gateway(lambda session: session.list_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 "UNAUTHENTICATED" in _text(call_result) finally: + set_current_gateway_port(None) await gateway_server.stop() await sales_server.stop() diff --git a/RI/tests/e2e/test_real_keycloak.py b/RI/tests/e2e/test_real_keycloak.py new file mode 100644 index 0000000..918b746 --- /dev/null +++ b/RI/tests/e2e/test_real_keycloak.py @@ -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:` 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 diff --git a/RI/tests/unit/test_approval.py b/RI/tests/unit/test_approval.py new file mode 100644 index 0000000..305e93a --- /dev/null +++ b/RI/tests/unit/test_approval.py @@ -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") diff --git a/RI/tests/unit/test_audit.py b/RI/tests/unit/test_audit.py new file mode 100644 index 0000000..735cc08 --- /dev/null +++ b/RI/tests/unit/test_audit.py @@ -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) diff --git a/RI/tests/unit/test_bypass_detection.py b/RI/tests/unit/test_bypass_detection.py new file mode 100644 index 0000000..21c133d --- /dev/null +++ b/RI/tests/unit/test_bypass_detection.py @@ -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 diff --git a/RI/tests/unit/test_canonical_request.py b/RI/tests/unit/test_canonical_request.py new file mode 100644 index 0000000..1833108 --- /dev/null +++ b/RI/tests/unit/test_canonical_request.py @@ -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 diff --git a/RI/tests/unit/test_downstream_identity.py b/RI/tests/unit/test_downstream_identity.py new file mode 100644 index 0000000..67e8c48 --- /dev/null +++ b/RI/tests/unit/test_downstream_identity.py @@ -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") diff --git a/RI/tests/unit/test_fabric_router.py b/RI/tests/unit/test_fabric_router.py new file mode 100644 index 0000000..1c37141 --- /dev/null +++ b/RI/tests/unit/test_fabric_router.py @@ -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"} diff --git a/RI/tests/unit/test_gateway_cache.py b/RI/tests/unit/test_gateway_cache.py new file mode 100644 index 0000000..6b7c2e9 --- /dev/null +++ b/RI/tests/unit/test_gateway_cache.py @@ -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) diff --git a/RI/tests/unit/test_registry.py b/RI/tests/unit/test_registry.py new file mode 100644 index 0000000..0ebe716 --- /dev/null +++ b/RI/tests/unit/test_registry.py @@ -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") diff --git a/RI/tests/unit/test_registry_seed_manifests.py b/RI/tests/unit/test_registry_seed_manifests.py new file mode 100644 index 0000000..eab73ee --- /dev/null +++ b/RI/tests/unit/test_registry_seed_manifests.py @@ -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 diff --git a/RI/tests/unit/test_rest_adapter.py b/RI/tests/unit/test_rest_adapter.py new file mode 100644 index 0000000..210ebfb --- /dev/null +++ b/RI/tests/unit/test_rest_adapter.py @@ -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() diff --git a/RI/tests/unit/test_shared_client_lint.py b/RI/tests/unit/test_shared_client_lint.py new file mode 100644 index 0000000..cef6795 --- /dev/null +++ b/RI/tests/unit/test_shared_client_lint.py @@ -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 diff --git a/docs/RI-PLANNING.md b/docs/RI-PLANNING.md index 6d834a1..e7ff56e 100644 --- a/docs/RI-PLANNING.md +++ b/docs/RI-PLANNING.md @@ -1509,8 +1509,8 @@ backlog: | 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. | -| **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. | -| **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. | +| **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 ✅ 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. | | **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. | diff --git a/docs/adr/ADR-019-identity-extension.md b/docs/adr/ADR-019-identity-extension.md new file mode 100644 index 0000000..dd6f51c --- /dev/null +++ b/docs/adr/ADR-019-identity-extension.md @@ -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.