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

View File

View File

@@ -0,0 +1,85 @@
"""YAML -> Pydantic configuration loading with environment-variable interpolation.
Every declarative config type in the RI (client profiles, capability manifests,
policies, ...) is loaded through :func:`load_yaml_model`, so secrets never live
as literal values in a committed YAML file (see docs/RI-PLANNING.md, EP-00-T05):
a value written as ``${VAR_NAME}`` or ``${VAR_NAME:-default}`` is resolved from
the environment at load time, and a value that still looks like an unresolved
placeholder after interpolation is treated as a configuration error.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import TypeVar
import yaml
from pydantic import BaseModel, ValidationError
ModelT = TypeVar("ModelT", bound=BaseModel)
_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(:-(.*?))?\}")
class ConfigError(Exception):
"""Raised when a YAML config file fails to parse or to validate against its model."""
def _interpolate_env_vars(raw_text: str) -> str:
def _replace(match: re.Match[str]) -> str:
var_name, _, default = match.groups()
if var_name in os.environ:
return os.environ[var_name]
if default is not None:
return default
raise ConfigError(
f"Environment variable '{var_name}' referenced in config is not set "
"and no default was provided (use ${VAR:-default} for optional values)."
)
return _ENV_VAR_PATTERN.sub(_replace, raw_text)
def load_yaml_model[ModelT: BaseModel](path: str | Path, model: type[ModelT]) -> ModelT:
"""Load a single YAML document at ``path`` and validate it against ``model``.
Raises :class:`ConfigError` on a missing file, invalid YAML, or a document
that fails Pydantic validation -- callers should treat this as a fatal
startup error (fail closed), never as a recoverable condition.
"""
file_path = Path(path)
if not file_path.is_file():
raise ConfigError(f"Config file not found: {file_path}")
raw_text = file_path.read_text(encoding="utf-8")
interpolated = _interpolate_env_vars(raw_text)
try:
document = yaml.safe_load(interpolated)
except yaml.YAMLError as exc:
raise ConfigError(f"Invalid YAML in {file_path}: {exc}") from exc
if document is None:
document = {}
try:
return model.model_validate(document)
except ValidationError as exc:
raise ConfigError(f"Config validation failed for {file_path}:\n{exc}") from exc
def load_yaml_models[ModelT: BaseModel](
directory: str | Path, model: type[ModelT], pattern: str = "*.yaml"
) -> list[ModelT]:
"""Load and validate every file matching ``pattern`` under ``directory``.
Files are returned sorted by filename for deterministic ordering (config
load order must never be a source of non-determinism in the PEP/PDP path).
"""
dir_path = Path(directory)
if not dir_path.is_dir():
raise ConfigError(f"Config directory not found: {dir_path}")
return [load_yaml_model(file_path, model) for file_path in sorted(dir_path.glob(pattern))]

View File

@@ -0,0 +1,30 @@
"""Shared value types used across config models (README.md §17, §41)."""
from __future__ import annotations
from enum import StrEnum
class RiskTier(StrEnum):
"""Capability/client risk classification (README.md §17).
R0 discovery/public, R1 read, R2 write, R3 high-impact (approval-eligible),
R4 destructive/restricted. The *same* enum is used for both a capability
manifest's `risk_tier` (EP-02-T01) and a client profile's `risk_ceiling`
(EP-04-T01) so entitlement resolution (EP-04-T02) can compare them
directly instead of maintaining two parallel classifications.
"""
R0 = "R0"
R1 = "R1"
R2 = "R2"
R3 = "R3"
R4 = "R4"
_ORDER: dict[RiskTier, int] = {tier: index for index, tier in enumerate(RiskTier)}
def risk_tier_at_or_below(tier: RiskTier, ceiling: RiskTier) -> bool:
"""True if `tier` is within `ceiling` (e.g. R1 <= R2, R3 > R2)."""
return _ORDER[tier] <= _ORDER[ceiling]

View File

@@ -0,0 +1,68 @@
"""Minimal OpenTelemetry tracing setup shared by every RI service.
Walking-skeleton scope (EP-08-T01): a `TracerProvider` per process, exporting
to stdout by default so the M0 trace is visible without a running collector.
Setting `OTEL_EXPORTER_OTLP_ENDPOINT` switches to OTLP/HTTP (requires the
`emcp-bus[otlp]` extra) for use against the collector in EP-08-T04.
Trace context propagates across the gateway -> downstream MCP server hop via
the `mcp` SDK's built-in `OpenTelemetryMiddleware` (on by default on every
`Server`/`MCPServer`): it injects/extracts standard W3C `traceparent` into the
JSON-RPC `_meta` field around every request, so a single trace_id spans both
processes without any extra instrumentation here -- this is the concrete,
checkable form of the M0 acceptance criterion in docs/RI-PLANNING.md ("um
único trace_id conecta Gateway -> Fabric -> backend mock"), verified in
tests/e2e/test_walking_skeleton.py.
`setup_tracing` registers one *process-global* TracerProvider via
`opentelemetry.trace.set_tracer_provider`, which OTel only ever honors once
per process -- a second call from a different service name in the same
process is silently ignored by the OTel API itself. That is correct for the
real deployment (one service per process); tests that run two RI services
in-process (as tests/e2e/test_walking_skeleton.py does, for speed) must not
rely on `resource.attributes["service.name"]` to tell spans from different
services apart, since both end up sharing whichever provider registered
first -- use `span.kind` / `span.name` instead.
"""
from __future__ import annotations
import os
from opentelemetry import trace
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from opentelemetry.trace import Tracer
_configured_services: set[str] = set()
def setup_tracing(service_name: str) -> Tracer:
"""Configure (once per process) a TracerProvider for `service_name` and return its Tracer.
Idempotent: calling this more than once for the same service name in the
same process returns a Tracer against the already-configured provider
instead of registering a second one.
"""
if service_name not in _configured_services:
resource = Resource.create({SERVICE_NAME: service_name})
provider = TracerProvider(resource=resource)
otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
if otlp_endpoint:
# Imported lazily: the OTLP exporter is an optional dependency
# (`emcp-bus[otlp]`) not required for the console-only M0 path.
from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( # type: ignore[import-not-found]
OTLPSpanExporter,
)
exporter = OTLPSpanExporter(endpoint=f"{otlp_endpoint}/v1/traces")
else:
exporter = ConsoleSpanExporter()
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
_configured_services.add(service_name)
return trace.get_tracer(service_name)

View File

View File

@@ -0,0 +1,146 @@
"""Entitlement Manager (EP-04-T02/T03/T04, README.md §6.9).
Resolves `MaximumEntitlement(client)` by combining an authenticated
client_id (never a value the agent/prompt could influence -- EP-01-T03) with
its registered `ClientProfile` (EP-04-T01), and keeps that resolution
versioned, cacheable and revocable (RF-08, RF-12). This module is the
*only* authority for `MaximumEntitlement` in the RI: it does not rank
relevance, does not compute NBA, and does not trust anything the LLM
declared (README.md §6.9, Axiom 2).
"""
from __future__ import annotations
import hashlib
from pydantic import BaseModel
from emcp_bus.common.models import RiskTier
from emcp_bus.entitlement.models import ClientProfile
from emcp_bus.identity.models import ClientRegistration
from emcp_bus.pdp.client import PDPClient
class UnknownClientError(Exception):
"""`client_id` has no registration, or its registration points at an unknown
profile. Callers (the Gateway) must treat this identically to DENY --
never as "grant the default/broadest profile"."""
class ResolvedEntitlement(BaseModel):
client_id: str
profile_name: str
entitlement_version: str
"""Changes whenever the client profile OR the PDP policies change (EP-04-T04) --
a Gateway cache keyed partly on this value self-invalidates on either (EP-05-T03)."""
allowed_tools: frozenset[str]
risk_ceiling: RiskTier
provenance: str
"""Which rule produced this entitlement -- trivial in the RI (one client
profile, no additional dynamic rule composition yet), but the field
exists so a richer Entitlement Manager can report it without a contract
change (README.md §6.9, "manter provenance")."""
model_config = {"frozen": True}
class EntitlementManager:
def __init__(self, pdp_client: PDPClient) -> None:
self._pdp = pdp_client
self._registrations: dict[str, ClientRegistration] = {}
self._profiles: dict[str, ClientProfile] = {}
self._cache: dict[str, ResolvedEntitlement] = {}
def load_registrations(self, registrations: list[ClientRegistration]) -> None:
self._registrations = {r.client_id: r for r in registrations}
def load_profiles(self, profiles: list[ClientProfile]) -> None:
self._profiles = {p.name: p for p in profiles}
async def sync_profiles_to_pdp(self) -> None:
"""Push every loaded client profile into the PDP's data document.
Must run (and complete) before the Gateway starts accepting
`tools/list`/`tools/call` -- a profile the PDP has never seen
resolves every tool to DENY, which is the correct fail-closed
behavior (README.md §38), never a reason to skip the sync.
"""
for profile in self._profiles.values():
# `exclude_none`: Rego's `not X` only negates an *undefined* or
# literal-`false` X -- a pushed `null` is a defined value, so
# `not profile.restrictions.max_transaction_value` would NOT
# treat an explicit `null` as "no restriction" the way it treats
# a genuinely absent key. Dropping None fields keeps "unset"
# meaning "undefined in Rego" (see config/policies/transaction.rego).
await self._pdp.push_client_profile(
profile.name,
profile.model_dump(mode="json", exclude={"name"}, exclude_none=True),
)
def resolve(self, client_id: str) -> ResolvedEntitlement:
"""Resolve and cache `MaximumEntitlement(client_id)`.
Cache lookups are keyed on `client_id` only, but `entitlement_version`
inside the cached value is computed from the *current* profile
content and PDP policy_version every time -- so a stale cache entry
is always self-describing (a caller comparing versions can tell it's
stale) even though this method does not itself re-fetch policy state
from OPA on every call (see `revoke_client`/`revoke_profile` for the
actual invalidation triggers, EP-04-T03).
"""
registration = self._registrations.get(client_id)
if registration is None:
raise UnknownClientError(f"No ClientRegistration for client_id={client_id!r}")
profile = self._profiles.get(registration.profile)
if profile is None:
raise UnknownClientError(
f"ClientRegistration for {client_id!r} references unknown profile "
f"{registration.profile!r}"
)
resolved = ResolvedEntitlement(
client_id=client_id,
profile_name=profile.name,
entitlement_version=self._compute_version(profile),
allowed_tools=frozenset(profile.allowed_tools),
risk_ceiling=profile.risk_ceiling,
provenance=f"client_profile:{profile.name}",
)
self._cache[client_id] = resolved
return resolved
def cached(self, client_id: str) -> ResolvedEntitlement | None:
return self._cache.get(client_id)
def revoke_client(self, client_id: str) -> None:
"""Immediately invalidate one client's entitlement (RF-08).
The *next* `resolve()` for this client_id will raise
`UnknownClientError` until/unless it is re-registered -- there is no
grace period and no fallback to a cached value.
"""
self._registrations.pop(client_id, None)
self._cache.pop(client_id, None)
def revoke_profile(self, profile_name: str) -> None:
"""Invalidate the cache for every client currently on `profile_name`.
Used when a profile itself changes (not just one client) --
`policy_changed` in the audit taxonomy (README.md §27). Does not
remove the profile or its registrations; the next `resolve()` call
recomputes against whatever profile is loaded now.
"""
stale_client_ids = [
client_id
for client_id, resolved in self._cache.items()
if resolved.profile_name == profile_name
]
for client_id in stale_client_ids:
del self._cache[client_id]
def _compute_version(self, profile: ClientProfile) -> str:
digest = hashlib.sha256()
digest.update(self._pdp.policy_version.encode("utf-8"))
digest.update(profile.model_dump_json().encode("utf-8"))
return digest.hexdigest()[:12]

View File

@@ -0,0 +1,83 @@
"""Client profile schema (EP-04-T01, README.md §41).
A client profile is the governed object that defines a MCP Client's
`MaximumEntitlement` ceiling (§7, §17, ADR-008: granularity = domain + risk
tier). It is *not* itself the entitlement decision -- that is resolved by
the Entitlement Manager (EP-04-T02) combining this profile with the PDP's
entitlement policy (EP-03-T02). A profile only ever narrows what a client
may be granted; nothing here can widen it at runtime (Axiom 4).
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from emcp_bus.common.models import RiskTier
class TransactionRestrictions(BaseModel):
"""Argument/resource-level bounds enforced by the Transaction Policy (EP-03-T03).
These are *hints* carried alongside the profile for the PDP's Rego data
document -- the PDP, not this model, is the authority that actually
enforces them (README.md §6.5.B, §25 Passo 2).
"""
max_transaction_value: int | None = Field(default=None, ge=0)
allowed_regions: list[str] | None = None
approval_threshold: int | None = Field(
default=None,
ge=0,
description=(
"Above this `arguments.amount`, the transaction policy returns "
"REQUIRE_APPROVAL instead of ALLOW (README.md §26 example: "
"payment.execute < 10k ALLOW, >= 10k REQUIRE_APPROVAL)."
),
)
class CredentialPolicy(BaseModel):
workload_identity: bool = True
short_lived: bool = True
class AuditPolicy(BaseModel):
level: Literal["none", "standard", "full"] = "full"
class ClientProfile(BaseModel):
"""A governed client profile (README.md §41 example)."""
name: str = Field(description='e.g. "Sales.Read", "Finance.Payments" (README.md §7.1).')
owner: str
environment: Literal["dev", "ci", "production"] = "production"
risk_ceiling: RiskTier
allowed_tools: list[str] = Field(
min_length=1,
description="Canonical <domain>.<capability> tool names (README.md §39).",
)
restrictions: TransactionRestrictions = Field(default_factory=TransactionRestrictions)
credential_policy: CredentialPolicy = Field(default_factory=CredentialPolicy)
audit: AuditPolicy = Field(default_factory=AuditPolicy)
@field_validator("name")
@classmethod
def _name_is_domain_dot_qualifier(cls, value: str) -> str:
if "." not in value:
raise ValueError(
f"client profile name {value!r} must be '<Domain>.<Qualifier>' "
"(e.g. 'Sales.Read') per README.md §7.1/§39"
)
return value
@field_validator("allowed_tools")
@classmethod
def _tools_are_domain_dot_capability(cls, tools: list[str]) -> list[str]:
for tool in tools:
if "." not in tool:
raise ValueError(
f"tool name {tool!r} must be '<domain>.<capability>' per README.md §39"
)
return tools

View File

View File

@@ -0,0 +1,260 @@
"""MCP Gateway / PEP (EP-05-T01/T02/T04).
Enforces the two invariants of README.md §9/§10:
- `tools/list` returns the intersection of the downstream catalog and the
authenticated client's MaximumEntitlement (filtered discovery, RF-03).
- `tools/call` is re-authorized independently of whatever `tools/list`
returned -- the PDP is consulted on every call, never skipped because a
tool was (or wasn't) visible in a prior discovery response (RF-04, Axiom 5).
No identity, no discovery, no execution (README.md §5): a request with no
valid access token gets an empty tool list and every `tools/call` denied --
this is the correct behavior when EP-01-T01 (Keycloak or any OIDC IdP) is
not configured for a given deployment, not a bug to work around.
Fail-closed (README.md §38, EP-05-T06): a PDP that cannot be reached is a
DENY, not an ALLOW -- see the `PDPUnavailableError` branch in `on_call_tool`.
Configuration is read from the environment lazily, inside `lifespan()`/
`build_auth()`, rather than into module-level constants at import time:
every RI service is one process per deployment in production, so this is
purely about correctness (env vars can differ across a Starlette app's
lifespan runs within one Python process, which the test suite relies on to
exercise multiple configurations without re-importing this module).
Tracing is automatic (see `common/otel.py`): the SDK's `OpenTelemetryMiddleware`
correlates every hop without any manual span code here.
"""
from __future__ import annotations
import os
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
import mcp_types as types
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from pydantic import AnyHttpUrl
from emcp_bus.common.config import load_yaml_models
from emcp_bus.common.otel import setup_tracing
from emcp_bus.entitlement.manager import EntitlementManager, UnknownClientError
from emcp_bus.entitlement.models import ClientProfile
from emcp_bus.identity.authn import TokenValidator
from emcp_bus.identity.models import ClientRegistration
from emcp_bus.identity.token_verifier import MCPTokenVerifier
from emcp_bus.pdp.client import PDPClient, PDPUnavailableError, compute_policy_version
from emcp_bus.pdp.models import PDPOutcome
setup_tracing("emcp-gateway")
_REPO_ROOT = Path(__file__).resolve().parents[3]
@dataclass(frozen=True)
class GatewayConfig:
downstream_url: str
opa_url: str
config_dir: Path
oidc_jwks_url: str
oidc_issuer: str
oidc_audience: str
@classmethod
def from_env(cls) -> GatewayConfig:
return cls(
downstream_url=os.environ.get("SALES_DOMAIN_URL", "http://127.0.0.1:8100/mcp"),
opa_url=os.environ.get("OPA_URL", "http://127.0.0.1:8181"),
config_dir=Path(os.environ.get("EMCP_CONFIG_DIR", str(_REPO_ROOT / "config"))),
oidc_jwks_url=os.environ.get("OIDC_JWKS_URL", ""),
oidc_issuer=os.environ.get("OIDC_ISSUER", ""),
oidc_audience=os.environ.get("OIDC_AUDIENCE", "emcp-gateway"),
)
@property
def clients_dir(self) -> Path:
return self.config_dir / "clients"
@property
def profiles_dir(self) -> Path:
return self.clients_dir / "profiles"
@property
def policies_dir(self) -> Path:
return self.config_dir / "policies"
@dataclass
class GatewayState:
entitlement_manager: EntitlementManager
pdp: PDPClient
downstream_url: str
@asynccontextmanager
async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]:
config = GatewayConfig.from_env()
pdp = PDPClient(
base_url=config.opa_url, policy_version=compute_policy_version(config.policies_dir)
)
manager = EntitlementManager(pdp)
manager.load_registrations(load_yaml_models(config.clients_dir, ClientRegistration))
manager.load_profiles(load_yaml_models(config.profiles_dir, ClientProfile))
# Deliberately not try/excepted: a PDP the Gateway cannot sync entitlements
# to at startup must not come up looking healthy (README.md §38).
await manager.sync_profiles_to_pdp()
try:
yield GatewayState(
entitlement_manager=manager, pdp=pdp, downstream_url=config.downstream_url
)
finally:
await pdp.aclose()
async def _with_downstream_session[ResultT](
downstream_url: str,
coro_factory: Callable[[ClientSession], Awaitable[ResultT]],
) -> ResultT:
"""Open a fresh downstream connection for one request and run `coro_factory(session)`.
A connection per call is simpler and safer than a shared long-lived
session under concurrent requests; the Enterprise MCP Fabric (EP-06) is
where connection pooling/reuse per backend would be introduced later.
"""
async with (
streamable_http_client(downstream_url) as (read_stream, write_stream),
ClientSession(read_stream, write_stream) as session,
):
await session.initialize()
return await coro_factory(session)
def _denied(reason_code: str) -> types.CallToolResult:
"""A deterministic denial the calling agent can observe and react to
(README.md §10/§14: "Operação bloqueada") -- a structured tool-result,
not a raised protocol-level error, matching real MCP tool-failure shape.
"""
return types.CallToolResult(
is_error=True,
content=[types.TextContent(type="text", text=f"Access denied: {reason_code}")],
)
async def on_list_tools(
ctx: ServerRequestContext[GatewayState],
params: types.PaginatedRequestParams | None,
) -> types.ListToolsResult:
access_token = get_access_token()
if access_token is None:
return types.ListToolsResult(tools=[])
state = ctx.lifespan_context
try:
resolved = state.entitlement_manager.resolve(access_token.client_id)
except UnknownClientError:
return types.ListToolsResult(tools=[])
downstream_result = await _with_downstream_session(
state.downstream_url, lambda session: session.list_tools(params=params)
)
filtered_tools = [
tool for tool in downstream_result.tools if tool.name in resolved.allowed_tools
]
return downstream_result.model_copy(update={"tools": filtered_tools})
async def on_call_tool(
ctx: ServerRequestContext[GatewayState],
params: types.CallToolRequestParams,
) -> types.CallToolResult:
access_token = get_access_token()
if access_token is None:
return _denied("UNAUTHENTICATED")
state = ctx.lifespan_context
try:
resolved = state.entitlement_manager.resolve(access_token.client_id)
except UnknownClientError:
return _denied("UNKNOWN_CLIENT")
try:
decision = await state.pdp.evaluate(
client_profile=resolved.profile_name,
tool=params.name,
arguments=dict(params.arguments or {}),
)
except PDPUnavailableError:
return _denied("PDP_UNAVAILABLE")
if decision.outcome is PDPOutcome.DENY:
return _denied(decision.reason_code)
if decision.outcome is PDPOutcome.REQUIRE_APPROVAL:
# EP-14 (human approval workflow) does not exist yet in this milestone
# -- fail closed rather than silently upgrading to ALLOW.
return _denied(f"REQUIRE_APPROVAL:{decision.reason_code}")
result = await _with_downstream_session(
state.downstream_url, lambda session: session.call_tool(params.name, params.arguments or {})
)
if isinstance(result, types.CallToolResult):
return result
raise TypeError(f"Unexpected downstream result type for tools/call: {type(result)!r}")
server = Server(
"emcp-gateway",
version="0.1.0",
instructions="Enterprise MCP Service Bus Gateway / PEP.",
lifespan=lifespan,
on_list_tools=on_list_tools,
on_call_tool=on_call_tool,
)
def build_auth(
config: GatewayConfig | None = None,
) -> tuple[AuthSettings | None, TokenVerifier | None]:
"""Wires up OIDC bearer-token auth when EP-01-T01 (Keycloak or any OIDC
IdP) is configured; otherwise both are `None` and every request is
treated as unauthenticated (deny by default, see module docstring) -- a
deliberate, safe default, not a placeholder to silence.
"""
config = config or GatewayConfig.from_env()
if not (config.oidc_jwks_url and config.oidc_issuer):
return None, None
token_validator = TokenValidator(
jwks_url=config.oidc_jwks_url, issuer=config.oidc_issuer, audience=config.oidc_audience
)
token_verifier: TokenVerifier = MCPTokenVerifier(token_validator, resource=config.oidc_audience)
# `TokenValidator` already checks `aud` against `oidc_audience` itself
# (README.md §45 Teste 8), so the SDK's own RFC 8707 resource check is
# left off here rather than requiring `oidc_audience` to also be a URL.
auth_settings = AuthSettings(
issuer_url=AnyHttpUrl(config.oidc_issuer),
resource_server_url=AnyHttpUrl(f"https://emcp-bus.internal/{config.oidc_audience}"),
validate_token_resource=False,
)
return auth_settings, token_verifier
def main() -> None:
import uvicorn
host = os.environ.get("GATEWAY_HOST", "127.0.0.1")
port = int(os.environ.get("GATEWAY_PORT", "8000"))
auth_settings, token_verifier = build_auth()
app = server.streamable_http_app(host=host, auth=auth_settings, token_verifier=token_verifier)
uvicorn.run(app, host=host, port=port, log_level="info")
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,73 @@
"""OAuth2/OIDC access token validation (EP-01-T03, README.md §5, §19, §20).
Validates a bearer token against the Identity Provider's JWKS (Keycloak,
EP-01-T01, in production; any standards-compliant OIDC IdP works, since this
speaks the standard, not a Keycloak-specific protocol) -- signature,
expiry, issuer and audience. The verified `azp`/`client_id` claim is the
*only* source of client identity this module trusts; nothing about
`clientInfo` or any other agent-declared field is ever consulted here
(README.md §5, "Observação crítica sobre clientInfo", Axiom 2).
"""
from __future__ import annotations
import jwt
from jwt import PyJWKClient
from pydantic import BaseModel
class AuthenticationError(Exception):
"""Token missing/malformed/expired/wrong audience-issuer/bad signature.
Callers (the Gateway, EP-05-T02/T04) must treat this identically to
DENY -- fail closed (README.md §38), never fall back to an unauthenticated
or agent-declared identity.
"""
class AuthenticatedClient(BaseModel):
client_id: str
"""From the token's `azp` (OIDC 'authorized party') or `client_id` claim --
never from clientInfo or any other unauthenticated, agent-controlled field."""
issuer: str
expires_at: int
class TokenValidator:
def __init__(
self,
jwks_url: str,
issuer: str,
audience: str,
leeway_seconds: int = 5,
) -> None:
self._jwks_client = PyJWKClient(jwks_url)
self._issuer = issuer
self._audience = audience
self._leeway_seconds = leeway_seconds
def validate(self, token: str) -> AuthenticatedClient:
try:
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=self._audience,
issuer=self._issuer,
leeway=self._leeway_seconds,
options={"require": ["exp", "iss", "aud"]},
)
except jwt.PyJWTError as exc:
raise AuthenticationError(str(exc)) from exc
client_id = claims.get("azp") or claims.get("client_id")
if not isinstance(client_id, str) or not client_id:
raise AuthenticationError("Token has neither a usable 'azp' nor 'client_id' claim")
return AuthenticatedClient(
client_id=client_id,
issuer=str(claims["iss"]),
expires_at=int(claims["exp"]),
)

