M0 + M1 done

This commit is contained in:
marcos.lohmann_at_orcl
2026-09-11 15:04:34 -03:00
parent 5ebec725ec
commit 439f60598e
51 changed files with 2991 additions and 0 deletions

0
RI/tests/__init__.py Normal file
View File

0
RI/tests/e2e/__init__.py Normal file
View File

273
RI/tests/e2e/conftest.py Normal file
View 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

View 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)

View 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"
)

View File

180
RI/tests/unit/test_authn.py Normal file
View 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")

View 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

View 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()

View 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"},
}
)