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

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