View File

@@ -0,0 +1,31 @@
"""MCP Client registration schema (EP-01-T02, README.md §6.3, §19).
Maps an authenticated OAuth2 client identity (the `client_id`/`azp` claim a
validated access token carries -- see EP-01-T03) to exactly one client
profile (EP-04). This indirection is what lets `docs/RI-PLANNING.md`'s §18
rule ("share a client only if all sharing agents can legitimately carry the
same ceiling") be expressed as data instead of code: two client_ids pointing
at the same profile are an explicit, auditable sharing decision.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
class ClientRegistration(BaseModel):
client_id: str
"""OAuth2 client_id as issued by the Identity Provider (Keycloak, EP-01-T01).
This is the identity the Gateway trusts (from a verified token's `azp`
claim, EP-01-T03) -- never a value read from the MCP request body or
from `clientInfo` (README.md §5, "Observação crítica sobre clientInfo").
"""
profile: str
"""Name of the ClientProfile (EP-04) this client_id resolves to."""
environment: Literal["dev", "ci", "production"] = "production"
description: str | None = None

View File

@@ -0,0 +1,38 @@
"""Adapts `TokenValidator` (EP-01-T03) to the `mcp` SDK's own `TokenVerifier`
protocol, so the Gateway can use the SDK's built-in bearer-auth middleware
(`AuthenticationMiddleware` + `AuthContextMiddleware`) instead of parsing the
`Authorization` header by hand.
"""
from __future__ import annotations
import asyncio
from mcp.server.auth.provider import AccessToken
from emcp_bus.identity.authn import AuthenticationError, TokenValidator
class MCPTokenVerifier:
def __init__(self, token_validator: TokenValidator, resource: str) -> None:
self._token_validator = token_validator
self._resource = resource
async def verify_token(self, token: str) -> AccessToken | None:
"""Returns `None` on any validation failure -- the SDK's bearer-auth
middleware treats that as "no authenticated user", never as an
exception to swallow-and-allow. `TokenValidator.validate` does a
blocking JWKS fetch/signature check (PyJWT), so it runs off the
event loop thread."""
try:
authenticated = await asyncio.to_thread(self._token_validator.validate, token)
except AuthenticationError:
return None
return AccessToken(
token=token,
client_id=authenticated.client_id,
scopes=[],
expires_at=authenticated.expires_at,
resource=self._resource,
)

View File

View File

@@ -0,0 +1,148 @@
"""PDP client (EP-03-T01/T04): wraps OPA's REST API behind the two-step
entitlement + transaction model (README.md §25).
Fail-closed by construction (README.md §38, RNF-Disponibilidade): every
method that talks to OPA either returns a `PDPDecision` or raises
`PDPUnavailableError` -- there is no code path that turns "OPA didn't
answer" into an implicit ALLOW. Callers (the Gateway, EP-05-T06) are
expected to treat `PDPUnavailableError` as DENY.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
import httpx
from emcp_bus.pdp.models import PDPDecision, PDPOutcome
class PDPUnavailableError(Exception):
"""OPA could not be reached or returned an unexpected response.
Deliberately a distinct type from a `PDPDecision(outcome=DENY)`: an
unreachable PDP is an *availability* incident (README.md §38), not a
policy decision, and the two must never be logged/audited identically.
"""
def compute_policy_version(policy_dir: str | Path) -> str:
"""Deterministic version id for the Rego policies currently on disk.
A SHA-256 over the sorted `*.rego` (non-test) file contents under
`policy_dir`. This is a pragmatic stand-in for real OPA bundle revision
tracking (which needs a bundle server) -- sufficient for the RI to prove
`policy_version` flows end-to-end into every PDP decision and audit
event (EP-08-T02/T03); replacing it with bundle revisions is a natural
M2+ extension, not a contract change.
"""
digest = hashlib.sha256()
policy_files = sorted(
p for p in Path(policy_dir).glob("*.rego") if not p.name.endswith("_test.rego")
)
for path in policy_files:
digest.update(path.name.encode("utf-8"))
digest.update(path.read_bytes())
return digest.hexdigest()[:12]
class PDPClient:
def __init__(self, base_url: str, policy_version: str, timeout_seconds: float = 2.0) -> None:
self._http = httpx.AsyncClient(base_url=base_url, timeout=timeout_seconds)
self._policy_version = policy_version
@property
def policy_version(self) -> str:
return self._policy_version
async def aclose(self) -> None:
await self._http.aclose()
async def push_client_profile(self, profile_name: str, profile_data: dict[str, Any]) -> None:
"""Push a validated ClientProfile (EP-04-T01) into OPA's data document.
Called by the Entitlement Manager (EP-04-T02) at startup/reload --
this client never reads client profile YAML itself, only the
already-validated dict a caller hands it.
"""
try:
response = await self._http.put(
f"/v1/data/emcp/client_profiles/{profile_name}", json=profile_data
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise PDPUnavailableError(
f"Failed to push client profile {profile_name!r}: {exc}"
) from exc
async def evaluate(
self,
*,
client_profile: str,
tool: str,
arguments: dict[str, Any] | None = None,
customer_classification: str | None = None,
) -> PDPDecision:
"""Run the two-step model (README.md §25) and return one combined decision.
Step 1 (entitlement.rego) is authoritative and short-circuits: a
tool absent from entitlement is DENY before any argument is ever
evaluated (Axiom 5 -- discovery/execution filtering, not argument
policy, is the first line of defense).
"""
entitlement_input = {"client_profile": client_profile, "tool": tool}
entitled = await self._query_bool("/v1/data/emcp/entitlement/allow", entitlement_input)
if not entitled:
return PDPDecision(
outcome=PDPOutcome.DENY,
policy_version=self._policy_version,
reason_code="NOT_IN_ENTITLEMENT",
)
transaction_input = {
"client_profile": client_profile,
"tool": tool,
"arguments": arguments or {},
"customer_classification": customer_classification,
}
transaction_result = await self._query("/v1/data/emcp/transaction", transaction_input)
if not transaction_result.get("allow", False):
return PDPDecision(
outcome=PDPOutcome.DENY,
policy_version=self._policy_version,
reason_code="TRANSACTION_POLICY_DENIED",
)
if transaction_result.get("require_approval", False):
return PDPDecision(
outcome=PDPOutcome.REQUIRE_APPROVAL,
policy_version=self._policy_version,
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
)
return PDPDecision(
outcome=PDPOutcome.ALLOW,
policy_version=self._policy_version,
reason_code="ALLOWED",
)
async def _query(self, path: str, input_document: dict[str, Any]) -> dict[str, Any]:
try:
response = await self._http.post(path, json={"input": input_document})
response.raise_for_status()
body: dict[str, Any] = response.json()
except httpx.HTTPError as exc:
raise PDPUnavailableError(f"PDP query to {path} failed: {exc}") from exc
result = body.get("result")
if not isinstance(result, dict):
raise PDPUnavailableError(f"PDP query to {path} returned an unexpected shape: {body!r}")
return result
async def _query_bool(self, path: str, input_document: dict[str, Any]) -> bool:
try:
response = await self._http.post(path, json={"input": input_document})
response.raise_for_status()
body: dict[str, Any] = response.json()
except httpx.HTTPError as exc:
raise PDPUnavailableError(f"PDP query to {path} failed: {exc}") from exc
return bool(body.get("result", False))

View File

@@ -0,0 +1,28 @@
"""PDP decision contract (EP-03-T04).
Every response from the PDP is one of these three outcomes, never a bare
boolean -- `policy_version` and `reason_code` are mandatory so the Gateway's
audit trail (EP-08-T02) and end-to-end correlation (EP-08-T03, RF-17) always
have something to point at, and so a REQUIRE_APPROVAL is never collapsed
into ALLOW/DENY by a caller that only checks truthiness.
"""
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel
class PDPOutcome(StrEnum):
ALLOW = "ALLOW"
DENY = "DENY"
REQUIRE_APPROVAL = "REQUIRE_APPROVAL"
class PDPDecision(BaseModel):
outcome: PDPOutcome
policy_version: str
reason_code: str
"""Machine-readable reason, e.g. NOT_IN_ENTITLEMENT, TRANSACTION_POLICY_DENIED,
APPROVAL_THRESHOLD_EXCEEDED, ALLOWED, PDP_UNAVAILABLE (README.md §27)."""

View File

View File

@@ -0,0 +1,104 @@
"""Capability manifest schema (EP-02-T01, README.md §6.8, §23, §39, §40).
Reconciles the two illustrative shapes in README.md: the publishing-pipeline
manifest example (§6.8, camelCase, `riskTier: medium`) and the Tool Registry
metadata example (§23, snake_case, `risk_tier: R2`). This RI standardizes on
the §23 field names and the `RiskTier` R0-R4 enum, because that is the same
enum a `ClientProfile.risk_ceiling` uses (EP-04-T01) -- one risk vocabulary,
not two, is what lets entitlement resolution compare a tool's risk against a
client's ceiling directly (see `emcp_bus.common.models.risk_tier_at_or_below`).
"""
from __future__ import annotations
from datetime import date
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, Field, ValidationInfo, field_validator
from emcp_bus.common.models import RiskTier
class DataClassification(StrEnum):
PUBLIC = "public"
INTERNAL = "internal"
CONFIDENTIAL = "confidential"
RESTRICTED = "restricted"
class LifecycleStatus(StrEnum):
"""Registry governance lifecycle (README.md §23)."""
NOMINATE = "nominate"
REVIEW = "review"
REGISTER = "register"
ACTIVE = "active"
DEPRECATED = "deprecated"
RETIRED = "retired"
class BackendRef(BaseModel):
"""Where the Fabric (EP-06-T01) routes a capability once authorized."""
type: Literal["rest", "mcp", "grpc"] = "mcp"
service: str = Field(
description="Adapter/service id the Fabric router resolves, e.g. 'sales-domain'."
)
operation: str | None = Field(
default=None, description="Informational, e.g. 'GET /v2/customers/{id}' (README.md §6.8)."
)
class ApprovalPolicy(BaseModel):
required: bool = False
class ObservabilityPolicy(BaseModel):
audit_level: Literal["none", "standard", "full"] = "full"
class LifecycleInfo(BaseModel):
status: LifecycleStatus = LifecycleStatus.NOMINATE
sunset_after: date | None = None
class CapabilityManifest(BaseModel):
name: str = Field(description="Canonical '<domain>.<capability>' name (README.md §39).")
version: str = Field(description="SemVer, e.g. '1.2.0' (README.md §40).")
domain: str
owner: str
risk_tier: RiskTier
side_effects: bool
idempotent: bool
data_classification: list[DataClassification] = Field(
default_factory=lambda: [DataClassification.INTERNAL]
)
entitlements: list[str] = Field(
min_length=1,
description="ClientProfile names required to see/call this capability (EP-04).",
)
backend: BackendRef
approval: ApprovalPolicy = Field(default_factory=ApprovalPolicy)
observability: ObservabilityPolicy = Field(default_factory=ObservabilityPolicy)
lifecycle: LifecycleInfo = Field(default_factory=LifecycleInfo)
@field_validator("name")
@classmethod
def _name_is_canonical(cls, value: str) -> str:
if "." not in value:
raise ValueError(
f"capability name {value!r} must be '<domain>.<capability>' (README.md §39)"
)
return value
@field_validator("domain")
@classmethod
def _domain_matches_name_prefix(cls, value: str, info: ValidationInfo) -> str:
name = info.data.get("name")
if isinstance(name, str) and not name.startswith(f"{value}."):
raise ValueError(
f"domain {value!r} does not match the prefix of name {name!r} "
"(README.md §39: name must be '<domain>.<capability>')"
)
return value