mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
269 lines
10 KiB
Python
269 lines
10 KiB
Python
"""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
|