mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
M0 + M1 done
This commit is contained in:
0
RI/tests/__init__.py
Normal file
0
RI/tests/__init__.py
Normal file
0
RI/tests/e2e/__init__.py
Normal file
0
RI/tests/e2e/__init__.py
Normal file
273
RI/tests/e2e/conftest.py
Normal file
273
RI/tests/e2e/conftest.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Shared e2e fixtures: a real OPA process, a real JWKS HTTP endpoint + RS256
|
||||
token signing, and helpers to run the Gateway/Sales servers in-process
|
||||
against them. No Keycloak available in this environment (no Java) -- see
|
||||
RI/README.md for exactly what that does and does not validate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
import uvicorn
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import ( # type: ignore[attr-defined]
|
||||
create_mcp_http_client,
|
||||
streamable_http_client,
|
||||
)
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2]
|
||||
POLICIES_DIR = REPO_ROOT / "config" / "policies"
|
||||
|
||||
ISSUER = "https://keycloak.example/realms/emcp"
|
||||
AUDIENCE = "emcp-gateway"
|
||||
KID = "test-key-1"
|
||||
|
||||
GATEWAY_PORT = 18000
|
||||
SALES_PORT = 18100
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return int(s.getsockname()[1])
|
||||
|
||||
|
||||
# --- Real OPA process (EP-03) --------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def opa_url() -> Iterator[str]:
|
||||
opa_binary = shutil.which(os.environ.get("OPA_BINARY", "opa"))
|
||||
if opa_binary is None:
|
||||
pytest.skip(
|
||||
"opa binary not found on PATH (set OPA_BINARY or install it -- see RI/README.md)"
|
||||
)
|
||||
|
||||
port = free_port()
|
||||
process = subprocess.Popen(
|
||||
[opa_binary, "run", "--server", "--addr", f"127.0.0.1:{port}", str(POLICIES_DIR)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
import httpx
|
||||
|
||||
for _ in range(100):
|
||||
try:
|
||||
httpx.get(f"{base_url}/health", timeout=1)
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("opa server did not become healthy in time")
|
||||
yield base_url
|
||||
finally:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
# --- Real JWKS HTTP endpoint + RS256 signing (EP-01-T03) ------------------
|
||||
|
||||
|
||||
class _JWKSHandler(BaseHTTPRequestHandler):
|
||||
jwks_document: dict[str, object] = {}
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
body = json.dumps(self.jwks_document).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def rsa_keypair() -> tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
return private_key, private_key.public_key()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def jwks_url(rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]) -> Iterator[str]:
|
||||
_, public_key = rsa_keypair
|
||||
jwk = RSAAlgorithm.to_jwk(public_key, as_dict=True)
|
||||
jwk.update({"kid": KID, "use": "sig", "alg": "RS256"})
|
||||
_JWKSHandler.jwks_document = {"keys": [jwk]}
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), _JWKSHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}/jwks"
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sign_token(
|
||||
rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey],
|
||||
) -> Callable[..., str]:
|
||||
private_key, _ = rsa_keypair
|
||||
|
||||
def _sign(
|
||||
client_id: str,
|
||||
*,
|
||||
issuer: str = ISSUER,
|
||||
audience: str = AUDIENCE,
|
||||
expires_in_seconds: int = 300,
|
||||
signing_key: rsa.RSAPrivateKey | None = None,
|
||||
) -> str:
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"sub": f"service-account-{client_id}",
|
||||
"azp": client_id,
|
||||
"iat": now,
|
||||
"exp": now + expires_in_seconds,
|
||||
}
|
||||
return jwt.encode(
|
||||
claims, signing_key or private_key, algorithm="RS256", headers={"kid": KID}
|
||||
)
|
||||
|
||||
return _sign
|
||||
|
||||
|
||||
# --- Running Gateway + Sales servers, wired to the fixtures above --------
|
||||
|
||||
|
||||
class RunningServer:
|
||||
def __init__(self, config: uvicorn.Config) -> None:
|
||||
self.server = uvicorn.Server(config)
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._task = asyncio.create_task(self.server.serve())
|
||||
while not self.server.started:
|
||||
await asyncio.sleep(0.01)
|
||||
# See test_walking_skeleton.py's original note: give the ASGI app's
|
||||
# own lifespan (session manager task group) one extra tick.
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.server.should_exit = True
|
||||
assert self._task is not None
|
||||
await self._task
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def governed_stack(
|
||||
monkeypatch: pytest.MonkeyPatch, opa_url: str, jwks_url: str
|
||||
) -> 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")
|
||||
monkeypatch.setenv("OPA_URL", opa_url)
|
||||
monkeypatch.setenv("OIDC_JWKS_URL", jwks_url)
|
||||
monkeypatch.setenv("OIDC_ISSUER", ISSUER)
|
||||
monkeypatch.setenv("OIDC_AUDIENCE", AUDIENCE)
|
||||
|
||||
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,
|
||||
log_level="warning",
|
||||
)
|
||||
sales_server = RunningServer(sales_config)
|
||||
await sales_server.start()
|
||||
|
||||
import emcp_bus.gateway.server as gateway_module
|
||||
|
||||
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
|
||||
),
|
||||
host="127.0.0.1",
|
||||
port=GATEWAY_PORT,
|
||||
log_level="warning",
|
||||
)
|
||||
gateway_server = RunningServer(gateway_config)
|
||||
await gateway_server.start()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await gateway_server.stop()
|
||||
await sales_server.stop()
|
||||
|
||||
|
||||
async def run_against_gateway[ResultT](
|
||||
operation: Callable[[ClientSession], Awaitable[ResultT]],
|
||||
*,
|
||||
token: str | None = None,
|
||||
) -> ResultT:
|
||||
"""Connect to the gateway (optionally authenticated) and run `operation(session)`.
|
||||
|
||||
Retries on `MCPError` ("SSE stream ended without a response" has been
|
||||
observed, rarely, under this in-process dual-uvicorn test setup under
|
||||
scheduler contention) -- a test-harness robustness concern, not a
|
||||
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"
|
||||
last_error: MCPError | None = None
|
||||
for attempt in range(3):
|
||||
if attempt:
|
||||
await asyncio.sleep(0.1 * attempt)
|
||||
try:
|
||||
async with (
|
||||
create_mcp_http_client(headers=headers) as http_client,
|
||||
streamable_http_client(url, http_client=http_client) as (read, write),
|
||||
ClientSession(read, write) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
return await operation(session)
|
||||
except MCPError as exc:
|
||||
last_error = exc
|
||||
except BaseExceptionGroup as group:
|
||||
# Each nested anyio TaskGroup (`streamable_http_client`'s, then
|
||||
# `ClientSession`'s) wraps the one below it in its own
|
||||
# ExceptionGroup, so a handler-raised MCPError (e.g. an auth
|
||||
# rejection during `initialize`) can arrive doubly-wrapped.
|
||||
# Unwrap fully so callers can `pytest.raises(MCPError)`
|
||||
# uniformly, same as for one raised directly.
|
||||
unwrapped = _unwrap_single_mcp_error(group)
|
||||
if unwrapped is not None:
|
||||
last_error = unwrapped
|
||||
else:
|
||||
raise
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
|
||||
def _unwrap_single_mcp_error(exc: BaseException) -> MCPError | None:
|
||||
"""If `exc` is an MCPError, or a (possibly nested) ExceptionGroup wrapping
|
||||
exactly one, return it; otherwise `None`."""
|
||||
if isinstance(exc, MCPError):
|
||||
return exc
|
||||
if isinstance(exc, BaseExceptionGroup) and len(exc.exceptions) == 1:
|
||||
return _unwrap_single_mcp_error(exc.exceptions[0])
|
||||
return None
|
||||
242
RI/tests/e2e/test_governed_gateway.py
Normal file
242
RI/tests/e2e/test_governed_gateway.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""EP-05-T02/T04 governed Gateway: the actual security invariants of
|
||||
README.md §45 ("Critérios de aceite de segurança"), run against the real
|
||||
chain -- real OIDC token validation (JWKS server standing in for Keycloak),
|
||||
real OPA-backed PDP, real EntitlementManager loading the real config/
|
||||
YAML files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
from tests.e2e.conftest import run_against_gateway
|
||||
|
||||
SALES_READ_CLIENT_ID = "sales-read-agent"
|
||||
SALES_WRITE_CLIENT_ID = "sales-write-agent"
|
||||
FINANCE_PAYMENTS_CLIENT_ID = "finance-payments-agent"
|
||||
|
||||
|
||||
async def test_teste1_unauthorized_discovery(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""README.md §45 Teste 1: finance.createPayment MUST NOT be returned to Sales.Read."""
|
||||
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 "finance.payment.execute" not in tool_names
|
||||
assert "sales.quote.create" not in tool_names # Sales.Write-only, not Sales.Read
|
||||
|
||||
|
||||
async def test_teste2_direct_unauthorized_call(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""README.md §45 Teste 2: calling a known-but-unentitled tool directly, without
|
||||
ever having seen it in tools/list, must still DENY."""
|
||||
token = sign_token(SALES_READ_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(
|
||||
lambda session: session.call_tool("finance.payment.execute", {"amount": 1_000_000}),
|
||||
token=token,
|
||||
)
|
||||
|
||||
assert result.is_error is True
|
||||
assert "Access denied" in _text(result)
|
||||
|
||||
|
||||
async def test_teste4_agent_role_spoofing_has_no_effect(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""README.md §45 Teste 4: nothing the caller declares in tool arguments
|
||||
(an agent-shaped 'role' claim in this case) can expand entitlement."""
|
||||
token = sign_token(SALES_READ_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(
|
||||
lambda session: session.call_tool(
|
||||
"finance.payment.execute",
|
||||
{"amount": 100, "role": "finance-admin", "client_profile": "Finance.Payments"},
|
||||
),
|
||||
token=token,
|
||||
)
|
||||
|
||||
assert result.is_error is True
|
||||
|
||||
|
||||
async def test_unauthenticated_request_sees_no_tools_and_is_denied(
|
||||
governed_stack: None,
|
||||
) -> None:
|
||||
"""README.md §5: 'No entitlement -> no discovery -> no execution'.
|
||||
|
||||
With OIDC configured, the SDK's own `RequireAuthMiddleware` rejects a
|
||||
connection with no bearer token before the MCP session handshake
|
||||
(`initialize`) ever completes -- stricter than this Gateway's own
|
||||
handler-level `access_token is None` checks (`on_list_tools`/
|
||||
`on_call_tool`), which exist as the deny-by-default backstop for the
|
||||
*other* supported deployment mode: no OIDC configured at all (see
|
||||
`GatewayConfig`/`build_auth` in emcp_bus/gateway/server.py), where every
|
||||
request reaches the handlers unauthenticated and those checks are what
|
||||
enforces "no discovery, no execution" instead.
|
||||
"""
|
||||
with pytest.raises(MCPError):
|
||||
await run_against_gateway(lambda session: session.list_tools())
|
||||
|
||||
|
||||
async def test_expired_token_is_denied(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""README.md §45 Teste 3 (adjacent)/§46: expired token, adversarial scenario."""
|
||||
token = sign_token(SALES_READ_CLIENT_ID, expires_in_seconds=-3600)
|
||||
|
||||
with pytest.raises(MCPError):
|
||||
await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||
|
||||
|
||||
async def test_wrong_audience_token_is_denied(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""README.md §45 Teste 8: a token issued for a different resource server."""
|
||||
token = sign_token(SALES_READ_CLIENT_ID, audience="some-other-service")
|
||||
|
||||
with pytest.raises(MCPError):
|
||||
await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||
|
||||
|
||||
async def test_forged_signature_token_is_denied(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""README.md §46: stolen/forged token, signed by a key not in the JWKS."""
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
forged_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
token = sign_token(SALES_READ_CLIENT_ID, signing_key=forged_key)
|
||||
|
||||
with pytest.raises(MCPError):
|
||||
await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||
|
||||
|
||||
async def test_transaction_policy_denies_over_limit_even_though_entitled(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""README.md §25 Passo 2: sales.quote.create IS in Sales.Write's entitlement,
|
||||
but the transaction policy still denies an amount above the profile's limit."""
|
||||
token = sign_token(SALES_WRITE_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(
|
||||
lambda session: session.call_tool(
|
||||
"sales.quote.create", {"amount": 999_999_999, "region": "BR-SP"}
|
||||
),
|
||||
token=token,
|
||||
)
|
||||
|
||||
assert result.is_error is True
|
||||
assert "TRANSACTION_POLICY_DENIED" in _text(result)
|
||||
|
||||
|
||||
async def test_require_approval_is_denied_pending_ep14(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""README.md §26: Finance.Payments allows payment.execute below 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."""
|
||||
token = sign_token(FINANCE_PAYMENTS_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(
|
||||
lambda session: session.call_tool("finance.payment.execute", {"amount": 50_000}),
|
||||
token=token,
|
||||
)
|
||||
|
||||
assert result.is_error is True
|
||||
assert "REQUIRE_APPROVAL" in _text(result)
|
||||
|
||||
|
||||
async def test_gateway_lifespan_fails_closed_when_pdp_unreachable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""README.md §38: PDP indisponível -> fail closed, never ALLOW-by-omission.
|
||||
|
||||
A Gateway that cannot sync entitlements to its PDP at startup must not
|
||||
come up looking healthy (EP-05-T06): its `lifespan` must raise, not swallow
|
||||
the error and yield a state built on an empty/stale entitlement sync.
|
||||
"""
|
||||
from tests.e2e.conftest import free_port
|
||||
|
||||
monkeypatch.setenv("OPA_URL", f"http://127.0.0.1:{free_port()}") # nothing listening here
|
||||
|
||||
import emcp_bus.gateway.server as gateway_module
|
||||
from emcp_bus.pdp.client import PDPUnavailableError
|
||||
|
||||
with pytest.raises(PDPUnavailableError):
|
||||
async with gateway_module.lifespan(gateway_module.server):
|
||||
pytest.fail("lifespan must not yield a state when the PDP sync failed")
|
||||
|
||||
|
||||
async def test_deny_by_default_when_no_oidc_provider_is_configured(
|
||||
monkeypatch: pytest.MonkeyPatch, opa_url: str
|
||||
) -> None:
|
||||
"""README.md §5: the *other* supported deployment mode (no OIDC provider
|
||||
configured at all -- `OIDC_JWKS_URL`/`OIDC_ISSUER` unset). No
|
||||
`RequireAuthMiddleware` is installed in this mode (see `build_auth`), so
|
||||
every request reaches the handlers with `get_access_token() is None`,
|
||||
and it is the Gateway's own handler-level checks that must deny by
|
||||
default -- this is the code path Teste 1-style discovery filtering and
|
||||
Teste 2-style execution denial fall back to without an IdP.
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
from tests.e2e.conftest import GATEWAY_PORT, SALES_PORT, RunningServer, run_against_gateway
|
||||
|
||||
monkeypatch.setenv("SALES_DOMAIN_URL", f"http://127.0.0.1:{SALES_PORT}/mcp")
|
||||
monkeypatch.setenv("OPA_URL", opa_url)
|
||||
monkeypatch.delenv("OIDC_JWKS_URL", raising=False)
|
||||
monkeypatch.delenv("OIDC_ISSUER", raising=False)
|
||||
|
||||
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()
|
||||
assert (auth_settings, token_verifier) == (None, None) # confirms the mode under test
|
||||
|
||||
gateway_server = RunningServer(
|
||||
uvicorn.Config(
|
||||
gateway_module.server.streamable_http_app(
|
||||
host="127.0.0.1", auth=auth_settings, token_verifier=token_verifier
|
||||
),
|
||||
host="127.0.0.1",
|
||||
port=GATEWAY_PORT,
|
||||
log_level="warning",
|
||||
)
|
||||
)
|
||||
await gateway_server.start()
|
||||
try:
|
||||
list_result = await run_against_gateway(lambda session: session.list_tools())
|
||||
assert list_result.tools == []
|
||||
|
||||
call_result = await run_against_gateway(
|
||||
lambda session: session.call_tool("sales.customer.get", {"customer_id": "cust-001"})
|
||||
)
|
||||
assert call_result.is_error is True
|
||||
assert "UNAUTHENTICATED" in _text(call_result)
|
||||
finally:
|
||||
await gateway_server.stop()
|
||||
await sales_server.stop()
|
||||
|
||||
|
||||
def _text(result: object) -> str:
|
||||
content = getattr(result, "content", [])
|
||||
return " ".join(getattr(item, "text", "") for item in content)
|
||||
105
RI/tests/e2e/test_walking_skeleton.py
Normal file
105
RI/tests/e2e/test_walking_skeleton.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""M0 walking skeleton, now exercised through M1's real enforcement chain
|
||||
(docs/RI-PLANNING.md, Marco M0 + M1: EP-01/EP-03/EP-04/EP-05).
|
||||
|
||||
Client -> Gateway (real OIDC auth + real OPA-backed entitlement) -> (static
|
||||
routing) -> example Sales MCP server -> response, over real HTTP
|
||||
(streamable-http), with a single OTel trace_id correlating spans across both
|
||||
processes. Authenticates as `sales-read-agent` (Sales.Read, README.md §7.1)
|
||||
calling only tools within its own entitlement -- the denial paths (no token,
|
||||
wrong tool, expired/forged token, PDP down) are tests/e2e/test_governed_gateway.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
from opentelemetry.trace import SpanKind
|
||||
|
||||
from tests.e2e.conftest import run_against_gateway
|
||||
|
||||
SALES_READ_CLIENT_ID = "sales-read-agent"
|
||||
|
||||
|
||||
async def test_tools_list_is_filtered_to_sales_read_entitlement(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
token = sign_token(SALES_READ_CLIENT_ID)
|
||||
|
||||
result = await run_against_gateway(lambda session: session.list_tools(), token=token)
|
||||
|
||||
# README.md §7.1: Sales.Read sees exactly these four -- not
|
||||
# sales.quote.create/update (Sales.Write-only) and not any Finance tool.
|
||||
assert {tool.name for tool in result.tools} == {
|
||||
"sales.customer.search",
|
||||
"sales.customer.get",
|
||||
"sales.order.get",
|
||||
"sales.inventory.check",
|
||||
}
|
||||
|
||||
|
||||
async def test_tools_call_reaches_the_backend_and_returns_its_result(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
token = sign_token(SALES_READ_CLIENT_ID)
|
||||
|
||||
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_a_single_trace_id_correlates_gateway_and_backend_spans(
|
||||
governed_stack: None, sign_token: Callable[..., str]
|
||||
) -> None:
|
||||
"""Both hops of one `tools/call` must land on the same trace_id.
|
||||
|
||||
This test runs both server processes in-process (in-process uvicorn, for
|
||||
speed), so they share one OTel global TracerProvider/resource -- unlike a
|
||||
real multi-process deployment (verified manually: each process gets its
|
||||
own `service.name` resource), so `resource.attributes["service.name"]`
|
||||
cannot be used here to tell the two hops apart. `SpanKind.SERVER` can:
|
||||
the gateway receiving the call from the client, and the Sales server
|
||||
receiving it from the gateway, are each a distinct SERVER-kind
|
||||
`tools/call ...` span -- exactly two (or more, under a rare transport
|
||||
retry -- see `run_against_gateway`) of them sharing one trace_id proves
|
||||
the request was correlated across both hops.
|
||||
"""
|
||||
from opentelemetry import trace
|
||||
|
||||
exporter = InMemorySpanExporter()
|
||||
trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore[attr-defined]
|
||||
|
||||
token = sign_token(SALES_READ_CLIENT_ID)
|
||||
await run_against_gateway(
|
||||
lambda session: session.call_tool("sales.customer.get", {"customer_id": "cust-001"}),
|
||||
token=token,
|
||||
)
|
||||
|
||||
spans: tuple[ReadableSpan, ...] = exporter.get_finished_spans()
|
||||
server_tool_call_spans = [
|
||||
span
|
||||
for span in spans
|
||||
if span.name.startswith("tools/call") and span.kind == SpanKind.SERVER
|
||||
]
|
||||
trace_ids = {span.context.trace_id for span in server_tool_call_spans}
|
||||
|
||||
assert len(trace_ids) == 1, (
|
||||
"Expected exactly one trace_id shared by both hops, got "
|
||||
f"{[f'{t:#x}' for t in trace_ids]} across spans {[s.name for s in server_tool_call_spans]}"
|
||||
)
|
||||
assert len(server_tool_call_spans) >= 2, (
|
||||
"Expected at least one SERVER 'tools/call' span for the gateway hop and one "
|
||||
f"for the backend hop, got {len(server_tool_call_spans)}: "
|
||||
f"{[s.name for s in server_tool_call_spans]} -- see Marco M0 acceptance "
|
||||
"criterion in docs/RI-PLANNING.md"
|
||||
)
|
||||
0
RI/tests/unit/__init__.py
Normal file
0
RI/tests/unit/__init__.py
Normal file
180
RI/tests/unit/test_authn.py
Normal file
180
RI/tests/unit/test_authn.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""TokenValidator against a real JWKS HTTP endpoint and real RS256-signed
|
||||
tokens (EP-01-T03) -- no Keycloak available in this environment (no Java),
|
||||
so a local JWKS server + self-issued tokens exercise the actual OIDC
|
||||
validation logic (signature, expiry, issuer, audience) that Keycloak would
|
||||
otherwise be the source of. See RI/README.md for what this does and does
|
||||
not prove.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
|
||||
from emcp_bus.identity.authn import AuthenticationError, TokenValidator
|
||||
|
||||
ISSUER = "https://keycloak.example/realms/emcp"
|
||||
AUDIENCE = "emcp-gateway"
|
||||
KID = "test-key-1"
|
||||
|
||||
|
||||
class _JWKSHandler(BaseHTTPRequestHandler):
|
||||
jwks_document: dict[str, object] = {}
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 -- BaseHTTPRequestHandler's naming convention
|
||||
body = json.dumps(self.jwks_document).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A002 -- stdlib signature
|
||||
pass # keep test output quiet
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rsa_keypair() -> tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]:
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
return private_key, private_key.public_key()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def jwks_server(
|
||||
rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey],
|
||||
) -> Iterator[str]:
|
||||
_, public_key = rsa_keypair
|
||||
jwk = RSAAlgorithm.to_jwk(public_key, as_dict=True)
|
||||
jwk.update({"kid": KID, "use": "sig", "alg": "RS256"})
|
||||
_JWKSHandler.jwks_document = {"keys": [jwk]}
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), _JWKSHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}/jwks"
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def _sign_token(
|
||||
private_key: rsa.RSAPrivateKey,
|
||||
*,
|
||||
issuer: str = ISSUER,
|
||||
audience: str = AUDIENCE,
|
||||
client_id: str = "sales-read-agent",
|
||||
expires_in_seconds: int = 300,
|
||||
client_id_claim: str = "azp",
|
||||
) -> str:
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"sub": "service-account-sales-read-agent",
|
||||
client_id_claim: client_id,
|
||||
"iat": now,
|
||||
"exp": now + expires_in_seconds,
|
||||
}
|
||||
return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": KID})
|
||||
|
||||
|
||||
def test_valid_token_resolves_the_authenticated_client_id(
|
||||
rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey], jwks_server: str
|
||||
) -> None:
|
||||
private_key, _ = rsa_keypair
|
||||
token = _sign_token(private_key, client_id="sales-write-agent")
|
||||
|
||||
validator = TokenValidator(jwks_url=jwks_server, issuer=ISSUER, audience=AUDIENCE)
|
||||
authenticated = validator.validate(token)
|
||||
|
||||
assert authenticated.client_id == "sales-write-agent"
|
||||
assert authenticated.issuer == ISSUER
|
||||
|
||||
|
||||
def test_client_id_claim_fallback_when_azp_absent(
|
||||
rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey], jwks_server: str
|
||||
) -> None:
|
||||
private_key, _ = rsa_keypair
|
||||
token = _sign_token(private_key, client_id="sales-write-agent", client_id_claim="client_id")
|
||||
|
||||
validator = TokenValidator(jwks_url=jwks_server, issuer=ISSUER, audience=AUDIENCE)
|
||||
authenticated = validator.validate(token)
|
||||
|
||||
assert authenticated.client_id == "sales-write-agent"
|
||||
|
||||
|
||||
def test_expired_token_is_rejected(
|
||||
rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey], jwks_server: str
|
||||
) -> None:
|
||||
private_key, _ = rsa_keypair
|
||||
token = _sign_token(private_key, expires_in_seconds=-3600)
|
||||
|
||||
validator = TokenValidator(jwks_url=jwks_server, issuer=ISSUER, audience=AUDIENCE)
|
||||
with pytest.raises(AuthenticationError):
|
||||
validator.validate(token)
|
||||
|
||||
|
||||
def test_wrong_audience_is_rejected(
|
||||
rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey], jwks_server: str
|
||||
) -> None:
|
||||
"""README.md §45 Teste 8: a token issued for a different resource server must be rejected."""
|
||||
private_key, _ = rsa_keypair
|
||||
token = _sign_token(private_key, audience="some-other-resource-server")
|
||||
|
||||
validator = TokenValidator(jwks_url=jwks_server, issuer=ISSUER, audience=AUDIENCE)
|
||||
with pytest.raises(AuthenticationError):
|
||||
validator.validate(token)
|
||||
|
||||
|
||||
def test_wrong_issuer_is_rejected(
|
||||
rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey], jwks_server: str
|
||||
) -> None:
|
||||
private_key, _ = rsa_keypair
|
||||
token = _sign_token(private_key, issuer="https://attacker.example/realms/fake")
|
||||
|
||||
validator = TokenValidator(jwks_url=jwks_server, issuer=ISSUER, audience=AUDIENCE)
|
||||
with pytest.raises(AuthenticationError):
|
||||
validator.validate(token)
|
||||
|
||||
|
||||
def test_token_signed_by_an_untrusted_key_is_rejected(jwks_server: str) -> None:
|
||||
"""A different keypair than the one in the JWKS -- e.g. a stolen/forged token."""
|
||||
forged_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
token = _sign_token(forged_key)
|
||||
|
||||
validator = TokenValidator(jwks_url=jwks_server, issuer=ISSUER, audience=AUDIENCE)
|
||||
with pytest.raises(AuthenticationError):
|
||||
validator.validate(token)
|
||||
|
||||
|
||||
def test_agent_declared_role_claim_is_never_consulted(
|
||||
rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey], jwks_server: str
|
||||
) -> None:
|
||||
"""README.md §35.1 anti-pattern / Axiom 2: a bonus claim on an otherwise
|
||||
valid token must have zero effect on the resolved client_id."""
|
||||
private_key, _ = rsa_keypair
|
||||
now = int(time.time())
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"aud": AUDIENCE,
|
||||
"sub": "service-account-sales-read-agent",
|
||||
"azp": "sales-read-agent",
|
||||
"role": "finance-admin", # attacker-controlled-shaped claim, never trusted
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
}
|
||||
token = jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": KID})
|
||||
|
||||
validator = TokenValidator(jwks_url=jwks_server, issuer=ISSUER, audience=AUDIENCE)
|
||||
authenticated = validator.validate(token)
|
||||
|
||||
assert authenticated.client_id == "sales-read-agent"
|
||||
assert not hasattr(authenticated, "role")
|
||||
165
RI/tests/unit/test_entitlement_manager.py
Normal file
165
RI/tests/unit/test_entitlement_manager.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""EntitlementManager against real OPA (EP-04-T02/T03/T04), loading the
|
||||
actual YAML client profiles/registrations checked into config/ -- not
|
||||
hand-built fixtures, so a schema or naming mistake in the YAML itself would
|
||||
fail these tests too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from emcp_bus.common.config import load_yaml_model, load_yaml_models
|
||||
from emcp_bus.common.models import RiskTier
|
||||
from emcp_bus.entitlement.manager import EntitlementManager, UnknownClientError
|
||||
from emcp_bus.entitlement.models import ClientProfile
|
||||
from emcp_bus.identity.models import ClientRegistration
|
||||
from emcp_bus.pdp.client import PDPClient, compute_policy_version
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2]
|
||||
POLICIES_DIR = REPO_ROOT / "config" / "policies"
|
||||
PROFILES_DIR = REPO_ROOT / "config" / "clients" / "profiles"
|
||||
CLIENTS_DIR = REPO_ROOT / "config" / "clients"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return int(s.getsockname()[1])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def entitlement_manager() -> AsyncIterator[EntitlementManager]:
|
||||
opa_binary = shutil.which(os.environ.get("OPA_BINARY", "opa"))
|
||||
if opa_binary is None:
|
||||
pytest.skip(
|
||||
"opa binary not found on PATH (set OPA_BINARY or install it -- see RI/README.md)"
|
||||
)
|
||||
|
||||
port = _free_port()
|
||||
process = subprocess.Popen(
|
||||
[opa_binary, "run", "--server", "--addr", f"127.0.0.1:{port}", str(POLICIES_DIR)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
pdp = PDPClient(
|
||||
base_url=f"http://127.0.0.1:{port}",
|
||||
policy_version=compute_policy_version(POLICIES_DIR),
|
||||
)
|
||||
try:
|
||||
for _ in range(100):
|
||||
try:
|
||||
await pdp._http.get("/health")
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
await asyncio.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("opa server did not become healthy in time")
|
||||
|
||||
manager = EntitlementManager(pdp)
|
||||
manager.load_registrations(
|
||||
[
|
||||
load_yaml_model(path, ClientRegistration)
|
||||
for path in sorted(CLIENTS_DIR.glob("*.yaml"))
|
||||
]
|
||||
)
|
||||
manager.load_profiles(load_yaml_models(PROFILES_DIR, ClientProfile))
|
||||
await manager.sync_profiles_to_pdp()
|
||||
yield manager
|
||||
finally:
|
||||
await pdp.aclose()
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
async def test_resolves_sales_read_exactly_as_readme_7_1(
|
||||
entitlement_manager: EntitlementManager,
|
||||
) -> None:
|
||||
resolved = entitlement_manager.resolve("sales-read-agent")
|
||||
|
||||
assert resolved.profile_name == "Sales.Read"
|
||||
assert resolved.risk_ceiling is RiskTier.R1
|
||||
assert resolved.allowed_tools == frozenset(
|
||||
{
|
||||
"sales.customer.search",
|
||||
"sales.customer.get",
|
||||
"sales.order.get",
|
||||
"sales.inventory.check",
|
||||
}
|
||||
)
|
||||
assert resolved.entitlement_version
|
||||
|
||||
|
||||
async def test_sales_write_does_not_include_approve_tools(
|
||||
entitlement_manager: EntitlementManager,
|
||||
) -> None:
|
||||
resolved = entitlement_manager.resolve("sales-write-agent")
|
||||
|
||||
assert "sales.quote.create" in resolved.allowed_tools
|
||||
assert "sales.discount.approve" not in resolved.allowed_tools
|
||||
assert "finance.createPayment" not in resolved.allowed_tools
|
||||
|
||||
|
||||
async def test_unknown_client_id_raises() -> None:
|
||||
pdp = PDPClient(base_url="http://127.0.0.1:1", policy_version="dev")
|
||||
manager = EntitlementManager(pdp)
|
||||
try:
|
||||
with pytest.raises(UnknownClientError):
|
||||
manager.resolve("no-such-client")
|
||||
finally:
|
||||
await pdp.aclose()
|
||||
|
||||
|
||||
async def test_revoke_client_forces_re_resolution_to_fail(
|
||||
entitlement_manager: EntitlementManager,
|
||||
) -> None:
|
||||
entitlement_manager.resolve("sales-read-agent") # populate cache
|
||||
assert entitlement_manager.cached("sales-read-agent") is not None
|
||||
|
||||
entitlement_manager.revoke_client("sales-read-agent")
|
||||
|
||||
assert entitlement_manager.cached("sales-read-agent") is None
|
||||
with pytest.raises(UnknownClientError):
|
||||
entitlement_manager.resolve("sales-read-agent")
|
||||
|
||||
|
||||
async def test_revoke_profile_invalidates_cache_for_all_its_clients(
|
||||
entitlement_manager: EntitlementManager,
|
||||
) -> None:
|
||||
entitlement_manager.resolve("sales-read-agent")
|
||||
assert entitlement_manager.cached("sales-read-agent") is not None
|
||||
|
||||
entitlement_manager.revoke_profile("Sales.Read")
|
||||
|
||||
assert entitlement_manager.cached("sales-read-agent") is None
|
||||
# Registration itself is untouched -- the *next* resolve() recomputes
|
||||
# cleanly against whatever profile is currently loaded (RF-08).
|
||||
resolved_again = entitlement_manager.resolve("sales-read-agent")
|
||||
assert resolved_again.profile_name == "Sales.Read"
|
||||
|
||||
|
||||
async def test_entitlement_version_changes_when_profile_content_changes(
|
||||
entitlement_manager: EntitlementManager,
|
||||
) -> None:
|
||||
before = entitlement_manager.resolve("sales-read-agent").entitlement_version
|
||||
|
||||
mutated = ClientProfile.model_validate(
|
||||
{
|
||||
"name": "Sales.Read",
|
||||
"owner": "sales-platform",
|
||||
"risk_ceiling": "R1",
|
||||
"allowed_tools": ["sales.customer.get", "sales.order.get", "sales.inventory.check"],
|
||||
}
|
||||
)
|
||||
entitlement_manager.load_profiles([mutated])
|
||||
|
||||
after = entitlement_manager.resolve("sales-read-agent").entitlement_version
|
||||
|
||||
assert before != after
|
||||
154
RI/tests/unit/test_pdp_client.py
Normal file
154
RI/tests/unit/test_pdp_client.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""PDPClient against a *real* OPA process (EP-03-T01/T04) -- not a mock.
|
||||
|
||||
Requires the `opa` binary on PATH (or `OPA_BINARY` pointing at it); skips
|
||||
otherwise. See RI/README.md for install instructions -- OPA ships as a
|
||||
single static binary, no Docker required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from emcp_bus.pdp.client import PDPClient, PDPUnavailableError, compute_policy_version
|
||||
from emcp_bus.pdp.models import PDPOutcome
|
||||
|
||||
POLICIES_DIR = Path(__file__).parents[2] / "config" / "policies"
|
||||
|
||||
|
||||
def _opa_binary() -> str | None:
|
||||
return shutil.which(os.environ.get("OPA_BINARY", "opa"))
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return int(s.getsockname()[1])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pdp_client() -> AsyncIterator[PDPClient]:
|
||||
opa_binary = _opa_binary()
|
||||
if opa_binary is None:
|
||||
pytest.skip(
|
||||
"opa binary not found on PATH (set OPA_BINARY or install it -- see RI/README.md)"
|
||||
)
|
||||
|
||||
port = _free_port()
|
||||
process = subprocess.Popen(
|
||||
[opa_binary, "run", "--server", "--addr", f"127.0.0.1:{port}", str(POLICIES_DIR)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
client = PDPClient(
|
||||
base_url=f"http://127.0.0.1:{port}",
|
||||
policy_version=compute_policy_version(POLICIES_DIR),
|
||||
)
|
||||
try:
|
||||
for _ in range(100):
|
||||
try:
|
||||
await client._http.get("/health")
|
||||
break
|
||||
except Exception: # noqa: BLE001 -- retry loop, any error means "not ready yet"
|
||||
await asyncio.sleep(0.05)
|
||||
else:
|
||||
raise RuntimeError("opa server did not become healthy in time")
|
||||
yield client
|
||||
finally:
|
||||
await client.aclose()
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
async def test_policy_version_is_stable_and_nonempty() -> None:
|
||||
version = compute_policy_version(POLICIES_DIR)
|
||||
assert version
|
||||
assert version == compute_policy_version(POLICIES_DIR)
|
||||
|
||||
|
||||
async def test_deny_when_tool_not_in_entitlement(pdp_client: PDPClient) -> None:
|
||||
await pdp_client.push_client_profile("Sales.Read", {"allowed_tools": ["sales.customer.get"]})
|
||||
|
||||
decision = await pdp_client.evaluate(client_profile="Sales.Read", tool="finance.createPayment")
|
||||
|
||||
assert decision.outcome is PDPOutcome.DENY
|
||||
assert decision.reason_code == "NOT_IN_ENTITLEMENT"
|
||||
assert decision.policy_version
|
||||
|
||||
|
||||
async def test_allow_when_tool_in_entitlement_and_within_transaction_limits(
|
||||
pdp_client: PDPClient,
|
||||
) -> None:
|
||||
await pdp_client.push_client_profile(
|
||||
"Sales.Write",
|
||||
{
|
||||
"allowed_tools": ["sales.quote.create"],
|
||||
"restrictions": {"max_transaction_value": 500000, "approval_threshold": 100000},
|
||||
},
|
||||
)
|
||||
|
||||
decision = await pdp_client.evaluate(
|
||||
client_profile="Sales.Write",
|
||||
tool="sales.quote.create",
|
||||
arguments={"amount": 50000},
|
||||
)
|
||||
|
||||
assert decision.outcome is PDPOutcome.ALLOW
|
||||
assert decision.reason_code == "ALLOWED"
|
||||
|
||||
|
||||
async def test_deny_when_transaction_exceeds_limit_even_though_entitled(
|
||||
pdp_client: PDPClient,
|
||||
) -> None:
|
||||
await pdp_client.push_client_profile(
|
||||
"Sales.Write",
|
||||
{
|
||||
"allowed_tools": ["sales.quote.create"],
|
||||
"restrictions": {"max_transaction_value": 500000},
|
||||
},
|
||||
)
|
||||
|
||||
decision = await pdp_client.evaluate(
|
||||
client_profile="Sales.Write",
|
||||
tool="sales.quote.create",
|
||||
arguments={"amount": 999999},
|
||||
)
|
||||
|
||||
assert decision.outcome is PDPOutcome.DENY
|
||||
assert decision.reason_code == "TRANSACTION_POLICY_DENIED"
|
||||
|
||||
|
||||
async def test_require_approval_above_threshold(pdp_client: PDPClient) -> None:
|
||||
await pdp_client.push_client_profile(
|
||||
"Finance.Payments",
|
||||
{
|
||||
"allowed_tools": ["finance.payment.execute"],
|
||||
"restrictions": {"max_transaction_value": 10_000_000, "approval_threshold": 10_000},
|
||||
},
|
||||
)
|
||||
|
||||
decision = await pdp_client.evaluate(
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments={"amount": 15_000},
|
||||
)
|
||||
|
||||
assert decision.outcome is PDPOutcome.REQUIRE_APPROVAL
|
||||
assert decision.reason_code == "APPROVAL_THRESHOLD_EXCEEDED"
|
||||
|
||||
|
||||
async def test_fails_closed_when_opa_is_unreachable() -> None:
|
||||
unreachable_port = _free_port() # nothing listening here
|
||||
client = PDPClient(base_url=f"http://127.0.0.1:{unreachable_port}", policy_version="dev")
|
||||
try:
|
||||
with pytest.raises(PDPUnavailableError):
|
||||
await client.evaluate(client_profile="Sales.Read", tool="sales.customer.get")
|
||||
finally:
|
||||
await client.aclose()
|
||||
90
RI/tests/unit/test_schemas.py
Normal file
90
RI/tests/unit/test_schemas.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Sanity checks for the M1 config schemas (EP-01-T02, EP-02-T01, EP-04-T01)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from emcp_bus.common.models import RiskTier, risk_tier_at_or_below
|
||||
from emcp_bus.entitlement.models import ClientProfile
|
||||
from emcp_bus.identity.models import ClientRegistration
|
||||
from emcp_bus.registry.models import BackendRef, CapabilityManifest
|
||||
|
||||
|
||||
def test_risk_tier_ordering() -> None:
|
||||
assert risk_tier_at_or_below(RiskTier.R1, RiskTier.R2)
|
||||
assert not risk_tier_at_or_below(RiskTier.R3, RiskTier.R2)
|
||||
assert risk_tier_at_or_below(RiskTier.R0, RiskTier.R0)
|
||||
|
||||
|
||||
def test_client_profile_from_readme_example() -> None:
|
||||
profile = ClientProfile.model_validate(
|
||||
{
|
||||
"name": "Sales.Write",
|
||||
"owner": "sales-platform",
|
||||
"environment": "production",
|
||||
"risk_ceiling": "R2",
|
||||
"allowed_tools": [
|
||||
"sales.customer.get",
|
||||
"sales.order.get",
|
||||
"sales.quote.create",
|
||||
"sales.quote.update",
|
||||
],
|
||||
"restrictions": {"max_transaction_value": 500000},
|
||||
"credential_policy": {"workload_identity": True, "short_lived": True},
|
||||
"audit": {"level": "full"},
|
||||
}
|
||||
)
|
||||
assert profile.risk_ceiling is RiskTier.R2
|
||||
assert "sales.quote.create" in profile.allowed_tools
|
||||
|
||||
|
||||
def test_client_profile_rejects_flat_name() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ClientProfile.model_validate(
|
||||
{
|
||||
"name": "SalesWrite", # missing "<Domain>.<Qualifier>"
|
||||
"owner": "sales-platform",
|
||||
"risk_ceiling": "R2",
|
||||
"allowed_tools": ["sales.quote.create"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_client_registration_references_a_profile_by_name() -> None:
|
||||
registration = ClientRegistration.model_validate(
|
||||
{"client_id": "sales-write-agent", "profile": "Sales.Write"}
|
||||
)
|
||||
assert registration.profile == "Sales.Write"
|
||||
|
||||
|
||||
def test_capability_manifest_domain_must_prefix_name() -> None:
|
||||
manifest = CapabilityManifest.model_validate(
|
||||
{
|
||||
"name": "sales.quote.create",
|
||||
"version": "2.1.0",
|
||||
"domain": "sales",
|
||||
"owner": "sales-platform",
|
||||
"risk_tier": "R2",
|
||||
"side_effects": True,
|
||||
"idempotent": False,
|
||||
"entitlements": ["Sales.Write", "Sales.Approve"],
|
||||
"backend": {"type": "mcp", "service": "sales-domain"},
|
||||
}
|
||||
)
|
||||
assert manifest.backend == BackendRef(type="mcp", service="sales-domain")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CapabilityManifest.model_validate(
|
||||
{
|
||||
"name": "sales.quote.create",
|
||||
"version": "2.1.0",
|
||||
"domain": "finance", # mismatched prefix
|
||||
"owner": "sales-platform",
|
||||
"risk_tier": "R2",
|
||||
"side_effects": True,
|
||||
"idempotent": False,
|
||||
"entitlements": ["Sales.Write"],
|
||||
"backend": {"type": "mcp", "service": "sales-domain"},
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user