M4 implementada e backlog validado

This commit is contained in:
Marcos Lohmann
2026-09-14 11:22:03 -03:00
parent c894c1604b
commit 2fe6d6d508
53 changed files with 3152 additions and 39 deletions

View File

@@ -0,0 +1,268 @@
"""Security acceptance suite (EP-15-T02, README.md §45): the 10 formal tests,
automated, running against the **real `docker-compose.yml`** (`core` +
`identity` profiles) -- a real `docker compose up --build`, a real Keycloak
container, a real containerized Gateway/OPA/Sales/Finance, not the
in-process fixtures the rest of this suite uses for speed.
Session-scoped: bringing the stack up (build + Keycloak cold start) costs
~30-60s, too slow to pay per test. Skipped, not failed, if `docker` is not
on PATH (matching `opa_url`/`keycloak_url` elsewhere in this suite).
Coverage note, for the 3 tests not implemented directly against this stack:
- **Teste 3 (prompt injection)** is reproduced with a *real* LLM in
`tests/e2e/test_example_agent.py::test_prompt_injection_never_lets_a_sales_agent_execute_a_finance_payment`
-- duplicating a full LLM-tool-calling round trip *and* a full Docker
stack in the same test would be expensive for no additional coverage;
the two independent dimensions (real LLM behavior, real containerized
Gateway enforcement) are each proven once, not both at once.
- **Teste 6 (policy revocation)** has no HTTP-exposed "revoke" endpoint in
this RI (`EntitlementManager.revoke_client`/`revoke_profile`, EP-04-T03,
are in-process methods of a component a deployment embeds -- there is no
admin API surfaced through the Gateway to trigger one from outside the
process). It is exercised directly in `tests/unit/test_entitlement_manager.py`.
- **Teste 8 (token audience)** is proven against the same real Keycloak
realm/config in
`tests/e2e/test_real_keycloak.py::test_real_keycloak_token_rejected_for_wrong_audience`
-- that file's Keycloak container uses the identical `deploy/keycloak/realm-export.json`
this stack does, just launched via a bespoke `docker run` instead of this
compose file, for a faster per-file fixture; the IdP config under test is
the same either way.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import mcp_types as types
import pytest
from mcp.client.session import ClientSession
from mcp.client.streamable_http import ( # type: ignore[attr-defined]
create_mcp_http_client,
streamable_http_client,
)
REPO_ROOT = Path(__file__).parents[2]
REALM_NAME = "emcp"
GATEWAY_URL = "http://127.0.0.1:8000/mcp"
KEYCLOAK_URL = "http://127.0.0.1:8080"
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_compose_env() -> dict[str, str]:
env = os.environ.copy()
env["OIDC_JWKS_URL"] = f"http://keycloak:8080/realms/{REALM_NAME}/protocol/openid-connect/certs"
env["OIDC_ISSUER"] = f"http://keycloak:8080/realms/{REALM_NAME}"
env["OIDC_AUDIENCE"] = "emcp-gateway"
return env
@pytest.fixture(scope="module")
def docker_stack() -> Iterator[None]:
if shutil.which("docker") is None:
pytest.skip("docker not found on PATH -- required for the docker-compose security suite")
env = _docker_compose_env()
subprocess.run(
["docker", "compose", "--profile", "core", "--profile", "identity", "up", "-d", "--build"],
cwd=REPO_ROOT,
env=env,
check=True,
capture_output=True,
)
try:
for _ in range(120):
try:
if httpx.get(f"{KEYCLOAK_URL}/realms/{REALM_NAME}", timeout=1).status_code == 200:
break
except httpx.HTTPError:
pass
time.sleep(1)
else:
raise RuntimeError("keycloak did not become ready in the compose stack")
for _ in range(60):
try:
token = _fetch_token("sales-read-agent")
httpx.post(
GATEWAY_URL,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "acceptance-suite", "version": "0"},
},
},
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
},
timeout=2,
)
break
except httpx.HTTPError:
time.sleep(1)
yield
finally:
subprocess.run(
["docker", "compose", "--profile", "core", "--profile", "identity", "down"],
cwd=REPO_ROOT,
env=env,
capture_output=True,
)
def _fetch_token(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
async def _call_gateway(
operation_name: str, arguments: dict[str, Any] | None, *, token: str | None
) -> types.ListToolsResult | types.CallToolResult:
headers = {"Authorization": f"Bearer {token}"} if token else None
async with (
create_mcp_http_client(headers=headers) as http_client,
streamable_http_client(GATEWAY_URL, http_client=http_client) as (read, write),
ClientSession(read, write) as session,
):
await session.initialize()
if operation_name == "tools/list":
return await session.list_tools()
assert arguments is not None
return await session.call_tool(operation_name, arguments)
def _text(result: types.CallToolResult) -> str:
return " ".join(getattr(item, "text", "") for item in result.content)
async def test_teste1_unauthorized_discovery_against_real_docker(docker_stack: None) -> None:
token = _fetch_token("sales-read-agent")
result = await _call_gateway("tools/list", None, token=token)
assert isinstance(result, types.ListToolsResult)
tool_names = {t.name for t in result.tools}
assert "finance.payment.execute" not in tool_names
async def test_teste2_direct_unauthorized_call_against_real_docker(docker_stack: None) -> None:
token = _fetch_token("sales-read-agent")
result = await _call_gateway(
"finance.payment.execute", {"payment_id": "pay-001", "amount": 1_000_000}, token=token
)
assert isinstance(result, types.CallToolResult)
assert result.is_error is True
async def test_teste4_agent_role_spoofing_against_real_docker(docker_stack: None) -> None:
token = _fetch_token("sales-read-agent")
result = await _call_gateway(
"finance.payment.execute",
{"amount": 100, "role": "finance-admin", "client_profile": "Finance.Payments"},
token=token,
)
assert isinstance(result, types.CallToolResult)
assert result.is_error is True
async def test_teste5_cache_isolation_against_real_docker(docker_stack: None) -> None:
sales_token = _fetch_token("sales-read-agent")
finance_token = _fetch_token("finance-payments-agent")
sales_result = await _call_gateway("tools/list", None, token=sales_token)
finance_result = await _call_gateway("tools/list", None, token=finance_token)
assert isinstance(sales_result, types.ListToolsResult)
assert isinstance(finance_result, types.ListToolsResult)
sales_names = {t.name for t in sales_result.tools}
finance_names = {t.name for t in finance_result.tools}
assert sales_names.isdisjoint(finance_names)
assert sales_result.cache_scope == "private"
assert finance_result.cache_scope == "private"
async def test_teste7_gateway_bypass_against_real_docker(docker_stack: None) -> None:
"""EP-05-T07: `sales-domain`/`finance-domain`/`opa` publish no host port
in the real compose file -- a direct connection attempt from the host
(outside the compose network) must fail."""
with pytest.raises(httpx.HTTPError):
httpx.get("http://127.0.0.1:8100/mcp", timeout=2)
async def test_teste9_backend_isolation_against_real_docker(docker_stack: None) -> None:
"""A tool a client IS entitled to must never reach a backend/resource
the transaction policy excludes -- Sales.Write's quote.create above its
own transaction limit is denied even though the tool itself is allowed."""
token = _fetch_token("sales-write-agent")
result = await _call_gateway(
"sales.quote.create",
{"customer_id": "cust-001", "amount": 999_999_999, "region": "BR-SP"},
token=token,
)
assert isinstance(result, types.CallToolResult)
assert result.is_error is True
assert "TRANSACTION_POLICY_DENIED" in _text(result)
async def test_teste10_client_compromise_containment_against_real_docker(
docker_stack: None,
) -> None:
"""A "stolen credential" (a real, validly-issued token for sales-read-agent)
still cannot exceed that client's own MaximumEntitlement -- containment,
not just correct discovery."""
stolen_token = _fetch_token("sales-read-agent")
tools_list = await _call_gateway("tools/list", None, token=stolen_token)
assert isinstance(tools_list, types.ListToolsResult)
assert {t.name for t in tools_list.tools} == {
"sales.customer.search",
"sales.customer.get",
"sales.order.get",
"sales.inventory.check",
}
write_attempt = await _call_gateway(
"sales.quote.create", {"customer_id": "cust-001", "amount": 100}, token=stolen_token
)
assert isinstance(write_attempt, types.CallToolResult)
assert write_attempt.is_error is True
finance_attempt = await _call_gateway(
"finance.payment.execute", {"payment_id": "pay-001", "amount": 100}, token=stolen_token
)
assert isinstance(finance_attempt, types.CallToolResult)
assert finance_attempt.is_error is True