mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M0 + M1 done
This commit is contained in:
0
RI/src/emcp_bus/__init__.py
Normal file
0
RI/src/emcp_bus/__init__.py
Normal file
0
RI/src/emcp_bus/common/__init__.py
Normal file
0
RI/src/emcp_bus/common/__init__.py
Normal file
85
RI/src/emcp_bus/common/config.py
Normal file
85
RI/src/emcp_bus/common/config.py
Normal 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))]
|
||||
30
RI/src/emcp_bus/common/models.py
Normal file
30
RI/src/emcp_bus/common/models.py
Normal 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]
|
||||
68
RI/src/emcp_bus/common/otel.py
Normal file
68
RI/src/emcp_bus/common/otel.py
Normal 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)
|
||||
0
RI/src/emcp_bus/entitlement/__init__.py
Normal file
0
RI/src/emcp_bus/entitlement/__init__.py
Normal file
146
RI/src/emcp_bus/entitlement/manager.py
Normal file
146
RI/src/emcp_bus/entitlement/manager.py
Normal 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]
|
||||
83
RI/src/emcp_bus/entitlement/models.py
Normal file
83
RI/src/emcp_bus/entitlement/models.py
Normal 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
|
||||
0
RI/src/emcp_bus/gateway/__init__.py
Normal file
0
RI/src/emcp_bus/gateway/__init__.py
Normal file
260
RI/src/emcp_bus/gateway/server.py
Normal file
260
RI/src/emcp_bus/gateway/server.py
Normal 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()
|
||||
0
RI/src/emcp_bus/identity/__init__.py
Normal file
0
RI/src/emcp_bus/identity/__init__.py
Normal file
73
RI/src/emcp_bus/identity/authn.py
Normal file
73
RI/src/emcp_bus/identity/authn.py
Normal 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"]),
|
||||
)
|
||||
31
RI/src/emcp_bus/identity/models.py
Normal file
31
RI/src/emcp_bus/identity/models.py
Normal 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
|
||||
38
RI/src/emcp_bus/identity/token_verifier.py
Normal file
38
RI/src/emcp_bus/identity/token_verifier.py
Normal 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,
|
||||
)
|
||||
0
RI/src/emcp_bus/pdp/__init__.py
Normal file
0
RI/src/emcp_bus/pdp/__init__.py
Normal file
148
RI/src/emcp_bus/pdp/client.py
Normal file
148
RI/src/emcp_bus/pdp/client.py
Normal 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))
|
||||
28
RI/src/emcp_bus/pdp/models.py
Normal file
28
RI/src/emcp_bus/pdp/models.py
Normal 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)."""
|
||||
0
RI/src/emcp_bus/registry/__init__.py
Normal file
0
RI/src/emcp_bus/registry/__init__.py
Normal file
104
RI/src/emcp_bus/registry/models.py
Normal file
104
RI/src/emcp_bus/registry/models.py
Normal 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
|
||||
Reference in New Issue
Block a user