Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:36:21 +02:00
commit cb5be898ea
663 files changed
+82674

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Control-plane application services."""
+28
View File
@@ -0,0 +1,28 @@
"""OIDC identity, browser BFF, and machine-principal boundaries."""
from .bff import BFFService
from .config import OIDCSettings
from .machine import (
MachineClientBinding,
MachinePrincipalAuthenticator,
PrincipalMapper,
RevocationCache,
)
from .models import AuthenticatedPrincipal, ManagementRole, PrincipalKind, SessionMetadata
from .provider import OIDCProviderClient
from .router import build_auth_router
__all__ = [
"AuthenticatedPrincipal",
"BFFService",
"MachineClientBinding",
"MachinePrincipalAuthenticator",
"PrincipalMapper",
"ManagementRole",
"OIDCProviderClient",
"OIDCSettings",
"PrincipalKind",
"RevocationCache",
"build_auth_router",
"SessionMetadata",
]
+255
View File
@@ -0,0 +1,255 @@
"""Same-origin browser BFF with server-side opaque sessions."""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import threading
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from http.cookies import SimpleCookie
from .config import OIDCSettings
from .errors import CSRFError, InvalidState, SessionExpired
from .models import (
AuthenticatedPrincipal,
PrincipalKind,
SessionCookie,
SessionMetadata,
TokenSet,
ValidatedClaims,
)
from .provider import OIDCProviderClient
def _hash(value: str, key: bytes | None = None) -> str:
payload = value.encode("ascii")
return (
hmac.new(key, payload, hashlib.sha256).hexdigest()
if key
else hashlib.sha256(payload).hexdigest()
)
def _pkce_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
@dataclass(frozen=True)
class _PendingLogin:
nonce: str
code_verifier: str
expires_at: datetime
@dataclass(frozen=True)
class _SessionRecord:
principal: AuthenticatedPrincipal
access_token: str
refresh_token: str | None
csrf_token: str
expires_at: datetime
class SessionStore:
"""Thread-safe in-memory session/CSRF store -- this IS the production
implementation, not a placeholder. It is a deliberate, documented,
single-instance-only design (docs/02-security/SESSION_STORE_DECISION.md):
control-api must run as exactly one process/replica, every session and
every CSRF token is lost on restart (users simply re-authenticate), and
horizontal scaling requires a real shared store first. The Compose
single-instance constraint this depends on is enforced statically by
scripts/compose_release_check.py, not just documented."""
def __init__(
self,
*,
ttl_seconds: int,
cookie_name: str,
same_site: str,
secure: bool,
hash_key: bytes | None = None,
) -> None:
self.ttl_seconds = ttl_seconds
self.cookie_name = cookie_name
self.same_site = same_site
self.secure = secure
self._hash_key = hash_key
self._records: dict[str, _SessionRecord] = {}
self._lock = threading.RLock()
def create(
self,
principal: AuthenticatedPrincipal,
token_set: TokenSet,
*,
now: datetime | None = None,
) -> SessionCookie:
reference = now or datetime.now(UTC)
expires_at = min(
principal.expires_at,
reference + timedelta(seconds=self.ttl_seconds),
)
if expires_at <= reference:
raise SessionExpired()
session_value = secrets.token_urlsafe(32)
csrf_token = secrets.token_urlsafe(24)
record = _SessionRecord(
principal=principal,
access_token=token_set.access_token,
refresh_token=token_set.refresh_token,
csrf_token=csrf_token,
expires_at=expires_at,
)
with self._lock:
self._records[_hash(session_value, self._hash_key)] = record
metadata = SessionMetadata(
subject=principal.subject,
client_id=principal.client_id,
kind=principal.kind,
roles=principal.roles,
expires_at=expires_at,
)
return SessionCookie(
value=session_value,
csrf_token=csrf_token,
metadata=metadata,
set_cookie=self._cookie(session_value, expires_at),
)
def get(self, session_value: str | None, *, now: datetime | None = None) -> _SessionRecord:
if not session_value or len(session_value) > 256:
raise SessionExpired()
reference = now or datetime.now(UTC)
key = _hash(session_value, self._hash_key)
with self._lock:
record = self._records.get(key)
if record is None:
raise SessionExpired()
if record.expires_at <= reference:
self._records.pop(key, None)
raise SessionExpired()
return record
def logout(self, session_value: str | None) -> None:
if session_value:
with self._lock:
self._records.pop(_hash(session_value, self._hash_key), None)
def _cookie(self, value: str, expires_at: datetime) -> str:
cookie = SimpleCookie()
cookie[self.cookie_name] = value
morsel = cookie[self.cookie_name]
morsel["httponly"] = True
morsel["secure"] = self.secure
morsel["samesite"] = self.same_site
morsel["path"] = "/"
morsel["expires"] = expires_at.astimezone(UTC).strftime("%a, %d %b %Y %H:%M:%S GMT")
return morsel.OutputString()
def clear_cookie(self) -> str:
cookie = SimpleCookie()
cookie[self.cookie_name] = ""
morsel = cookie[self.cookie_name]
morsel["httponly"] = True
morsel["secure"] = self.secure
morsel["samesite"] = self.same_site
morsel["path"] = "/"
morsel["max-age"] = 0
return morsel.OutputString()
class BFFService:
"""Coordinates PKCE login, opaque sessions, CSRF, logout, and safe metadata."""
def __init__(
self,
settings: OIDCSettings,
provider: OIDCProviderClient,
*,
principal_mapper: Callable[[ValidatedClaims], AuthenticatedPrincipal],
) -> None:
self.settings = settings
self.provider = provider
self.principal_mapper = principal_mapper
self.sessions = SessionStore(
ttl_seconds=settings.session_ttl_seconds,
cookie_name=settings.cookie_name,
same_site=settings.same_site,
secure=settings.secure_cookie,
hash_key=settings.session_signing_key(),
)
self._pending: dict[str, _PendingLogin] = {}
self._lock = threading.RLock()
def begin_login(self, *, now: datetime | None = None) -> str:
reference = now or datetime.now(UTC)
state = secrets.token_urlsafe(32)
pending = _PendingLogin(
nonce=secrets.token_urlsafe(24),
code_verifier=secrets.token_urlsafe(48),
expires_at=reference + timedelta(minutes=10),
)
with self._lock:
self._pending[_hash(state)] = pending
return self.provider.build_authorization_url(
state=state,
nonce=pending.nonce,
code_challenge=_pkce_challenge(pending.code_verifier),
)
def complete_login(
self,
*,
code: str,
state: str,
now: datetime | None = None,
) -> SessionCookie:
reference = now or datetime.now(UTC)
with self._lock:
pending = self._pending.pop(_hash(state), None)
if pending is None or pending.expires_at <= reference:
raise InvalidState()
token_set = self.provider.exchange_code(code=code, code_verifier=pending.code_verifier)
raw_token = token_set.id_token or token_set.access_token
claims = self.provider.validate_token(raw_token, now=reference)
if token_set.id_token:
if claims.nonce != pending.nonce:
raise InvalidState()
principal = self.principal_mapper(claims)
if principal.kind is not PrincipalKind.MANAGEMENT_USER:
raise InvalidState()
return self.sessions.create(principal, token_set, now=reference)
def session_metadata(
self, session_value: str | None, *, now: datetime | None = None
) -> SessionMetadata:
record = self.sessions.get(session_value, now=now)
return SessionMetadata(
subject=record.principal.subject,
client_id=record.principal.client_id,
kind=record.principal.kind,
roles=record.principal.roles,
expires_at=record.expires_at,
)
def require_session(
self, session_value: str | None, *, now: datetime | None = None
) -> AuthenticatedPrincipal:
return self.sessions.get(session_value, now=now).principal
def verify_csrf(self, session_value: str | None, csrf_token: str | None) -> None:
"""Require the session-bound token for every unsafe browser request."""
record = self.sessions.get(session_value)
if not csrf_token or not secrets.compare_digest(record.csrf_token, csrf_token):
raise CSRFError()
def logout(self, session_value: str | None, csrf_token: str | None) -> str:
self.verify_csrf(session_value, csrf_token)
self.sessions.logout(session_value)
return self.sessions.clear_cookie()
+160
View File
@@ -0,0 +1,160 @@
"""Environment-only OIDC configuration."""
from __future__ import annotations
import json
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Literal, cast
from pydantic import (
AnyHttpUrl,
BaseModel,
ConfigDict,
Field,
TypeAdapter,
field_validator,
model_validator,
)
from packages.hub_runtime import SecretFileError, read_secret_file
from .errors import ConfigurationError
from .models import ManagementRole
class OIDCSettings(BaseModel):
"""Validated configuration; secret values are never persisted in this model."""
model_config = ConfigDict(extra="forbid", frozen=True)
issuer: str = Field(min_length=1, max_length=2048)
audience: str = Field(min_length=1, max_length=256)
client_id: str = Field(min_length=1, max_length=256)
redirect_uri: AnyHttpUrl
client_secret_env: str | None = Field(default=None, min_length=1, max_length=256)
client_secret_file: Path | None = None
session_signing_key_file: Path | None = None
session_ttl_seconds: int = Field(default=28800, ge=60, le=28800)
clock_skew_seconds: int = Field(default=60, ge=0, le=120)
revocation_ttl_seconds: int = Field(default=60, ge=1, le=60)
cookie_name: str = Field(default="hub_session", pattern=r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
same_site: Literal["strict", "lax"] = "strict"
secure_cookie: bool = True
allowed_algorithms: tuple[str, ...] = ("RS256",)
role_group_map: dict[str, ManagementRole] = Field(default_factory=dict)
@field_validator("issuer")
@classmethod
def preserve_exact_https_issuer(cls, value: str) -> str:
parsed = TypeAdapter(AnyHttpUrl).validate_python(value)
if parsed.scheme != "https" or parsed.username is not None or parsed.password is not None:
raise ValueError("OIDC issuer must be an HTTPS URL without userinfo")
return value
@field_validator("secure_cookie")
@classmethod
def require_secure_cookie(cls, value: bool) -> bool:
if not value:
raise ValueError("session cookies must be Secure")
return value
@field_validator("allowed_algorithms")
@classmethod
def validate_algorithms(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if not value or any(
algorithm in {"none", "HS256", "HS384", "HS512"} for algorithm in value
):
raise ValueError("only asymmetric JWT algorithms are allowed")
return value
@model_validator(mode="after")
def one_client_secret_source(self) -> OIDCSettings:
if self.client_secret_env and self.client_secret_file:
raise ValueError("configure only one OIDC client secret source")
return self
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> OIDCSettings:
values = environ if environ is not None else os.environ
required = {
"issuer": values.get("HUB_OIDC_ISSUER"),
"audience": values.get("HUB_OIDC_AUDIENCE"),
"client_id": values.get("HUB_OIDC_CLIENT_ID"),
"redirect_uri": values.get("HUB_OIDC_REDIRECT_URI"),
}
missing = [key for key, value in required.items() if not value]
if missing:
raise ConfigurationError("missing required OIDC configuration")
role_map: dict[str, ManagementRole] = {}
raw_role_map = values.get("HUB_OIDC_ROLE_GROUP_MAP")
if raw_role_map:
try:
decoded = json.loads(raw_role_map)
if not isinstance(decoded, dict):
raise ValueError
role_map = {
str(group): ManagementRole(str(role)) for group, role in decoded.items()
}
except (TypeError, ValueError, json.JSONDecodeError) as exc:
raise ConfigurationError("invalid OIDC role mapping") from exc
try:
return cls(
issuer=cast(str, required["issuer"]),
audience=cast(str, required["audience"]),
client_id=cast(str, required["client_id"]),
redirect_uri=cast(AnyHttpUrl, required["redirect_uri"]),
client_secret_env=values.get("HUB_OIDC_CLIENT_SECRET_ENV"),
client_secret_file=(
Path(values["HUB_OIDC_CLIENT_SECRET_FILE"])
if values.get("HUB_OIDC_CLIENT_SECRET_FILE")
else None
),
session_signing_key_file=(
Path(values["HUB_SESSION_SIGNING_KEY_FILE"])
if values.get("HUB_SESSION_SIGNING_KEY_FILE")
else None
),
session_ttl_seconds=int(values.get("HUB_SESSION_TTL_SECONDS", "28800")),
clock_skew_seconds=int(values.get("HUB_OIDC_CLOCK_SKEW_SECONDS", "60")),
revocation_ttl_seconds=int(values.get("HUB_REVOCATION_TTL_SECONDS", "60")),
cookie_name=values.get("HUB_SESSION_COOKIE_NAME", "hub_session"),
same_site=cast(
Literal["strict", "lax"], values.get("HUB_SESSION_SAME_SITE", "strict")
),
secure_cookie=values.get("HUB_SESSION_SECURE", "true").lower() == "true",
allowed_algorithms=tuple(
algorithm.strip()
for algorithm in values.get("HUB_OIDC_ALLOWED_ALGORITHMS", "RS256").split(",")
if algorithm.strip()
),
role_group_map=role_map,
)
except (TypeError, ValueError) as exc:
raise ConfigurationError("invalid OIDC configuration") from exc
def client_secret(self, environ: Mapping[str, str] | None = None) -> str | None:
"""Read a secret only at the outbound token-exchange boundary."""
if self.client_secret_file:
try:
return read_secret_file(self.client_secret_file)
except SecretFileError as exc:
raise ConfigurationError("configured OIDC client secret is unavailable") from exc
if not self.client_secret_env:
return None
value = (environ or os.environ).get(self.client_secret_env)
if not value:
raise ConfigurationError("configured OIDC client secret is unavailable")
return value
def session_signing_key(self) -> bytes | None:
"""Read the server-side session key only while constructing the session store."""
if not self.session_signing_key_file:
return None
try:
return read_secret_file(self.session_signing_key_file).encode("utf-8")
except SecretFileError as exc:
raise ConfigurationError("configured session key is unavailable") from exc
+58
View File
@@ -0,0 +1,58 @@
"""Safe, stable authentication errors."""
from __future__ import annotations
class AuthError(Exception):
"""An error whose public code/message is safe to expose to a caller."""
code = "authentication_failed"
public_message = "Authentication failed."
def __init__(self, message: str | None = None) -> None:
super().__init__(message or self.public_message)
class ConfigurationError(AuthError):
code = "authentication_configuration_error"
public_message = "Authentication is not configured."
class ProviderUnavailable(AuthError):
code = "identity_provider_unavailable"
public_message = "The identity provider is temporarily unavailable."
class InvalidToken(AuthError):
code = "invalid_token"
public_message = "The supplied credential is invalid."
class TokenExchangeError(AuthError):
code = "token_exchange_failed"
public_message = "Authentication could not be completed."
class InvalidState(AuthError):
code = "invalid_auth_state"
public_message = "Authentication could not be completed."
class CSRFError(AuthError):
code = "csrf_failed"
public_message = "The request could not be verified."
class SessionExpired(AuthError):
code = "session_expired"
public_message = "The session has expired."
class UnknownMachinePrincipal(AuthError):
code = "unknown_machine_principal"
public_message = "The machine client is not registered."
class RevokedMachinePrincipal(AuthError):
code = "revoked_machine_principal"
public_message = "The machine client is disabled."
+214
View File
@@ -0,0 +1,214 @@
"""Deterministic offline OIDC provider used by integration and E2E tests."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, cast
from urllib.parse import parse_qs, urlparse
from uuid import uuid4
import httpx
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
@dataclass
class _FakeKey:
kid: str
private_key: Any
class FakeOIDCProvider:
"""A local MockTransport provider; it never uses real credentials or network I/O."""
def __init__(
self,
*,
issuer: str = "https://fake.oidc.test",
audience: str = "itworx-mcp-hub",
user_subject: str = "user-management-1",
user_groups: tuple[str, ...] = ("hub-viewer",),
persona_groups: dict[str, tuple[str, ...]] | None = None,
token_ttl_seconds: int = 300,
) -> None:
self.issuer = issuer
self._base_url = issuer.rstrip("/")
self.audience = audience
self.user_subject = user_subject
self.user_groups = user_groups
self._token_ttl_seconds = token_ttl_seconds
# Test-only: lets a real browser flow (GET /authorize, unlike
# authorize() which is called directly from Python) select which
# group claim to issue via a "persona" query param, so E2E tests can
# exercise multiple roles against one running fake-provider process
# without touching the real BFF login route to add a hint parameter.
self._persona_groups = persona_groups or {}
self._key = self._new_key("fake-key-1")
self._codes: dict[str, tuple[str, str, tuple[str, ...]]] = {}
@staticmethod
def _new_key(kid: str) -> _FakeKey:
return _FakeKey(
kid=kid,
private_key=rsa.generate_private_key(public_exponent=65537, key_size=2048),
)
def rotate_keys(self, kid: str = "fake-key-rotated") -> None:
self._key = self._new_key(kid)
def transport(self) -> httpx.MockTransport:
return httpx.MockTransport(self._handle)
def asgi_app(self) -> Any:
"""A real ASGI app serving the same endpoints as transport(), for
E2E harnesses where an actual browser must navigate to /authorize
(a MockTransport only intercepts server-to-server httpx calls)."""
from starlette.applications import Starlette
from starlette.requests import Request as StarletteRequest
from starlette.responses import Response as StarletteResponse
from starlette.routing import Route
async def endpoint(request: StarletteRequest) -> StarletteResponse:
body = await request.body()
httpx_request = httpx.Request(
request.method,
str(request.url),
headers=list(request.headers.items()),
content=body,
)
httpx_response = self._handle(httpx_request)
headers = {
key: value
for key, value in httpx_response.headers.items()
if key.lower() not in {"content-length", "transfer-encoding"}
}
return StarletteResponse(
content=httpx_response.content,
status_code=httpx_response.status_code,
headers=headers,
)
return Starlette(
routes=[Route("/{path:path}", endpoint, methods=["GET", "POST"])],
)
def _issue_code(self, challenge: str, nonce: str, groups: tuple[str, ...]) -> str:
code = f"fake-code-{uuid4().hex}"
self._codes[code] = (challenge, nonce, groups)
return code
def authorize(self, authorization_url: str, *, now: datetime | None = None) -> str:
query = parse_qs(urlparse(authorization_url).query)
state = query.get("state", [""])[0]
challenge = query.get("code_challenge", [""])[0]
if not state or not challenge:
raise ValueError("invalid fake authorization request")
return self._issue_code(challenge, query.get("nonce", [""])[0], self.user_groups)
def issue_machine_token(
self,
subject: str,
*,
now: datetime | None = None,
issuer: str | None = None,
audience: str | None = None,
) -> str:
reference = now or datetime.now(UTC)
return self._encode(
{
"sub": subject,
"client_id": subject,
"iss": issuer or self.issuer,
"aud": audience or self.audience,
"iat": int(reference.timestamp()),
"exp": int((reference + timedelta(minutes=5)).timestamp()),
}
)
def _encode(self, claims: dict[str, Any]) -> str:
return jwt.encode(
claims, self._key.private_key, algorithm="RS256", headers={"kid": self._key.kid}
)
def _public_jwk(self) -> dict[str, Any]:
raw = cast(
dict[str, Any],
json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(self._key.private_key.public_key())),
)
return raw | {
"kid": self._key.kid,
"use": "sig",
"alg": "RS256",
}
def _handle(self, request: httpx.Request) -> httpx.Response:
path = urlparse(str(request.url)).path
base_path = urlparse(self._base_url).path.rstrip("/")
if request.method == "GET" and path == f"{base_path}/.well-known/openid-configuration":
return httpx.Response(
200,
json={
"issuer": self.issuer,
"authorization_endpoint": f"{self._base_url}/authorize",
"token_endpoint": f"{self._base_url}/token",
"jwks_uri": f"{self._base_url}/jwks",
"id_token_signing_alg_values_supported": ["RS256"],
},
request=request,
)
if request.method == "GET" and path == f"{base_path}/jwks":
return httpx.Response(200, json={"keys": [self._public_jwk()]}, request=request)
if request.method == "GET" and path == f"{base_path}/authorize":
query = parse_qs(urlparse(str(request.url)).query)
redirect_uri = query.get("redirect_uri", [""])[0]
state = query.get("state", [""])[0]
challenge = query.get("code_challenge", [""])[0]
nonce = query.get("nonce", [""])[0]
persona = query.get("persona", [""])[0]
if not redirect_uri or not state or not challenge:
return httpx.Response(
400, text="invalid fake authorization request", request=request
)
groups = self._persona_groups.get(persona, self.user_groups)
code = self._issue_code(challenge, nonce, groups)
separator = "&" if "?" in redirect_uri else "?"
location = f"{redirect_uri}{separator}code={code}&state={state}"
return httpx.Response(302, headers={"location": location}, request=request)
if request.method == "POST" and path == f"{base_path}/token":
form = parse_qs(request.content.decode("utf-8"))
code = form.get("code", [""])[0]
verifier = form.get("code_verifier", [""])[0]
expected = self._codes.pop(code, None)
if expected is None:
return httpx.Response(400, json={"error": "invalid_grant"}, request=request)
challenge = jwt.utils.base64url_encode(
hashlib.sha256(verifier.encode()).digest()
).decode()
if challenge != expected[0]:
return httpx.Response(400, json={"error": "invalid_grant"}, request=request)
now = datetime.now(UTC)
id_token = self._encode(
{
"sub": self.user_subject,
"iss": self.issuer,
"aud": self.audience,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=self._token_ttl_seconds)).timestamp()),
"nonce": expected[1],
"groups": list(expected[2]),
}
)
return httpx.Response(
200,
json={
"access_token": id_token,
"id_token": id_token,
"token_type": "Bearer",
"expires_in": self._token_ttl_seconds,
},
request=request,
)
return httpx.Response(404, request=request)
+123
View File
@@ -0,0 +1,123 @@
"""Audience-bound machine principal mapping and bounded revocation cache."""
from __future__ import annotations
import re
import threading
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from uuid import UUID
from .errors import InvalidToken, RevokedMachinePrincipal, UnknownMachinePrincipal
from .models import AuthenticatedPrincipal, ManagementRole, PrincipalKind, ValidatedClaims
from .provider import OIDCProviderClient
@dataclass(frozen=True)
class MachineClientBinding:
subject: str
client_id: UUID
enabled: bool = True
class PrincipalMapper:
"""Maps trusted identity claims to roles, never to project/environment bindings."""
def __init__(
self,
*,
role_group_map: Mapping[str, ManagementRole] | None = None,
machine_subjects: Mapping[str, MachineClientBinding] | None = None,
) -> None:
self.role_group_map = dict(role_group_map or {})
self.machine_subjects = dict(machine_subjects or {})
def map_claims(self, claims: ValidatedClaims) -> AuthenticatedPrincipal:
machine = self.machine_subjects.get(claims.subject)
if machine is not None:
if not machine.enabled:
raise UnknownMachinePrincipal()
return AuthenticatedPrincipal(
subject=claims.subject,
issuer=claims.issuer,
audience=claims.audience,
kind=PrincipalKind.MACHINE_CLIENT,
client_id=machine.client_id,
machine_subject=claims.subject,
expires_at=claims.expires_at,
issued_at=claims.issued_at,
)
roles = frozenset(
role
for group in claims.groups
for role in [self.role_group_map.get(group)]
if role is not None
)
return AuthenticatedPrincipal(
subject=claims.subject,
issuer=claims.issuer,
audience=claims.audience,
kind=PrincipalKind.MANAGEMENT_USER,
groups=claims.groups,
roles=roles,
expires_at=claims.expires_at,
issued_at=claims.issued_at,
)
class RevocationCache:
"""Caches revocation truth for at most 60 seconds; invalidation is immediate."""
def __init__(self, lookup: Callable[[UUID], bool], *, ttl_seconds: int = 60) -> None:
self.lookup = lookup
self.ttl_seconds = max(1, min(ttl_seconds, 60))
self._values: dict[UUID, tuple[bool, float]] = {}
self._lock = threading.RLock()
def is_revoked(self, client_id: UUID) -> bool:
now = time.monotonic()
with self._lock:
cached = self._values.get(client_id)
if cached is not None and cached[1] > now:
return cached[0]
value = bool(self.lookup(client_id))
with self._lock:
self._values[client_id] = (value, now + self.ttl_seconds)
return value
def invalidate(self, client_id: UUID) -> None:
with self._lock:
self._values.pop(client_id, None)
class MachinePrincipalAuthenticator:
"""Validates a Hub-audience bearer and enforces client registration/revocation."""
_bearer = re.compile(r"^Bearer ([A-Za-z0-9._~+/=-]{1,65536})$")
def __init__(
self,
provider: OIDCProviderClient,
mapper: PrincipalMapper,
revocations: RevocationCache,
) -> None:
self.provider = provider
self.mapper = mapper
self.revocations = revocations
def authenticate(
self,
authorization_header: str | None,
) -> AuthenticatedPrincipal:
if not authorization_header:
raise InvalidToken()
match = self._bearer.fullmatch(authorization_header.strip())
if match is None:
raise InvalidToken()
principal = self.mapper.map_claims(self.provider.validate_token(match.group(1)))
if principal.kind is not PrincipalKind.MACHINE_CLIENT or principal.client_id is None:
raise InvalidToken()
if self.revocations.is_revoked(principal.client_id):
raise RevokedMachinePrincipal()
return principal
+126
View File
@@ -0,0 +1,126 @@
"""Typed identity models with no raw token or project-binding claims."""
from __future__ import annotations
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ManagementRole(StrEnum):
ADMIN = "admin"
OPERATOR = "operator"
AUDITOR = "auditor"
VIEWER = "viewer"
class PrincipalKind(StrEnum):
MANAGEMENT_USER = "management_user"
MACHINE_CLIENT = "machine_client"
class ValidatedClaims(BaseModel):
"""The small validated projection used by authorization mapping."""
model_config = ConfigDict(extra="ignore", frozen=True)
subject: str = Field(min_length=1, max_length=512, alias="sub")
issuer: str = Field(min_length=1, max_length=2048, alias="iss")
audience: tuple[str, ...] = Field(alias="aud")
expires_at: datetime = Field(alias="exp")
issued_at: datetime = Field(alias="iat")
not_before: datetime | None = Field(default=None, alias="nbf")
client_subject: str | None = Field(default=None, alias="azp")
client_id: str | None = None
nonce: str | None = None
groups: tuple[str, ...] = ()
roles: tuple[str, ...] = ()
@field_validator("expires_at", "issued_at", "not_before", mode="before")
@classmethod
def timezone_aware(cls, value: Any) -> Any:
if isinstance(value, int | float):
return datetime.fromtimestamp(value, tz=UTC)
if isinstance(value, datetime) and value.tzinfo is None:
raise ValueError("JWT timestamps must be timezone-aware")
return value
@field_validator("audience", mode="before")
@classmethod
def normalize_audience(cls, value: Any) -> tuple[str, ...]:
if isinstance(value, str):
return (value,)
if isinstance(value, list | tuple) and all(isinstance(item, str) for item in value):
return tuple(value)
raise ValueError("invalid JWT audience")
@field_validator("groups", "roles", mode="before")
@classmethod
def normalize_strings(cls, value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
return (value,)
if isinstance(value, list | tuple) and all(isinstance(item, str) for item in value):
return tuple(value)
raise ValueError("invalid identity group or role claim")
class AuthenticatedPrincipal(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
subject: str = Field(min_length=1, max_length=512)
issuer: str = Field(min_length=1, max_length=2048)
audience: tuple[str, ...]
kind: PrincipalKind
client_id: UUID | None = None
machine_subject: str | None = None
groups: tuple[str, ...] = ()
roles: frozenset[ManagementRole] = frozenset()
expires_at: datetime
issued_at: datetime
@field_validator("expires_at", "issued_at")
@classmethod
def require_aware_time(cls, value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("principal timestamps must be timezone-aware")
return value
class SessionMetadata(BaseModel):
"""Safe browser-visible session data; never contains bearer tokens."""
model_config = ConfigDict(extra="forbid", frozen=True)
subject: str
client_id: UUID | None
kind: PrincipalKind
roles: frozenset[ManagementRole]
expires_at: datetime
class TokenSet(BaseModel):
"""Internal token-exchange result. Never serialize this model to a browser."""
model_config = ConfigDict(extra="ignore")
access_token: str = Field(min_length=1, max_length=65536)
token_type: Literal["Bearer", "bearer"] = "Bearer" # noqa: S105
expires_in: int | None = Field(default=None, ge=1, le=86400)
refresh_token: str | None = Field(default=None, max_length=65536)
id_token: str | None = Field(default=None, max_length=65536)
class SessionCookie(BaseModel):
"""Internal result used to set an opaque session cookie and CSRF token."""
model_config = ConfigDict(extra="forbid", frozen=True)
value: str
csrf_token: str
metadata: SessionMetadata
set_cookie: str
+219
View File
@@ -0,0 +1,219 @@
"""OIDC discovery, key rotation, and asymmetric JWT validation."""
from __future__ import annotations
import json
import time
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import urlencode, urljoin
import httpx
import jwt
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, TypeAdapter, field_validator
from .config import OIDCSettings
from .errors import InvalidToken, ProviderUnavailable, TokenExchangeError
from .models import TokenSet, ValidatedClaims
class DiscoveryDocument(BaseModel):
model_config = ConfigDict(extra="ignore", frozen=True)
issuer: str
authorization_endpoint: AnyHttpUrl
token_endpoint: AnyHttpUrl
jwks_uri: AnyHttpUrl
id_token_signing_alg_values_supported: tuple[str, ...] = ("RS256",)
@field_validator("id_token_signing_alg_values_supported", mode="before")
@classmethod
def normalize_algorithms(cls, value: Any) -> tuple[str, ...]:
if value is None:
return ("RS256",)
return tuple(value)
@field_validator("issuer")
@classmethod
def validate_issuer_url(cls, value: str) -> str:
parsed = TypeAdapter(AnyHttpUrl).validate_python(value)
if parsed.scheme != "https" or parsed.username is not None or parsed.password is not None:
raise ValueError("OIDC issuer must be an HTTPS URL without userinfo")
return value
class OIDCProviderClient:
"""Provider client with bounded discovery/JWKS caches and one rotation retry."""
def __init__(
self,
settings: OIDCSettings,
*,
transport: httpx.BaseTransport | None = None,
timeout_seconds: float = 5.0,
cache_ttl_seconds: int = 300,
) -> None:
self.settings = settings
self._client = httpx.Client(transport=transport, timeout=timeout_seconds)
self._cache_ttl_seconds = max(30, min(cache_ttl_seconds, 900))
self._discovery: DiscoveryDocument | None = None
self._discovery_loaded = 0.0
self._jwks: dict[str, dict[str, Any]] = {}
self._jwks_loaded = 0.0
def close(self) -> None:
self._client.close()
def _request_json(self, method: str, url: str, **kwargs: Any) -> dict[str, Any]:
try:
response = self._client.request(method, url, **kwargs)
response.raise_for_status()
value = response.json()
except (httpx.HTTPError, ValueError, json.JSONDecodeError) as exc:
raise ProviderUnavailable() from exc
if not isinstance(value, dict):
raise ProviderUnavailable()
return value
def discover(self, *, force: bool = False) -> DiscoveryDocument:
now = time.monotonic()
if (
self._discovery is not None
and not force
and now - self._discovery_loaded < self._cache_ttl_seconds
):
return self._discovery
configured_issuer = str(self.settings.issuer)
discovery_base = configured_issuer.rstrip("/") + "/"
raw = self._request_json("GET", urljoin(discovery_base, ".well-known/openid-configuration"))
try:
document = DiscoveryDocument.model_validate(raw)
except ValueError as exc:
raise ProviderUnavailable() from exc
if document.issuer != configured_issuer:
raise ProviderUnavailable()
issuer_origin = discovery_base.split("/", 3)[:3]
for endpoint in (
document.authorization_endpoint,
document.token_endpoint,
document.jwks_uri,
):
if str(endpoint).split("/", 3)[:3] != issuer_origin:
raise ProviderUnavailable()
self._discovery = document
self._discovery_loaded = now
return document
def _load_jwks(self, *, force: bool = False) -> dict[str, dict[str, Any]]:
now = time.monotonic()
if self._jwks and not force and now - self._jwks_loaded < self._cache_ttl_seconds:
return self._jwks
document = self.discover()
raw = self._request_json("GET", str(document.jwks_uri))
keys = raw.get("keys")
if not isinstance(keys, list):
raise ProviderUnavailable()
parsed: dict[str, dict[str, Any]] = {}
for key in keys:
if (
isinstance(key, dict)
and isinstance(key.get("kid"), str)
and key.get("kty") in {"RSA", "EC", "OKP"}
):
parsed[key["kid"]] = key
if not parsed:
raise ProviderUnavailable()
self._jwks = parsed
self._jwks_loaded = now
return parsed
def build_authorization_url(self, *, state: str, nonce: str, code_challenge: str) -> str:
document = self.discover()
query = urlencode(
{
"response_type": "code",
"client_id": self.settings.client_id,
"redirect_uri": str(self.settings.redirect_uri),
"scope": "openid profile email groups",
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
)
return f"{document.authorization_endpoint}?{query}"
def exchange_code(self, *, code: str, code_verifier: str) -> TokenSet:
document = self.discover()
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": str(self.settings.redirect_uri),
"client_id": self.settings.client_id,
"code_verifier": code_verifier,
}
secret = self.settings.client_secret()
if secret is not None:
data["client_secret"] = secret
try:
response = self._client.post(str(document.token_endpoint), data=data)
response.raise_for_status()
return TokenSet.model_validate(response.json())
except (httpx.HTTPError, ValueError, json.JSONDecodeError) as exc:
raise TokenExchangeError() from exc
def validate_token(self, token: str, *, now: datetime | None = None) -> ValidatedClaims:
if not token or len(token) > 65536:
raise InvalidToken()
try:
header = jwt.get_unverified_header(token)
except jwt.PyJWTError as exc:
raise InvalidToken() from exc
algorithm = header.get("alg")
document = self.discover()
kid = header.get("kid")
if not isinstance(algorithm, str) or algorithm not in self.settings.allowed_algorithms:
raise InvalidToken()
if (
not isinstance(kid, str)
or not kid
or algorithm not in document.id_token_signing_alg_values_supported
):
raise InvalidToken()
keys = self._load_jwks()
key = keys.get(kid)
if key is None:
keys = self._load_jwks(force=True)
key = keys.get(kid)
if key is None:
raise InvalidToken()
try:
signing_key = jwt.PyJWK.from_dict(key).key
claims = jwt.decode(
token,
signing_key,
algorithms=list(self.settings.allowed_algorithms),
audience=self.settings.audience,
issuer=str(document.issuer),
leeway=self.settings.clock_skew_seconds,
options={
"require": ["sub", "iss", "aud", "exp", "iat"],
"verify_exp": False,
"verify_iat": False,
"verify_nbf": False,
},
)
validated = ValidatedClaims.model_validate(claims)
except (jwt.PyJWTError, ValueError, TypeError) as exc:
raise InvalidToken() from exc
reference = now or datetime.now(UTC)
if reference.tzinfo is None or reference.utcoffset() is None:
raise InvalidToken()
skew = timedelta(seconds=self.settings.clock_skew_seconds)
if validated.expires_at + skew <= reference:
raise InvalidToken()
if validated.issued_at - skew > reference:
raise InvalidToken()
if validated.not_before is not None and validated.not_before - skew > reference:
raise InvalidToken()
return validated
+70
View File
@@ -0,0 +1,70 @@
"""FastAPI routes for the same-origin management BFF."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from .bff import BFFService
from .errors import AuthError
def _safe_error(exc: AuthError) -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": exc.code, "message": exc.public_message},
headers={"Cache-Control": "no-store"},
)
def build_auth_router(bff: BFFService) -> APIRouter:
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/login", include_in_schema=False)
def login() -> RedirectResponse:
try:
return RedirectResponse(
bff.begin_login(), status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
except AuthError as exc:
raise _safe_error(exc) from exc
@router.get("/callback", include_in_schema=False)
def callback(code: str, state: str) -> RedirectResponse:
try:
session = bff.complete_login(code=code, state=state)
except AuthError as exc:
raise _safe_error(exc) from exc
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
response.headers["Set-Cookie"] = session.set_cookie
response.headers["Cache-Control"] = "no-store"
return response
@router.get("/session")
def session(request: Request, response: Response) -> dict[str, object]:
cookie = request.cookies.get(bff.settings.cookie_name)
if cookie is None:
response.headers["Cache-Control"] = "no-store"
return {"authenticated": False}
try:
metadata = bff.session_metadata(cookie)
record = bff.sessions.get(cookie)
except AuthError as exc:
raise _safe_error(exc) from exc
return {
"authenticated": True,
"csrf_token": record.csrf_token,
"session": metadata.model_dump(mode="json"),
}
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def logout(request: Request, response: Response) -> None:
try:
cookie = request.cookies.get(bff.settings.cookie_name)
clear_cookie = bff.logout(cookie, request.headers.get("X-CSRF-Token"))
except AuthError as exc:
raise _safe_error(exc) from exc
response.headers["Set-Cookie"] = clear_cookie
response.headers["Cache-Control"] = "no-store"
return router
+121
View File
@@ -0,0 +1,121 @@
"""Per-endpoint management-API permission matrix.
Framework-agnostic (no FastAPI import) so the matrix itself is directly
unit-testable without standing up an app. `services/runtime/control_api.py`
wires this into FastAPI dependencies.
Replaces the previous "has at least one management role" gate — under which
a viewer, an auditor, and an admin were functionally identical in what they
were allowed to do, including the most destructive operation in the API
(catalog reconciliation) — with an explicit, deny-by-default table checked
against every route.
## The matrix
| Action | viewer | auditor | operator | admin |
|---|---|---|---|---|
| dashboard.read | X | X | X | X |
| resource.read (projects/servers/tools/connectors/policies/clients) | X | X | X | X |
| audit.read | | X | X | X |
| policy.simulate | X | X | X | X |
| catalog.preview | X | X | X | X |
| tool.test | | | X | X |
| tool.invoke | | | X | X |
| operations.check | | | X | X |
| client.bootstrap | | | X | X |
| client.disable | | | X | X |
| client.revoke | | | X | X |
| client.create | | | | X |
| catalog.apply | | | | X |
Rationale for the split: `viewer`/`auditor` are both read-only, distinguished
only by whether audit/evidence access is included. `operator` additionally
gets bounded, reversible-in-effect-if-not-in-mechanism client lifecycle
actions on *existing* clients (bootstrap re-issuance, disable, revoke) --
useful for incident response without granting provisioning power. `admin`
alone can mint new trusted client identity (`client.create`) or mutate the
catalog for real (`catalog.apply`); both create durable, hard-to-undo state.
`policy.simulate` and `catalog.preview` are side-effect-free by contract
(see services/control_api/policy/service.py and
services/control_api/catalog/router.py) and are available to every role,
same as any other read.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from services.control_api.auth.models import AuthenticatedPrincipal, ManagementRole
logger = logging.getLogger("itworx_mcp_hub.management_authz")
_R = ManagementRole
ENDPOINT_PERMISSIONS: Mapping[str, frozenset[ManagementRole]] = {
"dashboard.read": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}),
"resource.read": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}),
"audit.read": frozenset({_R.AUDITOR, _R.OPERATOR, _R.ADMIN}),
"policy.simulate": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}),
"catalog.preview": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}),
"tool.test": frozenset({_R.OPERATOR, _R.ADMIN}),
"tool.invoke": frozenset({_R.OPERATOR, _R.ADMIN}),
"operations.check": frozenset({_R.OPERATOR, _R.ADMIN}),
"client.read": frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN}),
"client.bootstrap": frozenset({_R.OPERATOR, _R.ADMIN}),
"client.disable": frozenset({_R.OPERATOR, _R.ADMIN}),
"client.revoke": frozenset({_R.OPERATOR, _R.ADMIN}),
"client.create": frozenset({_R.ADMIN}),
"catalog.apply": frozenset({_R.ADMIN}),
}
# Actions available to every authenticated management role are not
# "privileged" in the audit sense -- logging every dashboard poll would be
# pure noise. Anything requiring more than baseline viewer access is logged
# on both allow and deny; baseline reads are logged only on deny.
_BASELINE_ROLES = frozenset({_R.VIEWER, _R.AUDITOR, _R.OPERATOR, _R.ADMIN})
class InsufficientRole(PermissionError):
"""Raised when a principal's roles don't satisfy an action's requirement."""
def __init__(self, action: str, principal: AuthenticatedPrincipal) -> None:
self.action = action
self.principal_roles = principal.roles
super().__init__(f"role(s) {sorted(principal.roles)} cannot perform {action!r}")
def check_role(principal: AuthenticatedPrincipal, action: str) -> None:
"""Raise InsufficientRole unless `principal` has a role permitted for `action`.
Every call is logged: denials always, and allows for any action whose
permitted role set is narrower than the full baseline (i.e. every
non-universally-available action) -- "audit every privileged action and
denial" from the WP-230 acceptance criteria.
"""
try:
allowed = ENDPOINT_PERMISSIONS[action]
except KeyError as exc:
raise ValueError(f"unknown management action: {action!r}") from exc
is_privileged = allowed != _BASELINE_ROLES
if not (principal.roles & allowed):
logger.warning(
"management_authz_denied",
extra={
"action": action,
"subject": principal.subject,
"principal_roles": sorted(role.value for role in principal.roles),
"allowed_roles": sorted(role.value for role in allowed),
},
)
raise InsufficientRole(action, principal)
if is_privileged:
logger.info(
"management_authz_allowed",
extra={
"action": action,
"subject": principal.subject,
"principal_roles": sorted(role.value for role in principal.roles),
},
)
+6
View File
@@ -0,0 +1,6 @@
"""Declarative catalog validation and reconciliation boundaries."""
from .loader import CatalogLoader
from .reconciler import CatalogReconciler
__all__ = ["CatalogLoader", "CatalogReconciler"]
+342
View File
@@ -0,0 +1,342 @@
"""Catalog file loading, JSON Schema validation, and safe normalization."""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from typing import Any, cast
from urllib.parse import urlsplit
import yaml # type: ignore[import-untyped]
from jsonschema import Draft202012Validator, RefResolver # type: ignore[import-untyped]
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, TypeAdapter, ValidationError
from services.control_api.contextforge.models import GatewayDesiredState
from .models import CatalogDocument, CatalogIssue, CatalogSource, CatalogState, ValidationReport
SCHEMA_BY_DIRECTORY = {
"workspaces": "workspace.schema.json",
"projects": "project.schema.json",
"connectors": "connector-manifest.schema.json",
"capability-packs": "capability-pack.schema.json",
"policies": "policy.schema.json",
"prompts": "prompt-manifest.schema.json",
"resources": "resource-manifest.schema.json",
"tools": "tool-manifest.schema.json",
}
ALLOWED_REFERENCE = re.compile(
r"^(?:docker-secret|service-identity|authentik-client|config)://[^\s]+$"
)
SENSITIVE_KEY = re.compile(
r"(?:password|secret|token|private[_-]?key|credential|authorization|cookie)", re.I
)
class _Envelope(BaseModel):
model_config = ConfigDict(extra="forbid")
apiVersion: str
kind: str
metadata: dict[str, Any]
spec: dict[str, Any]
def _canonical(value: Any) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def _pointer(parts: list[Any]) -> str:
escaped = [str(part).replace("~", "~0").replace("/", "~1") for part in parts]
return "/" + "/".join(escaped) if escaped else "/"
class CatalogLoader:
"""Loads only the configured catalog root; caller-controlled paths are rejected."""
def __init__(
self,
catalog_root: Path,
*,
schema_root: Path | None = None,
public_base_url: str = "https://mcp.example.com",
) -> None:
self.root = catalog_root.resolve()
self.schema_root = (schema_root or self.root.parent / "contracts" / "schemas").resolve()
self.public_base_url = str(TypeAdapter(AnyHttpUrl).validate_python(public_base_url)).rstrip(
"/"
)
def _issue(self, code: str, message: str, path: Path, pointer: str = "/") -> CatalogIssue:
return CatalogIssue(
code=code,
message=message,
source=CatalogSource(file=path.relative_to(self.root).as_posix(), pointer=pointer),
)
def _secret_issues(
self, value: Any, path: Path, pointer_parts: list[Any], key: str | None = None
) -> list[CatalogIssue]:
issues: list[CatalogIssue] = []
pointer = _pointer(pointer_parts)
if isinstance(value, dict):
for child_key, child_value in value.items():
issues.extend(
self._secret_issues(
child_value, path, [*pointer_parts, child_key], str(child_key)
)
)
elif isinstance(value, list):
for index, child_value in enumerate(value):
issues.extend(self._secret_issues(child_value, path, [*pointer_parts, index], key))
elif isinstance(value, str):
parsed = urlsplit(value)
if parsed.username is not None or parsed.password is not None:
issues.append(
self._issue(
"secret_value", "catalog URLs must not contain userinfo", path, pointer
)
)
if (
key
and SENSITIVE_KEY.search(key)
and value
and not ALLOWED_REFERENCE.fullmatch(value)
):
issues.append(
self._issue(
"secret_value",
"secret-bearing fields must contain a reference URI, not a value",
path,
pointer,
)
)
return issues
def _safe_payload(self, value: Any, key: str | None = None) -> Any:
if isinstance(value, dict):
return {
str(child_key): self._safe_payload(child_value, str(child_key))
for child_key, child_value in value.items()
}
if isinstance(value, list):
return [self._safe_payload(child_value, key) for child_value in value]
if isinstance(value, str):
if key and SENSITIVE_KEY.search(key) and not ALLOWED_REFERENCE.fullmatch(value):
return "<redacted>"
parsed = urlsplit(value)
if parsed.username is not None or parsed.password is not None:
return "<redacted-url>"
return value
def _schema(self, schema_name: str) -> dict[str, Any]:
return cast(
dict[str, Any], json.loads((self.schema_root / schema_name).read_text(encoding="utf-8"))
)
def _schema_store(self) -> dict[str, Any]:
store: dict[str, Any] = {}
for schema_path in self.schema_root.glob("*.schema.json"):
schema = cast(dict[str, Any], json.loads(schema_path.read_text(encoding="utf-8")))
schema_id = schema.get("$id")
if isinstance(schema_id, str):
store[schema_id] = schema
return store
def load(self) -> ValidationReport:
issues: list[CatalogIssue] = []
documents: list[CatalogDocument] = []
source_files: list[str] = []
seen: set[tuple[str, str]] = set()
if not self.root.is_dir():
return ValidationReport(
valid=False,
issues=(
self._issue("catalog_root_missing", "catalog root does not exist", self.root),
),
)
for path in sorted(self.root.rglob("*")):
if not path.is_file() or path.suffix.lower() not in {".yaml", ".yml", ".json"}:
continue
relative = path.relative_to(self.root)
directory = relative.parts[0] if relative.parts else ""
schema_name = SCHEMA_BY_DIRECTORY.get(directory)
if schema_name is None:
issues.append(
self._issue(
"unsupported_catalog_path",
"catalog file is outside a supported catalog directory",
path,
)
)
continue
source_files.append(relative.as_posix())
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
issues.append(
self._issue(
"parse_error",
f"catalog document could not be parsed: {type(exc).__name__}",
path,
)
)
continue
if not isinstance(raw, dict):
issues.append(
self._issue("document_type", "catalog document must be an object", path)
)
continue
issues.extend(self._secret_issues(raw, path, []))
schema = self._schema(schema_name)
resolver = RefResolver.from_schema(schema, store=self._schema_store())
for error in sorted(
Draft202012Validator(schema, resolver=resolver).iter_errors(raw),
key=lambda item: list(item.path),
):
issues.append(
self._issue(
"schema_error",
error.message[:480],
path,
_pointer(list(error.absolute_path)),
)
)
try:
envelope = _Envelope.model_validate(raw)
metadata = envelope.metadata
slug = metadata.get("slug")
name = metadata.get("name")
if not isinstance(slug, str) or not isinstance(name, str):
raise ValueError("metadata.name and metadata.slug are required")
identity = (envelope.kind, slug)
if identity in seen:
issues.append(
self._issue(
"duplicate_identity",
f"duplicate catalog identity {envelope.kind}/{slug}",
path,
"/metadata/slug",
)
)
seen.add(identity)
documents.append(
CatalogDocument(
kind=envelope.kind,
name=name,
slug=slug,
payload=self._safe_payload(raw),
source=CatalogSource(file=relative.as_posix()),
)
)
except (ValidationError, ValueError) as exc:
issues.append(self._issue("typed_validation", str(exc).splitlines()[0][:480], path))
issues.extend(self._cross_reference_issues(documents))
if issues:
return ValidationReport(
valid=False,
documents=tuple(sorted(documents, key=lambda item: (item.kind, item.slug))),
issues=tuple(issues),
source_files=tuple(source_files),
)
documents_tuple = tuple(sorted(documents, key=lambda item: (item.kind, item.slug)))
gateways = self._gateway_desired(documents_tuple)
gateway_slugs = [gateway.slug for gateway in gateways]
if len(gateway_slugs) != len(set(gateway_slugs)):
duplicate = next(slug for slug in gateway_slugs if gateway_slugs.count(slug) > 1)
issue = self._issue(
"duplicate_gateway_identity",
f"duplicate virtual server gateway identity {duplicate}",
self.root / documents_tuple[0].source.file,
"/spec/virtualServers",
)
return ValidationReport(
valid=False,
documents=documents_tuple,
issues=(issue,),
source_files=tuple(source_files),
)
desired_material = {
"documents": [document.payload for document in documents_tuple],
"gateways": [item.model_dump(mode="json") for item in gateways],
}
desired_hash = hashlib.sha256(_canonical(desired_material).encode("utf-8")).hexdigest()
return ValidationReport(
valid=True,
desired_hash=desired_hash,
documents=documents_tuple,
source_files=tuple(source_files),
)
def state(self) -> CatalogState:
report = self.load()
if not report.valid or report.desired_hash is None:
raise ValueError(
"catalog is invalid: " + "; ".join(issue.message for issue in report.issues[:5])
)
gateways = self._gateway_desired(report.documents)
return CatalogState(
documents=report.documents,
gateways=gateways,
desired_hash=report.desired_hash,
source_files=report.source_files,
)
def _cross_reference_issues(self, documents: list[CatalogDocument]) -> list[CatalogIssue]:
known = {(document.kind, document.slug) for document in documents}
issues: list[CatalogIssue] = []
for document in documents:
if document.kind != "Project":
continue
spec = document.payload["spec"]
for field, kind in (
("capabilityPacks", "CapabilityPack"),
("connectorBindings", "ConnectorTemplate"),
):
values = spec.get(field, [])
refs = [
value
if isinstance(value, str)
else value.get("ref")
if isinstance(value, dict)
else None
for value in values
]
for ref in refs:
if isinstance(ref, str) and (kind, ref) not in known:
issues.append(
self._issue(
"missing_reference",
f"{field} references unknown {kind}/{ref}",
self.root / document.source.file,
f"/spec/{field}",
)
)
return issues
def _gateway_desired(
self, documents: tuple[CatalogDocument, ...]
) -> tuple[GatewayDesiredState, ...]:
projects = [document for document in documents if document.kind == "Project"]
result: list[GatewayDesiredState] = []
for project in projects:
spec = project.payload["spec"]
for virtual_server in spec["virtualServers"]:
endpoint = virtual_server["endpointAlias"]
url = f"{self.public_base_url}{endpoint}"
result.append(
GatewayDesiredState.model_validate(
{
"slug": virtual_server["slug"],
"url": url,
"description": (
f"{project.name} {virtual_server['environment']} virtual server"
),
"transport": "STREAMABLEHTTP",
"enabled": True,
}
)
)
return tuple(sorted(result, key=lambda item: item.slug))
+99
View File
@@ -0,0 +1,99 @@
"""Typed, secret-safe catalog and reconciliation contracts."""
from __future__ import annotations
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from services.control_api.contextforge.models import GatewayDesiredState, GatewaySnapshot
class CatalogSource(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
file: str = Field(min_length=1, max_length=512)
pointer: str = Field(default="/", max_length=512)
class CatalogIssue(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
code: str = Field(min_length=1, max_length=64)
message: str = Field(min_length=1, max_length=512)
source: CatalogSource
class CatalogDocument(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
kind: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=120)
slug: str = Field(min_length=1, max_length=80)
payload: dict[str, Any]
source: CatalogSource
class CatalogState(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
documents: tuple[CatalogDocument, ...]
gateways: tuple[GatewayDesiredState, ...]
desired_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
source_files: tuple[str, ...]
class ValidationReport(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
valid: bool
desired_hash: str | None = None
documents: tuple[CatalogDocument, ...] = ()
issues: tuple[CatalogIssue, ...] = ()
source_files: tuple[str, ...] = ()
class PlanAction(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
action: Literal["create", "update", "disable", "noop"]
slug: str = Field(min_length=1, max_length=63)
desired: GatewayDesiredState
current: GatewaySnapshot | None = None
managed: bool = False
reason: str = Field(min_length=1, max_length=256)
class DriftItem(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
slug: str = Field(min_length=1, max_length=256)
kind: Literal["unknown_gateway", "managed_gateway", "missing_gateway"]
detail: str = Field(min_length=1, max_length=256)
class ReconcilePlan(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
desired_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
plan_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
actions: tuple[PlanAction, ...]
drift: tuple[DriftItem, ...]
source_files: tuple[str, ...]
class ReconcileRunView(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
id: UUID
workspace_id: UUID
project_id: UUID
desired_hash: str
current_hash: str | None
plan_hash: str | None
status: str
planned_changes: tuple[dict[str, Any], ...]
applied_changes: tuple[dict[str, Any], ...]
verification: dict[str, Any]
revision: int
+125
View File
@@ -0,0 +1,125 @@
"""Deterministic gateway diff and drift planning."""
from __future__ import annotations
import hashlib
import json
from collections.abc import Iterable
from services.control_api.contextforge.models import GatewayDesiredState, GatewaySnapshot
from .models import CatalogState, DriftItem, PlanAction, ReconcilePlan
def _canonical(value: object) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def _same(current: GatewaySnapshot, desired: GatewayDesiredState) -> bool:
value = desired.model_dump(mode="json")
return (
current.slug == value["slug"]
and current.url.rstrip("/") == str(value["url"]).rstrip("/")
and current.description == value["description"]
and current.transport == value["transport"]
and current.enabled == value["enabled"]
)
def build_plan(
state: CatalogState, current: Iterable[GatewaySnapshot], managed_slugs: set[str]
) -> ReconcilePlan:
current_by_slug = {item.slug: item for item in current}
desired_by_slug = {item.slug: item for item in state.gateways}
actions: list[PlanAction] = []
for desired in state.gateways:
snapshot = current_by_slug.get(desired.slug)
if snapshot is None:
actions.append(
PlanAction(
action="create",
slug=desired.slug,
desired=desired,
reason="desired gateway is absent",
)
)
elif _same(snapshot, desired):
actions.append(
PlanAction(
action="noop",
slug=desired.slug,
desired=desired,
current=snapshot,
managed=True,
reason="desired gateway already matches",
)
)
else:
actions.append(
PlanAction(
action="update",
slug=desired.slug,
desired=desired,
current=snapshot,
managed=True,
reason="gateway differs from desired state",
)
)
for slug in sorted(set(current_by_slug) - set(desired_by_slug)):
snapshot = current_by_slug[slug]
if slug in managed_slugs:
disabled = desired_by_slug.get(slug)
if disabled is None:
disabled = GatewayDesiredState.model_validate(
{
"slug": slug,
"url": snapshot.url,
"description": snapshot.description,
"transport": snapshot.transport,
"enabled": False,
}
)
actions.append(
PlanAction(
action="disable",
slug=slug,
desired=disabled,
current=snapshot,
managed=True,
reason="managed gateway is no longer declared",
)
)
drift = tuple(
DriftItem(
slug=slug,
kind="unknown_gateway",
detail="gateway exists outside the catalog and will not be deleted",
)
for slug in sorted(set(current_by_slug) - set(desired_by_slug) - managed_slugs)
)
action_material = [
{
"action": action.action,
"slug": action.slug,
"desired": action.desired.model_dump(mode="json"),
"current": action.current.model_dump(mode="json") if action.current else None,
"managed": action.managed,
}
for action in actions
]
plan_hash = hashlib.sha256(
_canonical(
{
"desired_hash": state.desired_hash,
"actions": action_material,
"drift": [item.model_dump(mode="json") for item in drift],
}
).encode("utf-8")
).hexdigest()
return ReconcilePlan(
desired_hash=state.desired_hash,
plan_hash=plan_hash,
actions=tuple(actions),
drift=drift,
source_files=state.source_files,
)
+230
View File
@@ -0,0 +1,230 @@
"""Safe, resumable catalog plan/apply/verify orchestration."""
from __future__ import annotations
from threading import RLock
from typing import Any
from uuid import UUID
from packages.hub_core.domain import ReconcileRun
from services.control_api.contextforge.client import GatewayAdapter
from .loader import CatalogLoader
from .models import CatalogState, ReconcilePlan, ReconcileRunView, ValidationReport
from .planner import build_plan
from .store import MemoryReconcileRunStore, now
class ReconciliationLock:
"""Process-local fallback lock; Redis may be unavailable or evicted without data loss."""
_locks: dict[str, RLock] = {}
_registry_lock = RLock()
def __init__(self, key: str) -> None:
with self._registry_lock:
self._lock = self._locks.setdefault(key, RLock())
def __enter__(self) -> ReconciliationLock:
self._lock.acquire()
return self
def __exit__(self, *_: object) -> None:
self._lock.release()
class CatalogReconciler:
def __init__(
self, loader: CatalogLoader, adapter: GatewayAdapter, *, store: Any | None = None
) -> None:
self.loader = loader
self.adapter = adapter
self.store = store or MemoryReconcileRunStore()
self._managed_slugs: set[str] = set()
def validate(self) -> ValidationReport:
return self.loader.load()
def _state_and_plan(self) -> tuple[CatalogState, ReconcilePlan]:
state = self.loader.state()
current = self.adapter.list_gateways(limit=100)
known = self._known_managed_slugs()
return state, build_plan(state, current, known)
def _known_managed_slugs(self) -> set[str]:
known = set(self._managed_slugs)
provider = getattr(self.store, "managed_slugs", None)
if callable(provider):
known.update(provider())
return known
def preview(self) -> ReconcilePlan:
with ReconciliationLock("catalog-preview"):
return self._state_and_plan()[1]
def _view(self, run: ReconcileRun) -> ReconcileRunView:
return ReconcileRunView(
id=run.id,
workspace_id=run.workspace_id,
project_id=run.project_id,
desired_hash=run.desired_hash,
current_hash=run.current_hash,
plan_hash=run.plan_hash,
status=run.status,
planned_changes=tuple(run.planned_changes),
applied_changes=tuple(run.applied_changes),
verification=run.verification,
revision=run.revision,
)
def _save(self, run: ReconcileRun, **changes: Any) -> ReconcileRun:
updated = run.model_copy(
update={"updated_at": now(), "revision": run.revision + 1, **changes}
)
return self.store.save(updated)
def apply(self, workspace_id: UUID, project_id: UUID) -> ReconcileRunView:
with ReconciliationLock(f"catalog-apply:{workspace_id}:{project_id}"):
report = self.validate()
if not report.valid or report.desired_hash is None:
run = ReconcileRun(
workspace_id=workspace_id,
project_id=project_id,
desired_hash=report.desired_hash or "0" * 64,
status="invalid",
planned_changes=[],
applied_changes=[],
verification={
"issues": [issue.model_dump(mode="json") for issue in report.issues]
},
created_at=now(),
updated_at=now(),
)
return self._view(self.store.create(run))
state, plan = self._state_and_plan()
run = ReconcileRun(
workspace_id=workspace_id,
project_id=project_id,
desired_hash=state.desired_hash,
plan_hash=plan.plan_hash,
status="created",
planned_changes=[self._safe_action(action) for action in plan.actions],
applied_changes=[],
verification={"drift": [item.model_dump(mode="json") for item in plan.drift]},
started_at=now(),
created_at=now(),
updated_at=now(),
)
run = self.store.create(run)
run = self._save(run, status="planning")
run = self._save(run, status="planned", current_hash=self._current_hash(plan))
if not any(action.action != "noop" for action in plan.actions):
run = self._save(run, status="verifying")
verification = self._verify(state, plan)
run = self._save(
run,
status="succeeded" if verification["valid"] else "failed",
verification=verification,
completed_at=now(),
)
return self._view(run)
run = self._save(run, status="applying")
applied: list[dict[str, Any]] = []
try:
for action in plan.actions:
if action.action == "noop":
continue
self.adapter.ensure_gateway(action.desired)
self._managed_slugs.add(action.slug)
applied.append(
{
"action": action.action,
"slug": action.slug,
"managed": True,
"status": "applied",
}
)
run = self._save(run, applied_changes=applied)
except Exception as exc: # adapter errors are intentionally sanitized
failure = {
"action": action.action,
"slug": action.slug,
"managed": True,
"status": "failed",
"error": type(exc).__name__,
}
run = self._save(
run,
applied_changes=[*applied, failure],
verification={"valid": False, "resumable": True, "error": type(exc).__name__},
status="failed",
completed_at=now(),
)
return self._view(run)
run = self._save(run, status="verifying")
verification = self._verify(state, plan)
run = self._save(
run,
status="succeeded" if verification["valid"] else "failed",
verification=verification,
completed_at=now(),
)
return self._view(run)
def _verify(self, state: CatalogState, plan: ReconcilePlan) -> dict[str, Any]:
current = self.adapter.list_gateways(limit=100)
actual = {item.slug: item for item in current}
missing = sorted(
item.slug
for item in state.gateways
if item.slug not in actual or actual[item.slug].enabled is not True
)
mismatched = sorted(
item.slug
for item in state.gateways
if item.slug in actual
and actual[item.slug].enabled
and actual[item.slug].url.rstrip("/") != str(item.url).rstrip("/")
)
return {
"valid": not missing and not mismatched,
"missing": missing,
"mismatched": mismatched,
"drift": [item.model_dump(mode="json") for item in plan.drift],
"resumable": bool(missing or mismatched),
}
@staticmethod
def _current_hash(plan: ReconcilePlan) -> str:
import hashlib
import json
return hashlib.sha256(
json.dumps(
[
action.current.model_dump(mode="json")
for action in plan.actions
if action.current
],
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()
@staticmethod
def _safe_action(action: Any) -> dict[str, Any]:
return {
"action": action.action,
"slug": action.slug,
"managed": action.managed,
"reason": action.reason,
"desired": action.desired.model_dump(mode="json"),
"current": action.current.model_dump(mode="json") if action.current else None,
}
def status(self, run_id: UUID) -> ReconcileRunView | None:
run = self.store.get(run_id)
return self._view(run) if run else None
def drift(self) -> tuple[dict[str, Any], ...]:
return tuple(item.model_dump(mode="json") for item in self.preview().drift)
+65
View File
@@ -0,0 +1,65 @@
"""Authenticated management API boundary for catalog operations."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict
from .models import ReconcilePlan, ReconcileRunView, ValidationReport
from .reconciler import CatalogReconciler
def _deny_by_default(_: Request) -> None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="management authorization required"
)
class ScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
workspace_id: UUID
project_id: UUID
def build_catalog_router(
reconciler: CatalogReconciler, *, authorize: Callable[[Request], Any] = _deny_by_default
) -> APIRouter:
def authorize_request(request: Request) -> None:
authorize(request)
router = APIRouter(
prefix="/catalog", tags=["catalog"], dependencies=[Depends(authorize_request)]
)
@router.post("/validate", response_model=ValidationReport)
def validate() -> ValidationReport:
return reconciler.validate()
@router.post("/preview", response_model=ReconcilePlan)
def preview() -> ReconcilePlan:
try:
return reconciler.preview()
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/apply", response_model=ReconcileRunView)
def apply(request: ScopeRequest) -> ReconcileRunView:
return reconciler.apply(request.workspace_id, request.project_id)
@router.get("/status/{run_id}", response_model=ReconcileRunView)
def status_for_run(run_id: UUID) -> ReconcileRunView:
result = reconciler.status(run_id)
if result is None:
raise HTTPException(status_code=404, detail="reconcile run not found")
return result
@router.get("/drift")
def drift() -> dict[str, Any]:
return {"items": reconciler.drift()}
return router
+91
View File
@@ -0,0 +1,91 @@
"""Reconcile-run stores: in-memory for tests and Hub PostgreSQL/SQLite persistence."""
from __future__ import annotations
from copy import deepcopy
from datetime import UTC, datetime
from threading import RLock
from uuid import UUID
from sqlalchemy.orm import Session
from packages.hub_core.domain import ReconcileRun, Scope
from services.control_api.persistence.repositories import ReconcileRunRepository
def now() -> datetime:
return datetime.now(UTC)
class MemoryReconcileRunStore:
def __init__(self) -> None:
self._runs: dict[UUID, ReconcileRun] = {}
self._lock = RLock()
def create(self, run: ReconcileRun) -> ReconcileRun:
with self._lock:
self._runs[run.id] = deepcopy(run)
return deepcopy(run)
def save(self, run: ReconcileRun) -> ReconcileRun:
with self._lock:
previous = self._runs.get(run.id)
if previous is None:
raise LookupError(f"reconcile run not found: {run.id}")
if run.revision != previous.revision + 1:
raise ValueError("reconcile run revision must advance exactly once")
self._runs[run.id] = deepcopy(run)
return deepcopy(run)
def managed_slugs(self) -> set[str]:
with self._lock:
slugs: set[str] = set()
for run in self._runs.values():
for change in [*run.planned_changes, *run.applied_changes]:
slug = change.get("slug")
if change.get("managed") is True and isinstance(slug, str):
slugs.add(slug)
return slugs
def get(self, run_id: UUID) -> ReconcileRun | None:
with self._lock:
return deepcopy(self._runs.get(run_id))
def list(self, workspace_id: UUID, project_id: UUID) -> tuple[ReconcileRun, ...]:
with self._lock:
return tuple(
deepcopy(run)
for run in self._runs.values()
if run.workspace_id == workspace_id and run.project_id == project_id
)
class SqlAlchemyReconcileRunStore:
"""Adapter over the existing scoped reconcile_runs table."""
def __init__(self, session: Session, scope: Scope) -> None:
self.repository = ReconcileRunRepository(session, scope)
def create(self, run: ReconcileRun) -> ReconcileRun:
return self.repository.create(run)
def save(self, run: ReconcileRun) -> ReconcileRun:
current = self.repository.get(run.id)
if current is None:
raise LookupError(f"reconcile run not found: {run.id}")
return self.repository.update(run, expected_version=current.revision)
def managed_slugs(self) -> set[str]:
slugs: set[str] = set()
for run in self.repository.list(limit=500):
for change in [*run.planned_changes, *run.applied_changes]:
slug = change.get("slug")
if change.get("managed") is True and isinstance(slug, str):
slugs.add(slug)
return slugs
def get(self, run_id: UUID) -> ReconcileRun | None:
return self.repository.get(run_id)
def list(self, workspace_id: UUID, project_id: UUID) -> tuple[ReconcileRun, ...]:
return tuple(self.repository.list(limit=500))
+9
View File
@@ -0,0 +1,9 @@
from .router import ClientCreateRequest, ClientOnboardingResponse, build_client_router
from .service import ClientService
__all__ = [
"ClientCreateRequest",
"ClientOnboardingResponse",
"ClientService",
"build_client_router",
]
+141
View File
@@ -0,0 +1,141 @@
"""Authenticated management routes for machine-client onboarding and revocation."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field
from packages.hub_clients import BootstrapHandle, ClientConfig, ClientKind, ClientRecord
from .service import ClientService
def _deny_by_default(_: Request) -> None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="client management authorization required"
)
def _deny_all_actions(_: Any, action: str) -> None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"client management authorization required for {action}",
)
class ClientCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=128)
project: str = Field(min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9-]*$")
environment: str = Field(min_length=1, max_length=32, pattern=r"^[a-z0-9][a-z0-9-]*$")
kind: ClientKind
class ClientOnboardingResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
client: ClientRecord
bootstrap: BootstrapHandle
config: ClientConfig
provisioning: dict[str, Any]
def build_client_router(
service: ClientService,
*,
authenticate: Callable[[Request], Any] = _deny_by_default,
authorize: Callable[[Any, str], None] = _deny_all_actions,
prefix: str = "/clients",
token_endpoint: str = "https://auth.example.invalid/application/o/token/", # noqa: S107
oauth_client_id: str = "itworx-mcp-hub",
) -> APIRouter:
"""Every route authenticates first, then checks a specific action against
`authorize` -- deliberately not one blanket router-level dependency, so a
read (list/config) and a destructive action (revoke) can require
different roles. See services/control_api/authz.py for the actual
permission matrix `authorize` is expected to enforce in production.
"""
router = APIRouter(prefix=prefix, tags=["clients"])
# Bound once, reused as route defaults below: ruff (flake8-bugbear B008)
# flags calling `Depends(...)` directly inside a function signature's
# default value.
authenticated = Depends(authenticate)
@router.post("", response_model=ClientOnboardingResponse, status_code=status.HTTP_201_CREATED)
def create(
payload: ClientCreateRequest, principal: Any = authenticated
) -> ClientOnboardingResponse:
authorize(principal, "client.create")
try:
client, bootstrap, config = service.create(**payload.model_dump())
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return ClientOnboardingResponse(
client=client,
bootstrap=bootstrap,
config=config,
provisioning={
"status": "pending-authentik-service-account",
"subject": None,
"issuer": str(getattr(principal, "issuer", "unavailable")),
"oauthUsername": (
f"svc-mcp-codex-{client.project}-{str(client.id).replace('-', '')[:8]}"
),
"tokenEndpoint": token_endpoint,
"oauthClientId": oauth_client_id,
"steps": [
"Provision the named Authentik service account and app password.",
"Activate this pending Hub client with the exact Authentik user UUID.",
"Install the generated Codex stdio profile using a user-only credential file.",
"Run MCP discovery, one read-only invocation and one cross-project denial.",
],
},
)
@router.get("", response_model=tuple[ClientRecord, ...])
def list_clients(principal: Any = authenticated) -> tuple[ClientRecord, ...]:
authorize(principal, "client.read")
return service.list()
@router.get("/{client_id}/config", response_model=ClientConfig)
def config(
client_id: UUID, kind: ClientKind | None = None, principal: Any = authenticated
) -> ClientConfig:
authorize(principal, "client.read")
try:
return service.config(client_id, kind)
except KeyError as exc:
raise HTTPException(status_code=404, detail="client not found") from exc
@router.post("/{client_id}/bootstrap", response_model=BootstrapHandle)
def bootstrap(client_id: UUID, principal: Any = authenticated) -> BootstrapHandle:
authorize(principal, "client.bootstrap")
try:
return service.bootstrap(client_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail="client not found") from exc
except PermissionError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/{client_id}/disable", response_model=ClientRecord)
def disable(client_id: UUID, principal: Any = authenticated) -> ClientRecord:
authorize(principal, "client.disable")
try:
return service.disable(client_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail="client not found") from exc
@router.post("/{client_id}/revoke", response_model=ClientRecord)
def revoke(client_id: UUID, principal: Any = authenticated) -> ClientRecord:
authorize(principal, "client.revoke")
try:
return service.revoke(client_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail="client not found") from exc
return router
+51
View File
@@ -0,0 +1,51 @@
"""Control-plane service for independently scoped MCP clients."""
from __future__ import annotations
from uuid import UUID
from packages.hub_clients import (
BootstrapHandle,
ClientConfig,
ClientKind,
ClientRecord,
ClientRegistry,
generate_config,
)
class ClientService:
def __init__(self, registry: ClientRegistry | None = None) -> None:
self.registry = registry or ClientRegistry()
def create(
self, *, name: str, project: str, environment: str, kind: ClientKind
) -> tuple[ClientRecord, BootstrapHandle, ClientConfig]:
record = self.registry.create(
name=name, project=project, environment=environment, kind=kind
)
bootstrap = self.registry.issue_bootstrap(record.id)
return record, bootstrap, generate_config(record)
def list(self) -> tuple[ClientRecord, ...]:
return self.registry.list()
def config(self, client_id: UUID, kind: ClientKind | None = None) -> ClientConfig:
record = self.registry.get(client_id)
if record is None:
raise KeyError(client_id)
selected = (
record
if kind is None or kind is record.kind
else record.model_copy(update={"kind": kind})
)
return generate_config(selected)
def disable(self, client_id: UUID) -> ClientRecord:
return self.registry.disable(client_id)
def revoke(self, client_id: UUID) -> ClientRecord:
return self.registry.revoke(client_id)
def bootstrap(self, client_id: UUID) -> BootstrapHandle:
return self.registry.issue_bootstrap(client_id)
@@ -0,0 +1,33 @@
"""Replaceable ContextForge gateway adapter boundary."""
from .client import ContextForgeClient
from .errors import ContextForgeError
from .fake import FakeContextForge
from .models import (
ContextForgeSettings,
GatewayCapabilities,
GatewayDesiredState,
GatewayPrompt,
GatewaySnapshot,
GatewayTestResult,
GatewayTool,
SanitizedGatewayInventory,
VirtualServerDesiredState,
VirtualServerHandle,
)
__all__ = [
"ContextForgeClient",
"ContextForgeError",
"ContextForgeSettings",
"FakeContextForge",
"GatewayCapabilities",
"GatewayDesiredState",
"GatewayPrompt",
"GatewaySnapshot",
"GatewayTestResult",
"GatewayTool",
"SanitizedGatewayInventory",
"VirtualServerDesiredState",
"VirtualServerHandle",
]
+625
View File
@@ -0,0 +1,625 @@
"""Typed, retry-bounded ContextForge HTTP adapter."""
from __future__ import annotations
import hashlib
import json
import os
import time
from base64 import b64encode
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any, Literal, Protocol, cast
from uuid import UUID
import httpx
from .errors import ContextForgeError
from .models import (
ContextForgeSettings,
GatewayCapabilities,
GatewayDesiredState,
GatewayHandle,
GatewayPrompt,
GatewaySnapshot,
GatewayTestResult,
GatewayTool,
SanitizedGatewayInventory,
VirtualServerDesiredState,
VirtualServerHandle,
)
class GatewayAdapter(Protocol):
def health(self) -> dict[str, Any]: ...
def capabilities(self) -> GatewayCapabilities: ...
def list_gateways(
self, *, cursor: str | None = None, limit: int = 50
) -> tuple[GatewaySnapshot, ...]: ...
def ensure_gateway(self, desired: GatewayDesiredState) -> GatewayHandle: ...
def update_gateway(
self, handle: GatewayHandle, desired: GatewayDesiredState
) -> GatewayHandle: ...
def delete_gateway(self, handle: GatewayHandle) -> None: ...
def refresh_gateway(self, handle: GatewayHandle) -> None: ...
def list_gateway_tools(self, handle: GatewayHandle) -> tuple[GatewayTool, ...]: ...
def ensure_canonical_tool_names(
self, tools: tuple[GatewayTool, ...]
) -> tuple[GatewayTool, ...]: ...
def list_gateway_prompts(self, handle: GatewayHandle) -> tuple[GatewayPrompt, ...]: ...
def test_gateway(self, handle: GatewayHandle) -> GatewayTestResult: ...
class ContextForgeClient:
"""ContextForge API client; raw gateway IDs stay inside this adapter."""
def __init__(
self,
settings: ContextForgeSettings,
*,
transport: httpx.BaseTransport | None = None,
environ: Mapping[str, str] | None = None,
) -> None:
self.settings = settings
self._environ = environ if environ is not None else os.environ
self._http = httpx.Client(
base_url=str(settings.base_url).rstrip("/"),
timeout=settings.timeout_seconds,
transport=transport,
)
self._capabilities: GatewayCapabilities | None = None
def close(self) -> None:
self._http.close()
def _auth_headers(self) -> dict[str, str]:
token = self.settings.credential(self._environ)
scheme = self.settings.auth_scheme
if scheme == "Basic":
encoded = b64encode(f"{self.settings.basic_username}:{token}".encode()).decode("ascii")
credential = f"Basic {encoded}"
else:
credential = f"Bearer {token}"
return {"Accept": "application/json", "Authorization": credential}
def authorization_header(self) -> str:
"""Build the outbound credential only at the request boundary."""
return self._auth_headers()["Authorization"]
def _request(
self,
method: str,
path: str,
*,
params: Mapping[str, str | int] | None = None,
json_body: Mapping[str, Any] | None = None,
retryable: bool = False,
) -> Any:
attempts = self.settings.retries if retryable else 0
for attempt in range(attempts + 1):
try:
response = self._http.request(
method,
path,
headers=self._auth_headers(),
params=params,
json=json_body,
)
except httpx.TimeoutException as exc:
if attempt < attempts:
time.sleep(min(0.25 * (attempt + 1), 1.0))
continue
raise ContextForgeError(
"gateway_timeout", "Gateway request timed out.", retryable=True
) from exc
except httpx.HTTPError as exc:
if attempt < attempts:
time.sleep(min(0.25 * (attempt + 1), 1.0))
continue
raise ContextForgeError(
"gateway_unavailable", "Gateway could not be reached.", retryable=True
) from exc
if response.status_code >= 400:
error = ContextForgeError.from_status(response.status_code)
if error.retryable and attempt < attempts:
time.sleep(min(0.25 * (attempt + 1), 1.0))
continue
raise error
if len(response.content) > self.settings.max_response_bytes:
raise ContextForgeError(
"gateway_response_too_large", "Gateway response exceeded the output budget."
)
if not response.content:
return {}
try:
return response.json()
except (ValueError, json.JSONDecodeError) as exc:
raise ContextForgeError(
"gateway_invalid_response", "Gateway returned an invalid response."
) from exc
raise ContextForgeError("gateway_unavailable", "Gateway request failed.", retryable=True)
def health(self) -> dict[str, Any]:
value = self._request("GET", "/health", retryable=True)
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway health response was invalid."
)
return {str(key): value[key] for key in value if key in {"status", "version"}}
def readiness(self) -> dict[str, Any]:
value = self._request("GET", "/ready", retryable=True)
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway readiness response was invalid."
)
return {str(key): value[key] for key in value if key in {"status"}}
def version(self) -> str | None:
value = self._request("GET", "/version", retryable=True)
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway version response was invalid."
)
raw = value.get("version") or value.get("release")
if raw is None and isinstance(value.get("app"), dict):
raw = value["app"].get("version")
return str(raw) if raw is not None else None
def capabilities(self) -> GatewayCapabilities:
if self._capabilities is not None:
return self._capabilities
health_ok = False
readiness_ok = False
version_value: str | None = None
try:
self.health()
health_ok = True
except ContextForgeError:
pass
try:
self.readiness()
readiness_ok = True
except ContextForgeError:
pass
try:
version_value = self.version()
except ContextForgeError:
pass
crud_ok = False
if health_ok:
try:
self.list_gateways(limit=1)
crud_ok = True
except ContextForgeError:
pass
self._capabilities = GatewayCapabilities(
health=health_ok,
readiness=readiness_ok,
version=version_value,
gateway_crud=crud_ok,
gateway_test=health_ok,
)
return self._capabilities
@staticmethod
def _snapshot(payload: Mapping[str, Any]) -> tuple[str, GatewaySnapshot]:
external_id = payload.get("id")
name = payload.get("name") or payload.get("slug")
if (
not isinstance(external_id, str)
or not external_id
or not isinstance(name, str)
or not name
):
raise ContextForgeError(
"gateway_invalid_response", "Gateway resource identity was invalid."
)
snapshot = GatewaySnapshot(
slug=name,
url=str(payload.get("url", "")),
description=str(payload.get("description", ""))[:1000],
transport=str(payload.get("transport", "STREAMABLEHTTP")),
enabled=bool(payload.get("enabled", True)),
status=str(payload.get("status", "unknown")),
passthrough_headers=tuple(
str(item).lower()
for item in payload.get(
"passthroughHeaders", payload.get("passthrough_headers", [])
)
),
)
return external_id, snapshot
def list_gateways(
self, *, cursor: str | None = None, limit: int = 50
) -> tuple[GatewaySnapshot, ...]:
if not 1 <= limit <= 100:
raise ValueError("gateway list limit must be between 1 and 100")
params: dict[str, str | int] = {"limit": limit, "include_pagination": "false"}
if cursor:
params["cursor"] = cursor
value = self._request("GET", "/gateways", params=params, retryable=True)
entities = (
value
if isinstance(value, list)
else value.get("gateways", [])
if isinstance(value, dict)
else []
)
if not isinstance(entities, list):
raise ContextForgeError(
"gateway_invalid_response", "Gateway inventory response was invalid."
)
return tuple(self._snapshot(item)[1] for item in entities if isinstance(item, dict))
def _find(self, slug: str) -> GatewayHandle | None:
value = self._request(
"GET",
"/gateways",
params={"limit": 100, "include_pagination": "false"},
retryable=True,
)
entities = (
value
if isinstance(value, list)
else value.get("gateways", [])
if isinstance(value, dict)
else []
)
for item in entities if isinstance(entities, list) else []:
if isinstance(item, dict) and (item.get("name") or item.get("slug")) == slug:
external_id, snapshot = self._snapshot(item)
return GatewayHandle(external_id=external_id, snapshot=snapshot)
return None
@staticmethod
def _desired_payload(desired: GatewayDesiredState) -> dict[str, Any]:
return {
"name": desired.slug,
"url": str(desired.url),
"description": desired.description,
"transport": desired.transport,
"enabled": desired.enabled,
"passthrough_headers": list(desired.passthrough_headers),
}
def create_gateway(self, desired: GatewayDesiredState) -> GatewayHandle:
value = self._request("POST", "/gateways", json_body=self._desired_payload(desired))
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway create response was invalid."
)
external_id, snapshot = self._snapshot(value)
return GatewayHandle(external_id=external_id, snapshot=snapshot)
def ensure_gateway(self, desired: GatewayDesiredState) -> GatewayHandle:
existing = self._find(desired.slug)
if existing is None:
return self.create_gateway(desired)
# ContextForge can deactivate a gateway after repeated upstream failures
# while leaving its configured ``enabled`` flag set. Treat runtime status
# as part of the desired state so a recovered connector is explicitly
# reactivated instead of retaining a stale, offline tool inventory.
runtime_status = existing.snapshot.status.strip().lower()
if (
existing.snapshot.url != str(desired.url)
or existing.snapshot.description != desired.description
or existing.snapshot.transport != desired.transport
or existing.snapshot.enabled != desired.enabled
or existing.snapshot.passthrough_headers != desired.passthrough_headers
or runtime_status not in {"active", "healthy", "online", "ready"}
):
return self.update_gateway(existing, desired)
return existing
def update_gateway(self, handle: GatewayHandle, desired: GatewayDesiredState) -> GatewayHandle:
value = self._request(
"PUT",
f"/gateways/{handle.external_id}",
json_body=self._desired_payload(desired),
)
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway update response was invalid."
)
external_id, snapshot = self._snapshot(value)
return GatewayHandle(external_id=external_id, snapshot=snapshot)
def delete_gateway(self, handle: GatewayHandle) -> None:
self._request("DELETE", f"/gateways/{handle.external_id}")
def list_gateway_tools(self, handle: GatewayHandle) -> tuple[GatewayTool, ...]:
value = self._request(
"GET",
"/tools",
params={
"gateway_id": handle.external_id,
"limit": 0,
"include_pagination": "false",
},
retryable=True,
)
entities = (
value
if isinstance(value, list)
else value.get("tools", [])
if isinstance(value, dict)
else []
)
tools: list[GatewayTool] = []
for item in entities if isinstance(entities, list) else []:
if not isinstance(item, dict):
continue
external_id = item.get("id")
name = (
item.get("originalName")
or item.get("original_name")
or item.get("customName")
or item.get("name")
)
published_name = item.get("customName") or item.get("custom_name") or item.get("name")
if isinstance(external_id, str) and isinstance(name, str):
if not isinstance(published_name, str):
published_name = name
raw_input_schema = item.get("inputSchema", item.get("input_schema"))
tools.append(
GatewayTool(
external_id=external_id,
name=name,
published_name=published_name,
input_schema=(
raw_input_schema if isinstance(raw_input_schema, dict) else None
),
)
)
return tuple(sorted(tools, key=lambda item: item.name))
def ensure_canonical_tool_names(
self, tools: tuple[GatewayTool, ...]
) -> tuple[GatewayTool, ...]:
"""Publish connector names verbatim instead of ContextForge prefixes.
ContextForge makes discovered tool names globally unique by prefixing the
gateway slug. The Hub catalog and ChatGPT app expose the connector's
canonical MCP names, so leaving that prefix in place makes every routed
``tools/call`` miss even though discovery itself is healthy.
"""
canonical: list[GatewayTool] = []
for tool in tools:
if tool.published_name == tool.name:
canonical.append(tool)
continue
value = self._request(
"PUT", f"/tools/{tool.external_id}", json_body={"customName": tool.name}
)
if not isinstance(value, dict) or value.get("customName") != tool.name:
raise ContextForgeError(
"gateway_invalid_response", "Gateway tool rename was not applied."
)
canonical.append(tool.model_copy(update={"published_name": tool.name}))
return tuple(canonical)
def refresh_gateway(self, handle: GatewayHandle) -> None:
value = self._request(
"POST",
f"/gateways/{handle.external_id}/tools/refresh",
params={"include_resources": "true", "include_prompts": "true"},
)
if not isinstance(value, dict) or value.get("success") is not True:
raise ContextForgeError(
"gateway_invalid_response", "Gateway refresh did not complete successfully."
)
def list_gateway_prompts(self, handle: GatewayHandle) -> tuple[GatewayPrompt, ...]:
value = self._request(
"GET",
"/prompts",
params={
"gateway_id": handle.external_id,
"limit": 0,
"include_pagination": "false",
},
retryable=True,
)
entities = (
value
if isinstance(value, list)
else value.get("prompts", [])
if isinstance(value, dict)
else []
)
prompts: list[GatewayPrompt] = []
for item in entities if isinstance(entities, list) else []:
if not isinstance(item, dict):
continue
external_id = item.get("id")
name = (
item.get("originalName")
or item.get("original_name")
or item.get("customName")
or item.get("name")
)
if isinstance(external_id, str) and isinstance(name, str):
prompts.append(GatewayPrompt(external_id=external_id, name=name))
return tuple(sorted(prompts, key=lambda item: item.name))
@staticmethod
def _server_handle(payload: Mapping[str, Any]) -> VirtualServerHandle:
external_id = payload.get("id")
name = payload.get("name")
tool_ids = payload.get(
"associated_tool_ids",
payload.get("associatedToolIds", payload.get("associated_tools", [])),
)
prompt_ids = payload.get(
"associated_prompt_ids",
payload.get("associatedPromptIds", payload.get("associated_prompts", [])),
)
if (
not isinstance(external_id, str)
or not isinstance(name, str)
or not isinstance(tool_ids, list)
or not isinstance(prompt_ids, list)
):
raise ContextForgeError(
"gateway_invalid_response", "Virtual server response was invalid."
)
normalized = tuple(
str(item.get("id")) if isinstance(item, dict) else str(item) for item in tool_ids
)
normalized_prompts = tuple(
str(item.get("id")) if isinstance(item, dict) else str(item) for item in prompt_ids
)
return VirtualServerHandle(
external_id=external_id,
name=name,
associated_tool_ids=tuple(sorted(normalized)),
associated_prompt_ids=tuple(sorted(normalized_prompts)),
enabled=bool(payload.get("enabled", True)),
visibility=cast(
Literal["public", "private", "team"],
str(payload.get("visibility", "private")),
),
)
def _find_server(self, desired: VirtualServerDesiredState) -> VirtualServerHandle | None:
value = self._request(
"GET",
"/servers",
params={"limit": 0, "include_pagination": "false", "include_inactive": "true"},
retryable=True,
)
entities = (
value
if isinstance(value, list)
else value.get("servers", [])
if isinstance(value, dict)
else []
)
for item in entities if isinstance(entities, list) else []:
if not isinstance(item, dict):
continue
candidate = self._server_handle(item)
try:
same_id = UUID(candidate.external_id) == desired.external_id
except ValueError:
same_id = False
if same_id or candidate.name == desired.name:
return candidate
return None
@staticmethod
def _server_payload(desired: VirtualServerDesiredState, *, include_id: bool) -> dict[str, Any]:
payload: dict[str, Any] = {
"name": desired.name,
"description": desired.description,
"associated_tools": list(desired.associated_tool_ids),
"associated_prompts": list(desired.associated_prompt_ids),
"visibility": desired.visibility,
}
if include_id:
payload["id"] = str(desired.external_id)
return payload
def ensure_virtual_server(self, desired: VirtualServerDesiredState) -> VirtualServerHandle:
existing = self._find_server(desired)
if existing is None:
server_payload = self._server_payload(desired, include_id=True)
value = self._request("POST", "/servers", json_body={"server": server_payload})
elif (
existing.name != desired.name
or existing.associated_tool_ids != desired.associated_tool_ids
or existing.associated_prompt_ids != desired.associated_prompt_ids
or existing.visibility != desired.visibility
):
value = self._request(
"PUT",
f"/servers/{existing.external_id}",
json_body=self._server_payload(desired, include_id=False),
)
else:
return existing
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Virtual server response was invalid."
)
return self._server_handle(value)
def test_gateway(self, handle: GatewayHandle) -> GatewayTestResult:
try:
value = self._request("GET", f"/gateways/{handle.external_id}/test", retryable=True)
except ContextForgeError as exc:
if exc.code == "gateway_not_found":
return GatewayTestResult(
reachable=False,
status="unsupported",
detail="Gateway test endpoint unavailable.",
)
raise
if not isinstance(value, dict):
raise ContextForgeError(
"gateway_invalid_response", "Gateway test response was invalid."
)
return GatewayTestResult(
reachable=bool(value.get("reachable", value.get("status") in {"ok", "healthy"})),
status=str(value.get("status", "unknown")),
detail=str(value.get("detail", ""))[:256],
)
def test_gateway_by_slug(self, slug: str) -> GatewayTestResult:
"""Probe one registered gateway without exposing its external ID."""
handle = self._find(slug)
if handle is None:
return GatewayTestResult(
reachable=False,
status="missing",
detail="Connector gateway is not registered.",
)
return self.test_gateway(handle)
def inventory(self) -> SanitizedGatewayInventory:
capabilities = self.capabilities()
value = self._request(
"GET",
"/gateways",
params={"limit": 100, "include_pagination": "false"},
retryable=True,
)
entities = (
value
if isinstance(value, list)
else value.get("gateways", [])
if isinstance(value, dict)
else []
)
inventory: list[dict[str, Any]] = []
for item in entities if isinstance(entities, list) else []:
if not isinstance(item, dict):
continue
external_id = str(item.get("id", ""))
inventory.append(
{
"slug": str(item.get("name") or item.get("slug") or ""),
"external_id_digest": hashlib.sha256(external_id.encode()).hexdigest(),
"url": str(item.get("url", "")),
"status": str(item.get("status", "unknown")),
"enabled": bool(item.get("enabled", True)),
}
)
return SanitizedGatewayInventory(
generated_at=datetime.now(UTC), capabilities=capabilities, gateways=tuple(inventory)
)
@@ -0,0 +1,41 @@
"""Stable, sanitized gateway adapter errors."""
from __future__ import annotations
class ContextForgeError(Exception):
"""An adapter error safe for logs and public service error mapping."""
def __init__(
self,
code: str,
message: str,
*,
status_code: int | None = None,
retryable: bool = False,
) -> None:
super().__init__(message)
self.code = code
self.status_code = status_code
self.retryable = retryable
@classmethod
def from_status(cls, status_code: int) -> ContextForgeError:
mappings = {
401: ("gateway_unauthorized", "Gateway authorization failed.", False),
403: ("gateway_forbidden", "Gateway authorization was denied.", False),
404: ("gateway_not_found", "Gateway resource was not found.", False),
400: ("gateway_invalid_request", "Gateway request was invalid.", False),
422: ("gateway_invalid_request", "Gateway request was invalid.", False),
409: ("gateway_conflict", "Gateway resource conflicts with existing state.", False),
429: ("gateway_rate_limited", "Gateway rate limit reached.", True),
}
code, message, retryable = mappings.get(
status_code,
(
"gateway_unavailable" if status_code >= 500 else "gateway_request_failed",
"Gateway request failed.",
status_code >= 500,
),
)
return cls(code, message, status_code=status_code, retryable=retryable)
+426
View File
@@ -0,0 +1,426 @@
"""Deterministic in-memory ContextForge-compatible target."""
from __future__ import annotations
import json
from copy import deepcopy
from typing import Any
from uuid import UUID, uuid4
import httpx
class FakeContextForge:
"""Fake REST gateway for offline adapter and reconciliation tests."""
def __init__(self) -> None:
self.gateways: dict[str, dict[str, Any]] = {}
self.tools: dict[str, list[dict[str, Any]]] = {}
self.prompts: dict[str, list[dict[str, Any]]] = {}
self.servers: dict[str, dict[str, Any]] = {}
self.requests: list[tuple[str, str, dict[str, str]]] = []
self.fail_next: int | None = None
_gateway_tools = {
"itworx-stitch": (
"stitch.project.list",
"stitch.project.get",
"stitch.project.create",
"stitch.screen.list",
"stitch.screen.get",
"stitch.screen.generate",
"stitch.screen.edit",
"stitch.screen.variants",
),
"itworx-knowledge": (
"knowledge.document.search",
"knowledge.document.get",
"knowledge.source.list",
"knowledge.index.status",
"knowledge.adr.list",
"knowledge.project.summary",
),
"itworx-gitea": (
"gitea.capabilities.get",
"gitea.connection.check",
"gitea.selftest.run",
"gitea.repository.list",
"gitea.repository.get",
"gitea.repository.context",
"gitea.repository.health",
"gitea.branchprotection.get",
"gitea.branchprotection.preview",
"gitea.branch.list",
"gitea.commit.list",
"gitea.commit.get",
"gitea.ref.compare",
"gitea.release.list",
"gitea.issue.list",
"gitea.issue.search",
"gitea.code.search",
"search",
"fetch",
"gitea.file.read",
"gitea.tree.list",
"gitea.changeset.preview",
"gitea.validation.get",
"gitea.validation.failure.get",
"gitea.validation.list",
"gitea.change.status",
"gitea.change.review",
"gitea.change.reconcile",
"gitea.workspace.status",
"gitea.portfolio.health",
"gitea.portfolio.governance.preview",
"gitea.portfolio.governance.verify",
"gitea.orchestration.plan",
"gitea.pullrequest.list",
"gitea.pullrequest.get",
"gitea.pullrequest.feedback",
"gitea.commitstatus.list",
"gitea.repository.create",
"gitea.branchprotection.ensure",
"gitea.portfolio.governance.apply",
"gitea.branch.create",
"gitea.issue.create",
"gitea.issue.update",
"gitea.issue.comment.create",
"gitea.file.create",
"gitea.file.update",
"gitea.file.delete",
"gitea.pullrequest.create",
"gitea.pullrequest.update",
"gitea.pullrequest.close",
"gitea.pullrequest.validation.ensure",
"gitea.pullrequest.sync",
"gitea.pullrequest.merge",
"gitea.branch.delete",
"gitea.changeset.apply",
"gitea.validation.run",
"gitea.workspace.run",
"gitea.orchestration.start",
),
"itworx-unraid": (
"unraid.host.status",
"unraid.storage.list",
"unraid.network.list",
"unraid.container.list",
"unraid.container.inspect",
"unraid.container.health",
"unraid.container.logs",
"unraid.project.summary",
),
"itworx-mobilityops": (
"mobilityops.operations.summary",
"mobilityops.attention.list",
"mobilityops.vehicle.get",
"mobilityops.knowledge.search",
),
"itworx-projectbrain": (
"projectbrain.project.list",
"projectbrain.repository.list",
"projectbrain.portfolio.get",
"projectbrain.portfolio.repository.list",
"projectbrain.portfolio.repository.reconcile",
"projectbrain.portfolio.repository.exclusion.set",
"projectbrain.repository.search",
"projectbrain.repository.fetch",
"projectbrain.context.get",
"projectbrain.workspace.render",
"projectbrain.modules.get",
"projectbrain.brainstorm.save",
"projectbrain.knowledge.save",
"projectbrain.documentation.save",
"projectbrain.memory.save",
"projectbrain.contextpack.create",
"projectbrain.feedback.record",
"projectbrain.delivery.list",
"projectbrain.delivery.get",
"projectbrain.delivery.create",
"projectbrain.delivery.update",
"projectbrain.repository.tree",
"projectbrain.repository.file",
"projectbrain.repository.branches",
"projectbrain.repository.pullrequests",
"projectbrain.delivery.start",
"projectbrain.delivery.file.write",
"projectbrain.delivery.file.delete",
"projectbrain.delivery.changeset.apply",
"projectbrain.delivery.validation.record",
"projectbrain.delivery.validation.runs",
"projectbrain.delivery.validation.sync",
"projectbrain.delivery.complete",
"projectbrain.delivery.merge",
"projectbrain.analysis.start",
"projectbrain.analysis.list",
"projectbrain.analysis.status",
"projectbrain.analysis.results",
"projectbrain.analysis.cancel",
"projectbrain.analysis.render",
"projectbrain.finding.list",
"projectbrain.finding.get",
"projectbrain.finding.issue.sync",
"projectbrain.finding.investigation.start",
"projectbrain.finding.investigation.get",
"projectbrain.finding.investigation.save",
"projectbrain.finding.suppress",
"projectbrain.finding.delivery.create",
"projectbrain.finding.verify",
"projectbrain.autonomy.projects.list",
"projectbrain.autonomy.executor.heartbeat",
"projectbrain.autonomy.state.get",
"projectbrain.autonomy.configure",
"projectbrain.autonomy.roadmap.import",
"projectbrain.autonomy.resume",
"projectbrain.autonomy.work.claim",
"projectbrain.autonomy.work.report",
),
"itworx-appops": (
"server.summary.get",
"server.application.list",
"server.container.list",
"server.safety.findings",
"server.action.list",
"server.operation.preview",
"server.audit.search",
"server.knowledge.query",
"server.operation.request",
"server.operation.status",
"server.operation.execute",
"server.job.status",
"server.job.cancel",
"server.ci.runners.status",
"server.project.list",
"server.project.reconcile",
"server.deployment.preflight",
"server.deployment.capabilities",
"server.deployment.enrollment.preflight",
"server.deployment.target.enroll",
"server.deployment.enrollment.status",
"server.deployment.apply",
"server.deployment.status",
"server.deployment.verify",
"server.deployment.rollback",
),
"itworx-opencode": (
"opencode.capabilities.get",
"opencode.models.list",
"opencode.workspace.ensure",
"opencode.job.preview",
"opencode.job.start",
"opencode.jobs.list",
"opencode.job.status",
"opencode.job.result",
"opencode.job.cancel",
"opencode.delivery.control.repositories.list",
"opencode.delivery.control.plan",
"opencode.delivery.control.start",
"opencode.delivery.control.jobs.list",
"opencode.delivery.control.status",
"opencode.delivery.control.result",
"opencode.delivery.control.retry",
"opencode.delivery.control.cancel",
),
"itworx-opencode-readonly": (
"opencode.analysis.capabilities.get",
"opencode.analysis.models.list",
"opencode.analysis.start",
"opencode.analysis.jobs.list",
"opencode.analysis.status",
"opencode.analysis.result",
"opencode.delivery.repositories.list",
"opencode.delivery.jobs.list",
"opencode.delivery.status",
"opencode.delivery.result",
),
}
_gateway_prompts = {
"itworx-knowledge": ("project-context-pack", "pre-change-impact-review"),
"itworx-gitea": (
"repository-engineer",
"repository-change-review",
"repository-maintenance",
"portfolio-engineer",
"release-readiness",
),
"itworx-unraid": ("production-incident-triage", "deployment-health-review"),
"itworx-mobilityops": ("fleet-operations-morning-brief",),
}
@staticmethod
def _tool_payload(tool_name: str) -> dict[str, Any]:
payload: dict[str, Any] = {"id": str(uuid4()), "name": tool_name}
if tool_name == "gitea.repository.create":
payload["inputSchema"] = {
"type": "object",
"properties": {
field: {"type": field_type}
for field, field_type in (
("name", "string"),
("description", "string"),
("private", "boolean"),
("autoInit", "boolean"),
("defaultBranch", "string"),
)
},
"required": ["name", "description", "private", "autoInit", "defaultBranch"],
}
payload["inputSchema"]["properties"]["autoInit"]["const"] = True
return payload
def transport(self) -> httpx.MockTransport:
return httpx.MockTransport(self._handle)
def _response(self, request: httpx.Request, status: int, payload: Any) -> httpx.Response:
return httpx.Response(status, json=payload, request=request)
def _handle(self, request: httpx.Request) -> httpx.Response:
self.requests.append(
(
request.method,
request.url.path,
{
key: "<redacted>" if key.lower() == "authorization" else value
for key, value in request.headers.items()
},
)
)
if self.fail_next is not None:
status = self.fail_next
self.fail_next = None
return self._response(request, status, {"error": "simulated"})
path = request.url.path
if path.startswith("/v1/"):
path = path[3:]
if request.method == "GET" and path == "/health":
return self._response(request, 200, {"status": "healthy", "version": "1.0.6"})
if request.method == "GET" and path == "/ready":
return self._response(request, 200, {"status": "ready"})
if request.method == "GET" and path == "/version":
return self._response(request, 200, {"version": "1.0.6"})
if path == "/gateways":
if request.method == "GET":
return self._response(
request, 200, {"gateways": list(self.gateways.values()), "nextCursor": None}
)
if request.method == "POST":
payload = json.loads(request.content)
name = str(payload["name"])
if any(item["name"] == name for item in self.gateways.values()):
return self._response(request, 409, {"error": "conflict"})
gateway = {
"id": f"gateway-{uuid4().hex}",
"name": name,
"url": payload["url"],
"description": payload.get("description", ""),
"transport": payload.get("transport", "STREAMABLEHTTP"),
"enabled": payload.get("enabled", True),
"status": "active",
"passthrough_headers": payload.get("passthrough_headers", []),
}
self.gateways[gateway["id"]] = gateway
self.tools[gateway["id"]] = [
self._tool_payload(tool_name) for tool_name in self._gateway_tools.get(name, ())
]
self.prompts[gateway["id"]] = [
{"id": str(uuid4()), "name": prompt_name}
for prompt_name in self._gateway_prompts.get(name, ())
]
return self._response(request, 201, deepcopy(gateway))
if path == "/tools" and request.method == "GET":
gateway_id = request.url.params.get("gateway_id", "")
return self._response(request, 200, deepcopy(self.tools.get(gateway_id, [])))
if path == "/prompts" and request.method == "GET":
gateway_id = request.url.params.get("gateway_id", "")
return self._response(request, 200, deepcopy(self.prompts.get(gateway_id, [])))
if path == "/servers":
if request.method == "GET":
return self._response(request, 200, deepcopy(list(self.servers.values())))
if request.method == "POST":
payload = json.loads(request.content)
payload = payload.get("server", payload)
external_id = UUID(str(payload["id"])).hex if payload.get("id") else uuid4().hex
server = {
"id": external_id,
"name": payload["name"],
"associated_tool_ids": payload.get("associated_tools", []),
"associated_prompt_ids": payload.get("associated_prompts", []),
"enabled": True,
"visibility": payload.get("visibility", "private"),
}
self.servers[external_id] = server
return self._response(request, 201, deepcopy(server))
parts = path.strip("/").split("/")
if len(parts) == 2 and parts[0] == "tools" and request.method == "PUT":
payload = json.loads(request.content)
for gateway_tools in self.tools.values():
tool = next((item for item in gateway_tools if item.get("id") == parts[1]), None)
if tool is not None:
if "customName" in payload:
tool["customName"] = payload["customName"]
return self._response(request, 200, deepcopy(tool))
return self._response(request, 404, {"error": "not_found"})
if len(parts) >= 2 and parts[0] == "gateways":
stored = self.gateways.get(parts[1])
if stored is None:
return self._response(request, 404, {"error": "not_found"})
if len(parts) == 3 and parts[2] == "test" and request.method == "GET":
return self._response(request, 200, {"reachable": True, "status": "healthy"})
if len(parts) == 4 and parts[2:] == ["tools", "refresh"] and request.method == "POST":
old_tools = len(self.tools.get(parts[1], []))
old_prompts = len(self.prompts.get(parts[1], []))
self.tools[parts[1]] = [
self._tool_payload(tool_name)
for tool_name in self._gateway_tools.get(str(stored["name"]), ())
]
self.prompts[parts[1]] = [
{"id": str(uuid4()), "name": prompt_name}
for prompt_name in self._gateway_prompts.get(str(stored["name"]), ())
]
return self._response(
request,
200,
{
"success": True,
"tools_added": max(0, len(self.tools[parts[1]]) - old_tools),
"tools_removed": 0,
"prompts_added": max(0, len(self.prompts[parts[1]]) - old_prompts),
"prompts_removed": 0,
},
)
if request.method == "GET":
return self._response(request, 200, deepcopy(stored))
if request.method == "PUT":
payload = json.loads(request.content)
stored.update(
{
"name": payload["name"],
"url": payload["url"],
"description": payload.get("description", ""),
"transport": payload.get("transport", "STREAMABLEHTTP"),
"enabled": payload.get("enabled", True),
"status": "active" if payload.get("enabled", True) else "inactive",
"passthrough_headers": payload.get("passthrough_headers", []),
}
)
return self._response(request, 200, deepcopy(stored))
if request.method == "DELETE":
del self.gateways[parts[1]]
return self._response(request, 204, {})
if len(parts) == 2 and parts[0] == "servers":
stored_server = self.servers.get(parts[1])
if stored_server is None:
return self._response(request, 404, {"error": "not_found"})
if request.method == "PUT":
payload = json.loads(request.content)
stored_server.update(
{
"name": payload["name"],
"associated_tool_ids": payload.get("associated_tools", []),
"associated_prompt_ids": payload.get("associated_prompts", []),
"visibility": payload.get("visibility", "private"),
}
)
return self._response(request, 200, deepcopy(stored_server))
return self._response(request, 404, {"error": "not_found"})
+234
View File
@@ -0,0 +1,234 @@
"""Hub-owned gateway adapter contracts without public gateway IDs."""
from __future__ import annotations
import os
import re
from collections.abc import Mapping
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal, cast
from uuid import UUID
from pydantic import (
AnyHttpUrl,
BaseModel,
ConfigDict,
Field,
TypeAdapter,
field_validator,
model_validator,
)
from packages.hub_runtime import SecretFileError, read_secret_file
from .errors import ContextForgeError
class ContextForgeSettings(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
base_url: AnyHttpUrl
auth_token_env: str | None = Field(default=None, min_length=1, max_length=256)
auth_token_file: Path | None = None
auth_scheme: Literal["Bearer", "Basic"] = "Bearer"
basic_username: str = Field(default="itworx-contextforge-admin", min_length=1, max_length=256)
timeout_seconds: float = Field(default=5.0, gt=0, le=30)
retries: int = Field(default=2, ge=0, le=5)
max_response_bytes: int = Field(default=4 * 1024 * 1024, ge=1024, le=4 * 1024 * 1024)
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> ContextForgeSettings:
values = environ if environ is not None else os.environ
base_url = values.get("HUB_CONTEXTFORGE_URL")
if not base_url:
raise ContextForgeError("gateway_configuration_missing", "Gateway URL is unavailable.")
try:
return cls(
base_url=TypeAdapter(AnyHttpUrl).validate_python(base_url),
auth_token_env=(
values.get("HUB_CONTEXTFORGE_TOKEN_ENV")
if values.get("HUB_CONTEXTFORGE_TOKEN_FILE")
else values.get("HUB_CONTEXTFORGE_TOKEN_ENV", "HUB_CONTEXTFORGE_TOKEN")
),
auth_token_file=(
Path(values["HUB_CONTEXTFORGE_TOKEN_FILE"])
if values.get("HUB_CONTEXTFORGE_TOKEN_FILE")
else None
),
auth_scheme=cast(
Literal["Bearer", "Basic"],
values.get("HUB_CONTEXTFORGE_AUTH_SCHEME", "Bearer"),
),
basic_username=values.get(
"HUB_CONTEXTFORGE_BASIC_USERNAME", "itworx-contextforge-admin"
),
timeout_seconds=float(values.get("HUB_CONTEXTFORGE_TIMEOUT_SECONDS", "5")),
retries=int(values.get("HUB_CONTEXTFORGE_RETRIES", "2")),
)
except (TypeError, ValueError) as exc:
raise ContextForgeError(
"gateway_configuration_invalid", "Gateway configuration is invalid."
) from exc
@model_validator(mode="after")
def exactly_one_credential_source(self) -> ContextForgeSettings:
if bool(self.auth_token_env) == bool(self.auth_token_file):
raise ValueError("configure exactly one ContextForge credential source")
return self
def credential(self, environ: Mapping[str, str] | None = None) -> str:
if self.auth_token_file:
try:
return read_secret_file(self.auth_token_file)
except SecretFileError as exc:
raise ContextForgeError(
"gateway_credentials_missing", "Gateway authorization is unavailable."
) from exc
value = (environ or os.environ).get(self.auth_token_env or "")
if not value:
raise ContextForgeError(
"gateway_credentials_missing", "Gateway authorization is unavailable."
)
return value
class GatewayDesiredState(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
slug: str = Field(
min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
)
url: AnyHttpUrl
description: str = Field(default="", max_length=1000)
transport: Literal["STREAMABLEHTTP", "SSE", "STDIO", "WEBSOCKET"] = "STREAMABLEHTTP"
enabled: bool = True
passthrough_headers: tuple[str, ...] = ()
@field_validator("passthrough_headers")
@classmethod
def validate_passthrough_headers(cls, value: tuple[str, ...]) -> tuple[str, ...]:
allowed = {
"x-itworx-project",
"x-itworx-environment",
"x-itworx-client-id",
"x-itworx-trace-id",
"x-itworx-identity-assertion",
}
normalized = tuple(dict.fromkeys(item.strip().lower() for item in value))
if any(not re.fullmatch(r"[a-z0-9-]{1,64}", item) for item in normalized):
raise ValueError("invalid passthrough header")
if not set(normalized).issubset(allowed):
raise ValueError("gateway passthrough header is not approved")
return normalized
class GatewaySnapshot(BaseModel):
"""Publicly safe gateway state; `external_id` is intentionally absent."""
model_config = ConfigDict(extra="forbid", frozen=True)
slug: str
url: str
description: str
transport: str
enabled: bool
status: str
passthrough_headers: tuple[str, ...] = ()
class GatewayHandle(BaseModel):
"""Adapter-internal handle; do not serialize into public Hub contracts."""
model_config = ConfigDict(extra="forbid", frozen=True)
external_id: str = Field(min_length=1, max_length=256)
snapshot: GatewaySnapshot
class GatewayTool(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
external_id: str = Field(min_length=1, max_length=256)
name: str = Field(min_length=1, max_length=256)
published_name: str = Field(min_length=1, max_length=256)
input_schema: dict[str, Any] | None = None
class GatewayPrompt(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
external_id: str = Field(min_length=1, max_length=256)
name: str = Field(min_length=1, max_length=256)
class VirtualServerDesiredState(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
external_id: UUID
name: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
description: str = Field(max_length=1000)
associated_tool_ids: tuple[str, ...] = Field(min_length=1)
associated_prompt_ids: tuple[str, ...] = ()
# ContextForge's service token is intentionally public-only. The virtual
# server is reachable solely on the private Docker network; the public Hub
# dataplane remains the OAuth, binding, policy and rate-limit boundary.
visibility: Literal["public"] = "public"
@field_validator("associated_tool_ids")
@classmethod
def unique_tool_ids(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if len(set(value)) != len(value) or any(not item or len(item) > 256 for item in value):
raise ValueError("virtual server tool IDs must be unique bounded values")
return tuple(sorted(value))
@field_validator("associated_prompt_ids")
@classmethod
def unique_prompt_ids(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if len(set(value)) != len(value) or any(not item or len(item) > 256 for item in value):
raise ValueError("virtual server prompt IDs must be unique bounded values")
return tuple(sorted(value))
class VirtualServerHandle(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
external_id: str = Field(min_length=1, max_length=256)
name: str = Field(min_length=1, max_length=63)
associated_tool_ids: tuple[str, ...]
associated_prompt_ids: tuple[str, ...] = ()
enabled: bool
visibility: Literal["public", "private", "team"]
class GatewayTestResult(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
reachable: bool
status: str
detail: str = Field(default="", max_length=256)
class GatewayCapabilities(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
health: bool
readiness: bool
version: str | None
gateway_crud: bool
gateway_test: bool
class SanitizedGatewayInventory(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
generated_at: datetime
capabilities: GatewayCapabilities
gateways: tuple[dict[str, Any], ...]
@field_validator("generated_at")
@classmethod
def require_aware_timestamp(cls, value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("inventory timestamp must be timezone-aware")
return value.astimezone(UTC)
+5
View File
@@ -0,0 +1,5 @@
"""Control-plane projection for registered Gitea repositories."""
from .registry import GiteaRegistration, registration_from_project
__all__ = ["GiteaRegistration", "registration_from_project"]
+55
View File
@@ -0,0 +1,55 @@
"""Build a Gitea connector projection from scoped Hub configuration."""
from __future__ import annotations
from packages.connector_kit import GiteaError, GiteaSettings, RegisteredRepository
from packages.hub_core.domain import Connector, Project
class GiteaRegistration:
"""Immutable connector settings plus the exact project repository allowlist."""
def __init__(
self, settings: GiteaSettings, repositories: tuple[RegisteredRepository, ...]
) -> None:
self.settings = settings
self.repositories = repositories
def registration_from_project(project: Project, connector: Connector) -> GiteaRegistration:
if connector.type != "gitea" or not connector.enabled:
raise GiteaError(
"GITEA_CONNECTOR_NOT_ENABLED", "Gitea connector is not enabled for this project"
)
if connector.project_id != project.id or project.repository_provider != "gitea":
raise GiteaError(
"GITEA_REPOSITORY_NOT_REGISTERED", "project repository is not configured for Gitea"
)
if not connector.endpoint or not connector.credential_reference:
raise GiteaError(
"GITEA_CONNECTOR_MISCONFIGURED", "Gitea endpoint or secret reference is missing"
)
settings_values = {
"timeout_seconds": connector.configuration.get(
"timeout_seconds", connector.configuration.get("requestTimeoutSeconds", 15)
),
"max_page_size": connector.configuration.get(
"max_page_size", connector.configuration.get("maxPageSize", 100)
),
}
settings = GiteaSettings(
base_url=connector.endpoint,
token_reference=connector.credential_reference,
**settings_values,
)
if not project.repository_owner or not project.repository_name:
raise GiteaError("GITEA_REPOSITORY_NOT_REGISTERED", "project has no registered repository")
repository = RegisteredRepository(
repository_ref=f"{project.slug}-repository",
project_id=project.id,
owner=project.repository_owner,
name=project.repository_name,
source_uri=f"itworx-git://{project.slug}/{project.slug}-repository",
private=project.classification != "public",
)
return GiteaRegistration(settings, (repository,))
@@ -0,0 +1,6 @@
"""Control-plane knowledge persistence and refresh jobs."""
from .jobs import KnowledgeRefreshScheduler
from .store import SqlAlchemyKnowledgeIndex
__all__ = ["KnowledgeRefreshScheduler", "SqlAlchemyKnowledgeIndex"]
+63
View File
@@ -0,0 +1,63 @@
"""Bounded background refresh orchestration for registered knowledge sources."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Iterable
from uuid import UUID
from packages.connector_kit.knowledge import (
IndexReport,
InMemoryKnowledgeIndex,
)
from packages.connector_kit.knowledge import (
KnowledgeRefreshJob as SingleRefreshJob,
)
class KnowledgeRefreshScheduler:
"""Cooperative scheduler; it never accepts a caller-supplied physical path."""
def __init__(
self,
index: InMemoryKnowledgeIndex,
source_ids: Iterable[UUID],
*,
interval_seconds: float = 300.0,
on_refresh: Callable[[tuple[IndexReport, ...]], Awaitable[None]] | None = None,
) -> None:
if not 1 <= interval_seconds <= 86_400:
raise ValueError("refresh interval is outside bounds")
self.index = index
self.source_ids = tuple(source_ids)
self.interval_seconds = interval_seconds
self.on_refresh = on_refresh
self._task: asyncio.Task[None] | None = None
self._stop = asyncio.Event()
async def run_once(self) -> tuple[IndexReport, ...]:
reports = SingleRefreshJob(self.index, self.source_ids).run_once()
if self.on_refresh is not None:
await self.on_refresh(reports)
return reports
async def start(self) -> None:
if self._task is not None and not self._task.done():
return
self._stop.clear()
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
self._stop.set()
task = self._task
self._task = None
if task is not None:
await task
async def _loop(self) -> None:
while not self._stop.is_set():
await self.run_once()
try:
await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds)
except TimeoutError:
continue
+233
View File
@@ -0,0 +1,233 @@
"""Hub persistence adapter for project knowledge and PostgreSQL lexical search."""
from __future__ import annotations
import re
from collections.abc import Iterable
from datetime import UTC, datetime
from typing import Any
from uuid import NAMESPACE_URL, UUID, uuid5
from sqlalchemy import delete, desc, func, select
from sqlalchemy.orm import Session
from packages.connector_kit.knowledge import (
DocumentRecord,
RegisteredSource,
SearchHit,
SearchPage,
SourceType,
)
from packages.connector_kit.redaction import safe_snippet
from ..persistence.models import KnowledgeChunkRow, KnowledgeDocumentRow, KnowledgeSourceRow
class SqlAlchemyKnowledgeIndex:
"""Scoped read/write index adapter; all rows remain Hub-owned and project-filtered."""
def __init__(self, session: Session, *, workspace_id: UUID, project_id: UUID) -> None:
self.session = session
self.workspace_id = workspace_id
self.project_id = project_id
def sync_source(self, source: RegisteredSource, documents: Iterable[DocumentRecord]) -> None:
if source.project_id != self.project_id:
raise ValueError("source project does not match index scope")
source_row = self.session.get(KnowledgeSourceRow, source.source_id)
if source_row is None:
source_row = KnowledgeSourceRow(
id=source.source_id,
workspace_id=self.workspace_id,
project_id=self.project_id,
slug=source.source_ref,
source_type=source.source_type.value,
uri=source.uri_prefix,
visibility="project",
state="registered" if source.enabled else "disabled",
configuration={
"rootRegistered": True,
"repositoryRef": source.repository_ref,
"repositoryCommit": source.repository_commit,
},
last_indexed_at=datetime.now(UTC),
)
self.session.add(source_row)
self.session.flush()
else:
if (
source_row.workspace_id != self.workspace_id
or source_row.project_id != self.project_id
):
raise ValueError("source row is outside index scope")
source_row.state = "registered" if source.enabled else "disabled"
source_row.last_indexed_at = datetime.now(UTC)
active = {
document.external_ref: document
for document in documents
if document.project_id == self.project_id and document.state == "registered"
}
existing = {
row.external_ref: row
for row in self.session.scalars(
select(KnowledgeDocumentRow).where(
KnowledgeDocumentRow.workspace_id == self.workspace_id,
KnowledgeDocumentRow.project_id == self.project_id,
KnowledgeDocumentRow.source_id == source.source_id,
)
).all()
}
for external_ref, document in active.items():
row = existing.get(external_ref)
metadata = {
"uri": document.uri,
"path": document.external_ref,
"sourceType": document.source_type.value,
"mimeType": document.mime_type,
"sourceRevision": document.source_revision,
"repositoryRef": document.repository_ref,
"repositoryCommit": document.repository_commit,
"modifiedAt": document.modified_at.isoformat(),
}
if row is None:
row = KnowledgeDocumentRow(
id=stable_document_id(document.document_id),
workspace_id=self.workspace_id,
project_id=self.project_id,
source_id=source.source_id,
external_ref=external_ref,
title=document.title,
content_hash=document.content_hash,
document_version=document.version,
state="registered",
content_bytes=document.content_bytes,
record_metadata=metadata,
)
self.session.add(row)
self.session.flush()
else:
row.title = document.title
row.content_hash = document.content_hash
row.document_version = document.version
row.state = "registered"
row.content_bytes = document.content_bytes
row.record_metadata = metadata
self.session.execute(
delete(KnowledgeChunkRow).where(KnowledgeChunkRow.document_id == row.id)
)
self.session.add_all(
[
KnowledgeChunkRow(
id=hashlib_id(document.document_id, chunk.index),
workspace_id=self.workspace_id,
project_id=self.project_id,
document_id=row.id,
chunk_index=chunk.index,
text=chunk.text,
content_hash=chunk.content_hash,
token_count=len(re.findall(r"\w+", chunk.text)),
record_metadata={},
)
for chunk in document.chunks
]
)
for external_ref, row in existing.items():
if external_ref in active:
continue
row.state = "deleted"
row.content_bytes = 0
row.document_version += 1
row.record_metadata = {"tombstone": True, "path": external_ref}
self.session.execute(
delete(KnowledgeChunkRow).where(KnowledgeChunkRow.document_id == row.id)
)
self.session.flush()
def search(self, query: str, *, limit: int = 20) -> SearchPage:
if not query.strip() or not 1 <= limit <= 20:
raise ValueError("query or limit is invalid")
document = KnowledgeDocumentRow
chunk = KnowledgeChunkRow
if self.session.bind is not None and self.session.bind.dialect.name == "postgresql":
vector = func.to_tsvector("simple", chunk.text)
tsquery = func.plainto_tsquery("simple", query)
score = (
func.ts_rank_cd(vector, tsquery)
+ func.similarity(document.title, query)
+ func.similarity(chunk.text, query)
)
statement = (
select(document, chunk, score.label("score"))
.join(chunk, chunk.document_id == document.id)
.where(
document.workspace_id == self.workspace_id,
document.project_id == self.project_id,
document.state == "registered",
vector.op("@@")(tsquery)
| (func.similarity(document.title, query) > 0.1)
| (func.similarity(chunk.text, query) > 0.1),
)
.order_by(desc("score"), document.id, chunk.chunk_index)
.limit(limit)
)
else:
statement = (
select(document, chunk)
.join(chunk, chunk.document_id == document.id)
.where(
document.workspace_id == self.workspace_id,
document.project_id == self.project_id,
document.state == "registered",
)
.order_by(document.id, chunk.chunk_index)
)
hits: list[SearchHit] = []
terms = [term.casefold() for term in re.findall(r"[\w-]{2,}", query)]
for result in self.session.execute(statement).all():
row = result[0]
chunk_row = result[1]
raw_score = (
float(result[2])
if len(result) > 2
else float(
sum(chunk_row.text.casefold().count(term) for term in terms)
+ row.title.casefold().count(query.casefold()) * 4
)
)
if raw_score <= 0:
continue
metadata: dict[str, Any] = (
row.record_metadata if isinstance(row.record_metadata, dict) else {}
)
hits.append(
SearchHit(
uri=str(metadata.get("uri", "")),
title=row.title,
path=row.external_ref,
source_type=SourceType(metadata.get("sourceType", "documentation")),
source_revision=str(metadata.get("sourceRevision", "registered")),
repository_ref=metadata.get("repositoryRef"),
repository_commit=metadata.get("repositoryCommit"),
content_hash=row.content_hash,
modified_at=datetime.fromisoformat(str(metadata.get("modifiedAt")))
if metadata.get("modifiedAt")
else datetime.now(UTC),
snippet=safe_snippet(chunk_row.text),
score=raw_score,
truncated=len(chunk_row.text.encode("utf-8")) > 2000,
)
)
hits.sort(key=lambda hit: (-hit.score, hit.uri, hit.path))
return SearchPage(items=tuple(hits[:limit]), next_cursor=None, truncated=False)
def stable_document_id(document_id: str) -> UUID:
return uuid5(NAMESPACE_URL, "knowledge-document:" + document_id)
def hashlib_id(document_id: str, chunk_index: int) -> UUID:
"""Stable UUID for a persisted chunk row."""
from uuid import uuid5
return uuid5(UUID(document_id[:32]), str(chunk_index))
@@ -0,0 +1,6 @@
"""Hub-owned persistence boundary."""
from .dataplane_source import SqlAlchemyAliasSource
from .db import Base, create_engine_from_url, session_factory
__all__ = ["Base", "SqlAlchemyAliasSource", "create_engine_from_url", "session_factory"]
@@ -0,0 +1,133 @@
"""Read-only alias projection from Hub-owned persistence."""
from __future__ import annotations
import re
from collections import defaultdict
from typing import Any, cast
from urllib.parse import urlsplit
from pydantic import AnyHttpUrl, TypeAdapter
from sqlalchemy import select
from sqlalchemy.orm import Session
from packages.hub_contracts.dataplane import (
SUPPORTED_PROTOCOLS,
AliasRecord,
ClientBinding,
EnvironmentName,
)
from .models import (
ClientBindingRow,
ClientRow,
EnvironmentRow,
ProjectRow,
VirtualServerRow,
)
class SqlAlchemyAliasSource:
"""Projects only enabled Hub virtual servers and active explicit bindings."""
def __init__(self, session: Session, *, workspace_id: Any, gateway_base_url: str) -> None:
self.session = session
self.workspace_id = workspace_id
base = TypeAdapter(AnyHttpUrl).validate_python(gateway_base_url)
internal_contextforge = (
base.scheme == "http"
and base.host in {"contextforge", "contextforge-control"}
and base.port == 4444
and (base.path or "").rstrip("/") == "/mcp"
)
if (
base.username is not None
or base.password is not None
or (base.scheme != "https" and not internal_contextforge)
):
raise ValueError("gateway URL must be HTTPS or the fixed internal gateway")
self.gateway_base_url = str(base).rstrip("/")
self.internal_contextforge = internal_contextforge
def aliases(self) -> tuple[AliasRecord, ...]:
projects = {
row.id: row
for row in self.session.scalars(
select(ProjectRow).where(ProjectRow.workspace_id == self.workspace_id)
).all()
}
environments = {
row.id: row
for row in self.session.scalars(
select(EnvironmentRow).where(EnvironmentRow.workspace_id == self.workspace_id)
).all()
}
clients = {
row.id: row
for row in self.session.scalars(
select(ClientRow).where(ClientRow.workspace_id == self.workspace_id)
).all()
}
bindings_by_server: dict[Any, list[ClientBinding]] = defaultdict(list)
for row in self.session.scalars(
select(ClientBindingRow).where(
ClientBindingRow.workspace_id == self.workspace_id,
ClientBindingRow.enabled.is_(True),
)
).all():
client = clients.get(row.client_id)
if client is None or client.status != "active":
continue
bindings_by_server[row.virtual_server_id].append(
ClientBinding(
client_id=client.id,
issuer=client.issuer,
subject=client.subject,
enabled=True,
)
)
values: list[AliasRecord] = []
for server in self.session.scalars(
select(VirtualServerRow).where(
VirtualServerRow.workspace_id == self.workspace_id,
VirtualServerRow.enabled.is_(True),
VirtualServerRow.health_state.in_(("healthy", "degraded")),
)
).all():
if self.internal_contextforge and not server.gateway_external_id:
continue
project = projects.get(server.project_id)
environment = environments.get(server.environment_id)
if project is None or environment is None:
continue
if environment.name not in {"dev", "test", "staging", "prod"}:
continue
metadata = server.record_metadata
endpoint_alias = metadata.get("endpointAlias") if isinstance(metadata, dict) else None
if not isinstance(endpoint_alias, str):
endpoint_alias = f"/mcp/{project.slug}/{environment.name}"
try:
if self.internal_contextforge:
external_id = server.gateway_external_id or ""
if not re.fullmatch(r"[0-9a-f]{32}", external_id):
raise ValueError("invalid ContextForge server route identifier")
parsed = urlsplit(self.gateway_base_url)
upstream = f"{parsed.scheme}://{parsed.netloc}/servers/{external_id}/mcp"
else:
upstream = f"{self.gateway_base_url}{endpoint_alias}"
values.append(
AliasRecord(
project_slug=project.slug,
workspace_id=self.workspace_id,
project_id=project.id,
environment=cast(EnvironmentName, environment.name),
alias=endpoint_alias,
upstream_url=TypeAdapter(AnyHttpUrl).validate_python(upstream),
enabled=True,
protocol_revisions=tuple(SUPPORTED_PROTOCOLS),
bindings=tuple(bindings_by_server.get(server.id, ())),
)
)
except ValueError:
continue
return tuple(sorted(values, key=lambda item: (item.project_slug, item.environment)))
+40
View File
@@ -0,0 +1,40 @@
"""SQLAlchemy engine and session construction for the Hub database only."""
from __future__ import annotations
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
class Base(DeclarativeBase):
"""Declarative root for Hub-owned tables; ContextForge is never imported."""
def utc_now() -> datetime:
return datetime.now(UTC)
def create_engine_from_url(database_url: str, **kwargs: Any) -> Engine:
"""Create an engine with safe SQLite test defaults and explicit pool settings."""
options: dict[str, Any] = {"future": True, "pool_pre_ping": True, **kwargs}
if database_url.startswith("sqlite"):
options.setdefault("connect_args", {"check_same_thread": False})
engine = create_engine(database_url, **options)
if database_url.startswith("sqlite"):
def enable_sqlite_foreign_keys(dbapi_connection: Any, _: Any) -> None:
dbapi_connection.execute("PRAGMA foreign_keys=ON")
event.listen(engine, "connect", enable_sqlite_foreign_keys)
return engine
def session_factory(engine: Engine) -> Callable[[], Session]:
"""Return an explicit session factory; no hidden global session is used."""
return sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, class_=Session)
@@ -0,0 +1,118 @@
"""One-time, idempotent provisioning of the Fleet Ops demo client identity.
Docs: docs/superpowers/specs/2026-08-05-mobilityops-demo-token-design.md
"""
from __future__ import annotations
from dataclasses import dataclass
from uuid import NAMESPACE_URL, UUID, uuid5
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import (
ClientBindingRow,
ClientRow,
EnvironmentRow,
ProjectRow,
VirtualServerRow,
WorkspaceRow,
)
DEMO_CLIENT_ISSUER = "itworx-mcp-hub:demo-token"
DEMO_CLIENT_SUBJECT = "mobilityops-demo"
DEMO_CLIENT_ID: UUID = uuid5(NAMESPACE_URL, "https://hub.example.com/demo-client/mobilityops")
@dataclass(frozen=True)
class DemoClientResult:
client_id: UUID
binding_id: UUID
def provision_demo_client(
session: Session,
*,
workspace_slug: str = "itworx",
project_slug: str = "mobilityops",
environment_name: str = "prod",
) -> DemoClientResult:
workspace = session.scalar(select(WorkspaceRow).where(WorkspaceRow.slug == workspace_slug))
if workspace is None:
raise ValueError("demo client workspace is not registered")
project = session.scalar(
select(ProjectRow).where(
ProjectRow.workspace_id == workspace.id,
ProjectRow.slug == project_slug,
ProjectRow.status == "active",
)
)
if project is None:
raise ValueError("demo client project is not registered")
environment = session.scalar(
select(EnvironmentRow).where(
EnvironmentRow.workspace_id == workspace.id,
EnvironmentRow.project_id == project.id,
EnvironmentRow.name == environment_name,
EnvironmentRow.status == "active",
)
)
if environment is None:
raise ValueError("demo client environment is not registered")
# The demo client must bind to exactly one, unambiguous, externally-routed
# virtual server. Taking "the first match" would silently bind the demo
# identity to an arbitrary server if the project/environment ever grows a
# second one.
servers = session.scalars(
select(VirtualServerRow).where(
VirtualServerRow.workspace_id == workspace.id,
VirtualServerRow.project_id == project.id,
VirtualServerRow.environment_id == environment.id,
VirtualServerRow.enabled.is_(True),
VirtualServerRow.gateway_external_id.is_not(None),
)
).all()
if len(servers) != 1:
raise ValueError(
f"demo client virtual server is not uniquely determined "
f"({len(servers)} enabled, externally-routed servers found for "
f"{project_slug}/{environment_name}; expected exactly 1)"
)
server = servers[0]
client = session.get(ClientRow, DEMO_CLIENT_ID)
if client is None:
client = ClientRow(
id=DEMO_CLIENT_ID,
workspace_id=workspace.id,
subject=DEMO_CLIENT_SUBJECT,
issuer=DEMO_CLIENT_ISSUER,
client_type="mcp",
status="active",
record_metadata={"managedBy": "demo-token-provisioning"},
)
session.add(client)
session.flush()
binding = session.scalar(
select(ClientBindingRow).where(
ClientBindingRow.client_id == DEMO_CLIENT_ID,
ClientBindingRow.virtual_server_id == server.id,
)
)
if binding is None:
binding = ClientBindingRow(
workspace_id=workspace.id,
project_id=project.id,
client_id=DEMO_CLIENT_ID,
virtual_server_id=server.id,
enabled=True,
)
session.add(binding)
session.flush()
elif not binding.enabled:
binding.enabled = True
session.flush()
return DemoClientResult(client_id=client.id, binding_id=binding.id)
@@ -0,0 +1,169 @@
"""Idempotent provisioning for real Authentik-backed MCP machine clients."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import NAMESPACE_URL, UUID, uuid5
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import (
ClientBindingRow,
ClientRow,
EnvironmentRow,
ProjectRow,
VirtualServerRow,
WorkspaceRow,
)
@dataclass(frozen=True)
class MachineClientResult:
client_id: UUID
binding_id: UUID
endpoint: str
def provision_machine_client(
session: Session,
*,
issuer: str,
subject: str,
oauth_username: str,
name: str,
project_slug: str,
environment_name: str = "prod",
workspace_slug: str = "itworx",
public_host: str = "mcp.example.com",
client_id: UUID | None = None,
) -> MachineClientResult:
"""Bind one validated Authentik subject to exactly one external virtual server.
The caller obtains ``subject`` from a validated Authentik token or the exact
Authentik user UUID. Credentials are deliberately not accepted here.
"""
issuer = issuer.strip()
subject = subject.strip()
oauth_username = oauth_username.strip()
name = name.strip()
if not issuer.startswith("https://") or not subject or not oauth_username or not name:
raise ValueError("machine client identity is incomplete")
workspace = session.scalar(select(WorkspaceRow).where(WorkspaceRow.slug == workspace_slug))
if workspace is None:
raise ValueError("machine client workspace is not registered")
project = session.scalar(
select(ProjectRow).where(
ProjectRow.workspace_id == workspace.id,
ProjectRow.slug == project_slug,
ProjectRow.status == "active",
)
)
if project is None:
raise ValueError("machine client project is not registered")
environment = session.scalar(
select(EnvironmentRow).where(
EnvironmentRow.workspace_id == workspace.id,
EnvironmentRow.project_id == project.id,
EnvironmentRow.name == environment_name,
EnvironmentRow.status == "active",
)
)
if environment is None:
raise ValueError("machine client environment is not registered")
servers = session.scalars(
select(VirtualServerRow).where(
VirtualServerRow.workspace_id == workspace.id,
VirtualServerRow.project_id == project.id,
VirtualServerRow.environment_id == environment.id,
VirtualServerRow.enabled.is_(True),
VirtualServerRow.gateway_external_id.is_not(None),
)
).all()
if len(servers) != 1:
raise ValueError(
"machine client virtual server is not uniquely determined "
f"({len(servers)} externally-routed servers found)"
)
server = servers[0]
deterministic_id = uuid5(NAMESPACE_URL, f"{issuer}\n{subject}")
principal = session.scalar(
select(ClientRow).where(
ClientRow.workspace_id == workspace.id,
ClientRow.issuer == issuer,
ClientRow.subject == subject,
)
)
selected_id = client_id or deterministic_id
by_id = session.get(ClientRow, selected_id)
if principal is not None and principal.id != selected_id:
raise ValueError("machine client principal is already managed by another identity")
if (
client_id is None
and by_id is not None
and (by_id.issuer != issuer or by_id.subject != subject)
):
raise ValueError("machine client deterministic identity collision")
if client_id is not None and by_id is not None:
existing_metadata = by_id.record_metadata if isinstance(by_id.record_metadata, dict) else {}
if (
existing_metadata.get("managedBy") != "runtime-onboarding"
or existing_metadata.get("project") != project_slug
or existing_metadata.get("environment") != environment_name
or by_id.status != "pending"
):
raise ValueError("existing Hub client is not an activatable pending identity")
client = principal or by_id
metadata = {
"managedBy": "authentik-machine-provisioning",
"identityProvisioned": True,
"name": name,
"project": project_slug,
"environment": environment_name,
"kind": "stdio-bridge",
"oauthUsername": oauth_username,
"credentialReference": f"authentik://service-account/{oauth_username}",
"operatorDisabled": False,
}
if client is None:
client = ClientRow(
id=selected_id,
workspace_id=workspace.id,
subject=subject,
issuer=issuer,
client_type="mcp",
status="active",
record_metadata=metadata,
)
session.add(client)
session.flush()
else:
if client.status == "revoked":
raise ValueError("revoked machine client cannot be reprovisioned")
client.issuer = issuer
client.subject = subject
client.status = "active"
client.record_metadata = metadata
session.flush()
bindings = session.scalars(
select(ClientBindingRow).where(ClientBindingRow.client_id == client.id)
).all()
foreign = [item for item in bindings if item.virtual_server_id != server.id]
if foreign:
raise ValueError("machine client already has a different project binding")
binding = next(iter(bindings), None)
if binding is None:
binding = ClientBindingRow(
workspace_id=workspace.id,
project_id=project.id,
client_id=client.id,
virtual_server_id=server.id,
enabled=True,
)
session.add(binding)
else:
binding.enabled = True
session.flush()
endpoint = f"https://{public_host}/mcp/{project_slug}/{environment_name}"
return MachineClientResult(client_id=client.id, binding_id=binding.id, endpoint=endpoint)
+550
View File
@@ -0,0 +1,550 @@
"""Hub-owned SQLAlchemy rows and constraints.
The model intentionally does not contain gateway tables or foreign keys to the
ContextForge database. Cross-system identity is represented only by opaque
external IDs and is reconciled by a later adapter.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID, uuid4
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base, utc_now
JSON_TYPE = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
UUID_TYPE = sa.Uuid(as_uuid=True)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), default=utc_now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), default=utc_now, onupdate=utc_now, nullable=False
)
revision: Mapped[int] = mapped_column(sa.Integer, default=1, nullable=False)
class WorkspaceRow(TimestampMixin, Base):
__tablename__ = "workspaces"
__table_args__ = (sa.UniqueConstraint("slug", name="uq_workspaces_slug"),)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
slug: Mapped[str] = mapped_column(sa.String(63), nullable=False)
name: Mapped[str] = mapped_column(sa.String(160), nullable=False)
status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="active")
class ProjectRow(TimestampMixin, Base):
__tablename__ = "projects"
__table_args__ = (
sa.UniqueConstraint("workspace_id", "slug", name="uq_projects_workspace_slug"),
sa.UniqueConstraint("workspace_id", "id", name="uq_projects_workspace_id"),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(sa.ForeignKey("workspaces.id"), nullable=False)
slug: Mapped[str] = mapped_column(sa.String(63), nullable=False)
name: Mapped[str] = mapped_column(sa.String(160), nullable=False)
description: Mapped[str | None] = mapped_column(sa.String(1000))
status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="active")
repository_provider: Mapped[str | None] = mapped_column(sa.String(32))
repository_owner: Mapped[str | None] = mapped_column(sa.String(120))
repository_name: Mapped[str | None] = mapped_column(sa.String(120))
classification: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="internal")
owner_metadata: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
class EnvironmentRow(TimestampMixin, Base):
__tablename__ = "environments"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id"],
["projects.workspace_id", "projects.id"],
name="fk_environments_project_scope",
),
sa.UniqueConstraint("workspace_id", "project_id", "id", name="uq_environments_scope_id"),
sa.UniqueConstraint("project_id", "name", name="uq_environments_project_name"),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
name: Mapped[str] = mapped_column(sa.String(32), nullable=False)
classification: Mapped[str] = mapped_column(sa.String(32), nullable=False)
base_url: Mapped[str | None] = mapped_column(sa.String(2048))
status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="active")
class ConnectorRow(TimestampMixin, Base):
__tablename__ = "connectors"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id"],
["projects.workspace_id", "projects.id"],
name="fk_connectors_project_scope",
),
sa.ForeignKeyConstraint(
["workspace_id", "project_id", "environment_id"],
["environments.workspace_id", "environments.project_id", "environments.id"],
name="fk_connectors_environment_scope",
),
sa.UniqueConstraint(
"workspace_id",
"project_id",
"environment_scope",
"slug",
name="uq_connectors_scope_slug",
),
sa.CheckConstraint(
"health_state in ("
"'unconfigured','checking','healthy','degraded','unavailable','misconfigured','disabled')",
name="ck_connectors_health_state",
),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
environment_id: Mapped[UUID | None] = mapped_column()
environment_scope: Mapped[str] = mapped_column(
sa.String(36), nullable=False, default="__project__"
)
type: Mapped[str] = mapped_column(sa.String(64), nullable=False)
slug: Mapped[str] = mapped_column(sa.String(63), nullable=False)
endpoint: Mapped[str | None] = mapped_column(sa.String(2048))
credential_reference: Mapped[str | None] = mapped_column(sa.String(512))
configuration: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False)
health_state: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="unconfigured")
last_check_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True))
latency_ms: Mapped[int | None] = mapped_column(sa.Integer)
stable_error_code: Mapped[str | None] = mapped_column(sa.String(128))
sanitized_error: Mapped[str | None] = mapped_column(sa.String(1000))
class RiskClassRow(Base):
__tablename__ = "risk_classes"
code: Mapped[str] = mapped_column(sa.String(64), primary_key=True)
description: Mapped[str] = mapped_column(sa.String(500), nullable=False)
allows_writes: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False)
max_output_bytes: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=65536)
class CapabilityRow(TimestampMixin, Base):
__tablename__ = "capabilities"
__table_args__ = (
sa.ForeignKeyConstraint(
["risk_class"], ["risk_classes.code"], name="fk_capabilities_risk_class"
),
sa.UniqueConstraint("canonical_name", "version", name="uq_capabilities_name_version"),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
canonical_name: Mapped[str] = mapped_column(sa.String(120), nullable=False)
version: Mapped[str] = mapped_column(sa.String(32), nullable=False)
description: Mapped[str] = mapped_column(sa.String(1000), nullable=False)
risk_class: Mapped[str] = mapped_column(sa.String(64), nullable=False)
enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
class ToolDefinitionRow(TimestampMixin, Base):
__tablename__ = "tool_definitions"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id"],
["projects.workspace_id", "projects.id"],
name="fk_tools_project_scope",
),
sa.ForeignKeyConstraint(["connector_id"], ["connectors.id"], name="fk_tools_connector"),
sa.UniqueConstraint("canonical_name", "version", name="uq_tools_name_version"),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
connector_id: Mapped[UUID] = mapped_column(nullable=False)
canonical_name: Mapped[str] = mapped_column(sa.String(120), nullable=False)
version: Mapped[str] = mapped_column(sa.String(32), nullable=False)
input_schema: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
output_schema: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
timeout_ms: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=10000)
output_limit_bytes: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=65536)
rate_limit_per_minute: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=60)
redaction_profile: Mapped[str] = mapped_column(sa.String(64), nullable=False, default="default")
lifecycle_status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="active")
class VirtualServerRow(TimestampMixin, Base):
__tablename__ = "virtual_servers"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id"],
["projects.workspace_id", "projects.id"],
name="fk_virtual_servers_project_scope",
),
sa.ForeignKeyConstraint(
["workspace_id", "project_id", "environment_id"],
["environments.workspace_id", "environments.project_id", "environments.id"],
name="fk_virtual_servers_environment_scope",
),
sa.UniqueConstraint("workspace_id", "project_id", "id", name="uq_virtual_servers_scope_id"),
sa.UniqueConstraint("slug", name="uq_virtual_servers_slug"),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
environment_id: Mapped[UUID] = mapped_column(nullable=False)
slug: Mapped[str] = mapped_column(sa.String(63), nullable=False)
gateway_external_id: Mapped[str | None] = mapped_column(sa.String(256))
desired_hash: Mapped[str] = mapped_column(sa.String(128), nullable=False)
enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False)
protocol: Mapped[str] = mapped_column(sa.String(64), nullable=False, default="streamable-http")
health_state: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="unconfigured")
record_metadata: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
class VirtualServerCapabilityRow(Base):
__tablename__ = "virtual_server_capabilities"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id", "virtual_server_id"],
["virtual_servers.workspace_id", "virtual_servers.project_id", "virtual_servers.id"],
name="fk_vs_capabilities_server_scope",
),
sa.ForeignKeyConstraint(
["capability_id"], ["capabilities.id"], name="fk_vs_capabilities_capability"
),
sa.UniqueConstraint(
"virtual_server_id", "capability_id", name="uq_vs_capabilities_binding"
),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
virtual_server_id: Mapped[UUID] = mapped_column(nullable=False)
capability_id: Mapped[UUID] = mapped_column(nullable=False)
desired_state_source: Mapped[str] = mapped_column(
sa.String(64), nullable=False, default="catalog"
)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), default=utc_now, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), default=utc_now, nullable=False
)
class ClientRow(TimestampMixin, Base):
__tablename__ = "clients"
__table_args__ = (
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], name="fk_clients_workspace"),
sa.UniqueConstraint("workspace_id", "id", name="uq_clients_workspace_id"),
sa.UniqueConstraint("workspace_id", "issuer", "subject", name="uq_clients_principal"),
sa.CheckConstraint(
"status in ('pending','active','disabled','revoked')", name="ck_clients_status"
),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
subject: Mapped[str] = mapped_column(sa.String(256), nullable=False)
issuer: Mapped[str] = mapped_column(sa.String(512), nullable=False)
client_type: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="mcp")
status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="pending")
revoked_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True))
last_seen_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True))
record_metadata: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
class ClientBindingRow(TimestampMixin, Base):
__tablename__ = "client_bindings"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id"],
["projects.workspace_id", "projects.id"],
name="fk_client_bindings_project_scope",
),
sa.ForeignKeyConstraint(
["workspace_id", "client_id"],
["clients.workspace_id", "clients.id"],
name="fk_client_bindings_client_scope",
),
sa.ForeignKeyConstraint(
["workspace_id", "project_id", "virtual_server_id"],
["virtual_servers.workspace_id", "virtual_servers.project_id", "virtual_servers.id"],
name="fk_client_bindings_server_scope",
),
sa.UniqueConstraint(
"client_id", "virtual_server_id", "capability_pack", name="uq_client_bindings"
),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
client_id: Mapped[UUID] = mapped_column(nullable=False)
virtual_server_id: Mapped[UUID] = mapped_column(nullable=False)
capability_pack: Mapped[str | None] = mapped_column(sa.String(128))
constraints: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False)
class PolicyRow(TimestampMixin, Base):
__tablename__ = "policies"
__table_args__ = (
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], name="fk_policies_workspace"),
sa.ForeignKeyConstraint(
["workspace_id", "project_id"],
["projects.workspace_id", "projects.id"],
name="fk_policies_project_scope",
),
sa.UniqueConstraint("workspace_id", "slug", name="uq_policies_workspace_slug"),
sa.CheckConstraint(
"decision in ('allow','allow_if_conditions','deny','deny_and_alert','enforce')",
name="ck_policies_decision",
),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID | None] = mapped_column()
slug: Mapped[str] = mapped_column(sa.String(63), nullable=False)
priority: Mapped[int] = mapped_column(sa.Integer, nullable=False)
description: Mapped[str] = mapped_column(sa.String(1000), nullable=False)
match_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
conditions_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
decision: Mapped[str] = mapped_column(sa.String(32), nullable=False)
terminal: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False)
source: Mapped[str] = mapped_column(sa.String(64), nullable=False, default="catalog")
enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
class PolicyDecisionRow(Base):
__tablename__ = "policy_decisions"
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(sa.ForeignKey("workspaces.id"), nullable=False)
project_id: Mapped[UUID | None] = mapped_column()
policy_id: Mapped[UUID | None] = mapped_column(sa.ForeignKey("policies.id"))
decision: Mapped[str] = mapped_column(sa.String(32), nullable=False)
matched_rule_ids: Mapped[list[str]] = mapped_column(JSON_TYPE, default=list, nullable=False)
decision_trace: Mapped[list[dict[str, Any]]] = mapped_column(
JSON_TYPE, default=list, nullable=False
)
reason_code: Mapped[str | None] = mapped_column(sa.String(128))
trace_id: Mapped[str] = mapped_column(sa.String(128), nullable=False)
retention_until: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), default=utc_now, nullable=False
)
class ReconcileRunRow(TimestampMixin, Base):
__tablename__ = "reconcile_runs"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id"],
["projects.workspace_id", "projects.id"],
name="fk_reconcile_runs_project_scope",
),
sa.CheckConstraint(
"status in ("
"'created','planning','planned','applying','verifying','succeeded','invalid','failed')",
name="ck_reconcile_runs_status",
),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
desired_hash: Mapped[str] = mapped_column(sa.String(128), nullable=False)
current_hash: Mapped[str | None] = mapped_column(sa.String(128))
plan_hash: Mapped[str | None] = mapped_column(sa.String(128))
status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="created")
planned_changes: Mapped[list[dict[str, Any]]] = mapped_column(
JSON_TYPE, default=list, nullable=False
)
applied_changes: Mapped[list[dict[str, Any]]] = mapped_column(
JSON_TYPE, default=list, nullable=False
)
verification: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
started_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True))
class OperationRow(TimestampMixin, Base):
__tablename__ = "operations"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id"],
["projects.workspace_id", "projects.id"],
name="fk_operations_project_scope",
),
sa.ForeignKeyConstraint(
["workspace_id", "project_id", "environment_id"],
["environments.workspace_id", "environments.project_id", "environments.id"],
name="fk_operations_environment_scope",
),
sa.UniqueConstraint("workspace_id", "project_id", "id", name="uq_operations_project_id"),
sa.CheckConstraint(
"state in ("
"'requested','planned','approval_required','approved','executing','verifying','succeeded','rejected','failed','rollback_pending','rolled_back','rollback_failed')",
name="ck_operations_state",
),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
environment_id: Mapped[UUID] = mapped_column(nullable=False)
operation_type: Mapped[str] = mapped_column(sa.String(64), nullable=False)
target_ref: Mapped[str] = mapped_column(sa.String(512), nullable=False)
plan_hash: Mapped[str] = mapped_column(sa.String(128), nullable=False)
state: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="requested")
requested_by: Mapped[str] = mapped_column(sa.String(256), nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True))
verification: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
class ApprovalRow(TimestampMixin, Base):
__tablename__ = "approvals"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id", "operation_id"],
["operations.workspace_id", "operations.project_id", "operations.id"],
name="fk_approvals_operation_scope",
),
sa.CheckConstraint(
"state in ('pending','approved','rejected','expired')", name="ck_approvals_state"
),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
operation_id: Mapped[UUID] = mapped_column(nullable=False)
plan_hash: Mapped[str] = mapped_column(sa.String(128), nullable=False)
state: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="pending")
actor: Mapped[str] = mapped_column(sa.String(256), nullable=False)
reason: Mapped[str | None] = mapped_column(sa.String(1000))
expires_at: Mapped[datetime] = mapped_column(sa.DateTime(timezone=True), nullable=False)
approved_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True))
class AuditEventRow(Base):
__tablename__ = "audit_events"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id"], ["workspaces.id"], name="fk_audit_events_workspace"
),
sa.Index("ix_audit_events_occurred_at_id", "occurred_at", "id"),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
occurred_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), default=utc_now, nullable=False
)
trace_id: Mapped[str] = mapped_column(sa.String(128), nullable=False)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID | None] = mapped_column()
environment_id: Mapped[UUID | None] = mapped_column()
event_type: Mapped[str] = mapped_column(sa.String(128), nullable=False)
principal_issuer: Mapped[str | None] = mapped_column(sa.String(512))
principal_subject: Mapped[str | None] = mapped_column(sa.String(256))
client_id: Mapped[UUID | None] = mapped_column()
virtual_server_id: Mapped[UUID | None] = mapped_column()
tool_name: Mapped[str | None] = mapped_column(sa.String(256))
policy_decision: Mapped[str | None] = mapped_column(sa.String(32))
matched_rule_ids: Mapped[list[str]] = mapped_column(JSON_TYPE, default=list, nullable=False)
policy_trace: Mapped[list[dict[str, Any]]] = mapped_column(
JSON_TYPE, default=list, nullable=False
)
arguments_hash: Mapped[str | None] = mapped_column(sa.String(128))
sanitized_arguments: Mapped[dict[str, Any]] = mapped_column(
JSON_TYPE, default=dict, nullable=False
)
duration_ms: Mapped[int | None] = mapped_column(sa.Integer)
outcome: Mapped[str] = mapped_column(sa.String(32), nullable=False)
error_code: Mapped[str | None] = mapped_column(sa.String(128))
retention_until: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True))
operation_id: Mapped[UUID | None] = mapped_column()
class KnowledgeSourceRow(TimestampMixin, Base):
__tablename__ = "knowledge_sources"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id"],
["projects.workspace_id", "projects.id"],
name="fk_knowledge_sources_project_scope",
),
sa.UniqueConstraint("project_id", "slug", name="uq_knowledge_sources_project_slug"),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
slug: Mapped[str] = mapped_column(sa.String(63), nullable=False)
source_type: Mapped[str] = mapped_column(sa.String(64), nullable=False)
uri: Mapped[str] = mapped_column(sa.String(2048), nullable=False)
visibility: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="project")
state: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="registered")
configuration: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
last_indexed_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True))
class KnowledgeDocumentRow(TimestampMixin, Base):
__tablename__ = "knowledge_documents"
__table_args__ = (
sa.ForeignKeyConstraint(
["workspace_id", "project_id"],
["projects.workspace_id", "projects.id"],
name="fk_knowledge_documents_project_scope",
),
sa.ForeignKeyConstraint(
["source_id"], ["knowledge_sources.id"], name="fk_knowledge_documents_source"
),
sa.UniqueConstraint("source_id", "external_ref", name="uq_knowledge_documents_source_ref"),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
source_id: Mapped[UUID] = mapped_column(nullable=False)
external_ref: Mapped[str] = mapped_column(sa.String(1024), nullable=False)
title: Mapped[str] = mapped_column(sa.String(500), nullable=False)
content_hash: Mapped[str] = mapped_column(sa.String(128), nullable=False)
document_version: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=1)
state: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="registered")
content_bytes: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
record_metadata: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
class KnowledgeChunkRow(TimestampMixin, Base):
__tablename__ = "knowledge_chunks"
__table_args__ = (
sa.ForeignKeyConstraint(
["document_id"], ["knowledge_documents.id"], name="fk_knowledge_chunks_document"
),
sa.UniqueConstraint("document_id", "chunk_index", name="uq_knowledge_chunks_index"),
)
id: Mapped[UUID] = mapped_column(UUID_TYPE, primary_key=True, default=uuid4)
workspace_id: Mapped[UUID] = mapped_column(nullable=False)
project_id: Mapped[UUID] = mapped_column(nullable=False)
document_id: Mapped[UUID] = mapped_column(nullable=False)
chunk_index: Mapped[int] = mapped_column(sa.Integer, nullable=False)
text: Mapped[str] = mapped_column(sa.Text, nullable=False)
content_hash: Mapped[str] = mapped_column(sa.String(128), nullable=False)
token_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
record_metadata: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
@@ -0,0 +1,599 @@
"""Workspace/project-scoped repositories.
Every repository constructor requires a ``Scope``. Project-owned repositories
reject workspace-only scopes, and every query applies the scope predicate before
loading or mutating a row. There is intentionally no generic unrestricted
query or audit update method.
"""
from __future__ import annotations
import re
from datetime import UTC, datetime
from typing import Any, TypeVar, cast
from uuid import UUID
from pydantic import BaseModel
from sqlalchemy import Select, select, update
from sqlalchemy.orm import Session
from packages.hub_core.domain import (
Approval,
AuditEvent,
Capability,
Client,
ClientBinding,
Connector,
Environment,
KnowledgeChunk,
KnowledgeDocument,
KnowledgeSource,
Operation,
Policy,
PolicyDecision,
Project,
ReconcileRun,
RiskClass,
Scope,
ToolDefinition,
VirtualServer,
VirtualServerCapability,
Workspace,
)
from packages.hub_core.errors import (
AuditMutationError,
ConcurrentUpdateError,
DomainError,
InvalidTransition,
MissingProjectScope,
)
from packages.hub_core.state_machines import transition
from .models import (
ApprovalRow,
AuditEventRow,
CapabilityRow,
ClientBindingRow,
ClientRow,
ConnectorRow,
EnvironmentRow,
KnowledgeChunkRow,
KnowledgeDocumentRow,
KnowledgeSourceRow,
OperationRow,
PolicyDecisionRow,
PolicyRow,
ProjectRow,
ReconcileRunRow,
RiskClassRow,
ToolDefinitionRow,
VirtualServerCapabilityRow,
VirtualServerRow,
WorkspaceRow,
)
RowT = TypeVar("RowT")
DomainT = TypeVar("DomainT", bound=BaseModel)
def _now() -> datetime:
return datetime.now(UTC)
def _normalized_row_data(row: Any, rename_fields: dict[str, str] | None = None) -> dict[str, Any]:
reverse = {target: source for source, target in (rename_fields or {}).items()}
values: dict[str, Any] = {}
for column in row.__table__.columns:
value = getattr(row, column.name)
if isinstance(value, datetime) and value.tzinfo is None:
value = value.replace(tzinfo=UTC)
values[reverse.get(column.name, column.name)] = value
return values
def _atomic_transition(
session: Session,
row_type: type[Any],
scope: Scope,
record_id: UUID,
current: str,
target: str,
expected_version: int,
machine: str,
state_field: str,
*,
project_required: bool,
) -> None:
next_state = transition(machine, current, target)
statement = (
update(row_type)
.where(row_type.id == record_id)
.where(row_type.workspace_id == scope.workspace_id)
.where(row_type.revision == expected_version)
)
if project_required:
statement = statement.where(row_type.project_id == scope.project_id)
result = session.execute(
statement.values(
**{
state_field: next_state,
"revision": expected_version + 1,
"updated_at": _now(),
}
)
)
if result.rowcount != 1:
raise ConcurrentUpdateError(f"stale version for {row_type.__name__} {record_id}")
class ScopedRepository[RowT, DomainT]:
"""Small typed CRUD boundary with mandatory workspace/project filtering."""
row_type: Any
domain_type: Any
project_required = True
project_filter_if_present = True
rename_fields: dict[str, str] = {}
def __init__(self, session: Session, scope: Scope) -> None:
if not isinstance(scope, Scope):
raise TypeError("repository scope must be a Scope")
self.session = session
self.scope = scope
if self.project_required and scope.project_id is None:
raise MissingProjectScope(f"{type(self).__name__} requires project scope")
def _statement(self) -> Select[tuple[RowT]]:
row = self.row_type
statement = select(row).where(row.workspace_id == self.scope.workspace_id)
if self.project_required or (
self.project_filter_if_present and self.scope.project_id is not None
):
statement = statement.where(row.project_id == self.scope.project_id)
return statement.order_by(row.id)
def _row_to_domain(self, row: RowT) -> DomainT:
data = _normalized_row_data(row, self.rename_fields)
data = {key: value for key, value in data.items() if key in self.domain_type.model_fields}
return cast(DomainT, self.domain_type.model_validate(data))
def _model_data(self, model: DomainT) -> dict[str, Any]:
values = cast(Any, model).model_dump()
columns = set(self.row_type.__table__.columns.keys())
data: dict[str, Any] = {}
for field, value in values.items():
target = self.rename_fields.get(field, field)
if target in columns:
data[target] = value
return data
def list(self, *, limit: int = 100, offset: int = 0) -> list[DomainT]:
if not 1 <= limit <= 500:
raise ValueError("limit must be between 1 and 500")
if offset < 0:
raise ValueError("offset must be non-negative")
rows = self.session.scalars(self._statement().limit(limit).offset(offset)).all()
return [self._row_to_domain(row) for row in rows]
def get(self, record_id: UUID) -> DomainT | None:
row = self.session.scalars(self._statement().where(self.row_type.id == record_id)).first()
return self._row_to_domain(row) if row is not None else None
def create(self, model: DomainT) -> DomainT:
data = self._model_data(model)
if data.get("workspace_id") != self.scope.workspace_id:
raise ValueError("model workspace does not match repository scope")
if self.project_required and data.get("project_id") != self.scope.project_id:
raise ValueError("model project does not match repository scope")
row = self.row_type(**data)
self.session.add(row)
self.session.flush()
return self._row_to_domain(row)
def update(self, model: DomainT, *, expected_version: int) -> DomainT:
record_id = cast(Any, model).id
data = self._model_data(model)
data.pop("id", None)
data.pop("created_at", None)
data.pop("revision", None)
data["updated_at"] = _now()
data["revision"] = expected_version + 1
statement = (
update(self.row_type)
.where(self.row_type.id == record_id)
.where(self.row_type.workspace_id == self.scope.workspace_id)
.where(self.row_type.revision == expected_version)
)
if self.project_required or (
self.project_filter_if_present and self.scope.project_id is not None
):
statement = statement.where(self.row_type.project_id == self.scope.project_id)
result = self.session.execute(statement.values(**data))
if result.rowcount != 1:
raise ConcurrentUpdateError(f"stale version for {type(self).__name__} {record_id}")
self.session.flush()
return self.get(record_id) # type: ignore[return-value]
class WorkspaceRepository(ScopedRepository[WorkspaceRow, Workspace]):
row_type = WorkspaceRow
domain_type = Workspace
project_required = False
project_filter_if_present = False
def create(self, model: Workspace) -> Workspace:
if model.id != self.scope.workspace_id:
raise ValueError("workspace model does not match repository scope")
row = WorkspaceRow(**model.model_dump())
self.session.add(row)
self.session.flush()
return self._row_to_domain(row)
def _statement(self) -> Select[tuple[WorkspaceRow]]:
return (
select(self.row_type)
.where(self.row_type.id == self.scope.workspace_id)
.order_by(self.row_type.id)
)
class ProjectRepository(ScopedRepository[ProjectRow, Project]):
row_type = ProjectRow
domain_type = Project
project_required = False
project_filter_if_present = False
def _statement(self) -> Select[tuple[ProjectRow]]:
statement = select(self.row_type).where(
self.row_type.workspace_id == self.scope.workspace_id
)
if self.scope.project_id is not None:
statement = statement.where(self.row_type.id == self.scope.project_id)
return statement.order_by(self.row_type.id)
def create(self, model: Project) -> Project:
if self.scope.project_id is not None and model.id != self.scope.project_id:
raise ValueError("project model does not match repository scope")
return super().create(model)
class RiskClassRepository:
"""Read-only catalog boundary for safe global risk classes."""
def __init__(self, session: Session, scope: Scope) -> None:
if not isinstance(scope, Scope):
raise TypeError("repository scope must be a Scope")
self.session = session
self.scope = scope
def list(self) -> list[RiskClass]:
rows = self.session.scalars(select(RiskClassRow).order_by(RiskClassRow.code)).all()
return [RiskClass.model_validate(row) for row in rows]
class CapabilityRepository:
"""Read/create boundary for global capability definitions, always scoped by caller."""
def __init__(self, session: Session, scope: Scope) -> None:
if not isinstance(scope, Scope):
raise TypeError("repository scope must be a Scope")
self.session = session
self.scope = scope
def list(self, *, limit: int = 100, offset: int = 0) -> list[Capability]:
rows = self.session.scalars(
select(CapabilityRow)
.order_by(CapabilityRow.canonical_name, CapabilityRow.version)
.limit(limit)
.offset(offset)
).all()
return [Capability.model_validate(_normalized_row_data(row)) for row in rows]
def create(self, model: Capability) -> Capability:
row = CapabilityRow(**model.model_dump())
self.session.add(row)
self.session.flush()
return Capability.model_validate(_normalized_row_data(row))
class ToolDefinitionRepository(ScopedRepository[ToolDefinitionRow, ToolDefinition]):
row_type = ToolDefinitionRow
domain_type = ToolDefinition
class VirtualServerCapabilityRepository(
ScopedRepository[VirtualServerCapabilityRow, VirtualServerCapability]
):
row_type = VirtualServerCapabilityRow
domain_type = VirtualServerCapability
class ClientBindingRepository(ScopedRepository[ClientBindingRow, ClientBinding]):
row_type = ClientBindingRow
domain_type = ClientBinding
class EnvironmentRepository(ScopedRepository[EnvironmentRow, Environment]):
row_type = EnvironmentRow
domain_type = Environment
class ConnectorRepository(ScopedRepository[ConnectorRow, Connector]):
row_type = ConnectorRow
domain_type = Connector
def _model_data(self, model: Connector) -> dict[str, Any]:
data = super()._model_data(model)
data["environment_scope"] = (
str(model.environment_id) if model.environment_id else "__project__"
)
return data
def transition_health(
self, connector_id: UUID, target: str, *, expected_version: int
) -> Connector:
row = self.session.scalars(
self._statement().where(self.row_type.id == connector_id)
).first()
if row is None:
raise LookupError(f"connector not found in scope: {connector_id}")
_atomic_transition(
self.session,
self.row_type,
self.scope,
connector_id,
row.health_state,
target,
expected_version,
"connector_health",
"health_state",
project_required=True,
)
self.session.flush()
return self.get(connector_id) # type: ignore[return-value]
class VirtualServerRepository(ScopedRepository[VirtualServerRow, VirtualServer]):
row_type = VirtualServerRow
domain_type = VirtualServer
rename_fields = {"metadata": "record_metadata"}
class ClientRepository(ScopedRepository[ClientRow, Client]):
row_type = ClientRow
domain_type = Client
project_required = False
project_filter_if_present = False
rename_fields = {"metadata": "record_metadata"}
def transition_status(self, client_id: UUID, target: str, *, expected_version: int) -> Client:
row = self.session.scalars(self._statement().where(self.row_type.id == client_id)).first()
if row is None:
raise LookupError(f"client not found in workspace: {client_id}")
_atomic_transition(
self.session,
self.row_type,
self.scope,
client_id,
row.status,
target,
expected_version,
"client",
"status",
project_required=False,
)
if target == "revoked":
row.revoked_at = _now()
self.session.flush()
return self.get(client_id) # type: ignore[return-value]
class PolicyRepository(ScopedRepository[PolicyRow, Policy]):
row_type = PolicyRow
domain_type = Policy
project_required = False
rename_fields = {"match": "match_json", "conditions": "conditions_json"}
class PolicyDecisionRepository:
"""Short-retention decision record boundary with mandatory workspace scope."""
def __init__(self, session: Session, scope: Scope) -> None:
if not isinstance(scope, Scope):
raise TypeError("repository scope must be a Scope")
self.session = session
self.scope = scope
def create(self, model: PolicyDecision) -> PolicyDecision:
if model.workspace_id != self.scope.workspace_id:
raise ValueError("policy decision workspace does not match scope")
if self.scope.project_id is not None and model.project_id != self.scope.project_id:
raise ValueError("policy decision project does not match scope")
row = PolicyDecisionRow(**model.model_dump())
self.session.add(row)
self.session.flush()
return PolicyDecision.model_validate(_normalized_row_data(row))
def list(self, *, limit: int = 100, offset: int = 0) -> list[PolicyDecision]:
statement = select(PolicyDecisionRow).where(
PolicyDecisionRow.workspace_id == self.scope.workspace_id
)
if self.scope.project_id is not None:
statement = statement.where(PolicyDecisionRow.project_id == self.scope.project_id)
statement = (
statement.order_by(PolicyDecisionRow.created_at, PolicyDecisionRow.id)
.limit(limit)
.offset(offset)
)
return [
PolicyDecision.model_validate(_normalized_row_data(row))
for row in self.session.scalars(statement).all()
]
class ReconcileRunRepository(ScopedRepository[ReconcileRunRow, ReconcileRun]):
row_type = ReconcileRunRow
domain_type = ReconcileRun
def transition_status(
self, run_id: UUID, target: str, *, expected_version: int
) -> ReconcileRun:
row = self.session.scalars(self._statement().where(self.row_type.id == run_id)).first()
if row is None:
raise LookupError(f"reconcile run not found in scope: {run_id}")
_atomic_transition(
self.session,
self.row_type,
self.scope,
run_id,
row.status,
target,
expected_version,
"reconcile",
"status",
project_required=True,
)
if target in {"succeeded", "failed", "invalid"}:
row.completed_at = _now()
self.session.flush()
return self.get(run_id) # type: ignore[return-value]
class OperationRepository(ScopedRepository[OperationRow, Operation]):
row_type = OperationRow
domain_type = Operation
def transition_state(
self, operation_id: UUID, target: str, *, expected_version: int
) -> Operation:
row = self.session.scalars(
self._statement().where(self.row_type.id == operation_id)
).first()
if row is None:
raise LookupError(f"operation not found in scope: {operation_id}")
_atomic_transition(
self.session,
self.row_type,
self.scope,
operation_id,
row.state,
target,
expected_version,
"operation",
"state",
project_required=True,
)
self.session.flush()
return self.get(operation_id) # type: ignore[return-value]
class ApprovalRepository(ScopedRepository[ApprovalRow, Approval]):
"""Approval boundary with plan-hash and state-machine enforcement."""
row_type = ApprovalRow
domain_type = Approval
def transition_state(
self, approval_id: UUID, target: str, *, expected_version: int
) -> Approval:
row = self.session.scalars(self._statement().where(self.row_type.id == approval_id)).first()
if row is None:
raise LookupError(f"approval not found in scope: {approval_id}")
if target == "approved":
operation = self.session.scalars(
select(OperationRow)
.where(OperationRow.id == row.operation_id)
.where(OperationRow.workspace_id == self.scope.workspace_id)
.where(OperationRow.project_id == self.scope.project_id)
).first()
if operation is None or operation.plan_hash != row.plan_hash:
raise DomainError("approval plan hash does not match operation")
if operation.state != "approval_required":
raise InvalidTransition("operation is not waiting for approval")
_atomic_transition(
self.session,
self.row_type,
self.scope,
approval_id,
row.state,
target,
expected_version,
"approval",
"state",
project_required=True,
)
if target == "approved":
row.approved_at = _now()
self.session.flush()
return self.get(approval_id) # type: ignore[return-value]
class AuditEventRepository:
"""Append/query-only audit boundary; update and delete are not exposed."""
_sensitive_key = re.compile(
r"(?:password|secret|token|private|authorization|cookie|credential|connection)", re.I
)
def __init__(self, session: Session, scope: Scope) -> None:
if not isinstance(scope, Scope):
raise TypeError("repository scope must be a Scope")
self.session = session
self.scope = scope
@classmethod
def _assert_sanitized(cls, value: Any) -> None:
if isinstance(value, dict):
for key, item in value.items():
if cls._sensitive_key.search(str(key)):
raise AuditMutationError(f"sensitive audit field is prohibited: {key}")
cls._assert_sanitized(item)
elif isinstance(value, list):
for item in value:
cls._assert_sanitized(item)
def create(self, event: AuditEvent) -> AuditEvent:
if event.workspace_id != self.scope.workspace_id:
raise ValueError("audit workspace does not match repository scope")
if self.scope.project_id is not None and event.project_id != self.scope.project_id:
raise ValueError("audit project does not match repository scope")
self._assert_sanitized(event.sanitized_arguments)
row = AuditEventRow(**event.model_dump())
self.session.add(row)
self.session.flush()
return AuditEvent.model_validate(row)
def list(self, *, limit: int = 100, offset: int = 0) -> list[AuditEvent]:
if not 1 <= limit <= 500 or offset < 0:
raise ValueError("invalid pagination")
statement = select(AuditEventRow).where(
AuditEventRow.workspace_id == self.scope.workspace_id
)
if self.scope.project_id is not None:
statement = statement.where(AuditEventRow.project_id == self.scope.project_id)
statement = (
statement.order_by(AuditEventRow.occurred_at, AuditEventRow.id)
.limit(limit)
.offset(offset)
)
return [
AuditEvent.model_validate(_normalized_row_data(row))
for row in self.session.scalars(statement).all()
]
class KnowledgeSourceRepository(ScopedRepository[KnowledgeSourceRow, KnowledgeSource]):
row_type = KnowledgeSourceRow
domain_type = KnowledgeSource
class KnowledgeDocumentRepository(ScopedRepository[KnowledgeDocumentRow, KnowledgeDocument]):
row_type = KnowledgeDocumentRow
domain_type = KnowledgeDocument
rename_fields = {"metadata": "record_metadata"}
class KnowledgeChunkRepository(ScopedRepository[KnowledgeChunkRow, KnowledgeChunk]):
row_type = KnowledgeChunkRow
domain_type = KnowledgeChunk
rename_fields = {"metadata": "record_metadata"}
+58
View File
@@ -0,0 +1,58 @@
"""Deterministic, credential-free Hub seed data."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import NAMESPACE_URL, UUID, uuid5
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import RiskClassRow, WorkspaceRow
SEED_NAMESPACE = "https://hub.example.com/seed/v1"
@dataclass(frozen=True)
class SeedResult:
workspace_id: UUID
risk_class_codes: tuple[str, ...]
def _stable_id(kind: str, name: str) -> UUID:
return uuid5(NAMESPACE_URL, f"{SEED_NAMESPACE}:{kind}:{name}")
def seed_database(session: Session) -> SeedResult:
"""Insert the ITWorx workspace and safe read-only risk classes idempotently."""
workspace_id = _stable_id("workspace", "itworx")
workspace = session.get(WorkspaceRow, workspace_id)
if workspace is None:
workspace = WorkspaceRow(id=workspace_id, slug="itworx", name="ITWorx", status="active")
session.add(workspace)
risk_classes = (
("health-read", "Read-only health and liveness metadata", 65536),
("metadata-read", "Read-only catalog and metadata inspection", 65536),
("read-only", "Read-only target-system capability", 131072),
)
for code, description, max_output_bytes in risk_classes:
existing = session.get(RiskClassRow, code)
if existing is None:
session.add(
RiskClassRow(
code=code,
description=description,
allows_writes=False,
max_output_bytes=max_output_bytes,
)
)
session.flush()
persisted = session.scalar(select(WorkspaceRow.id).where(WorkspaceRow.slug == "itworx"))
if persisted is None:
raise RuntimeError("deterministic workspace seed was not persisted")
return SeedResult(
workspace_id=persisted, risk_class_codes=tuple(code for code, _, _ in risk_classes)
)
@@ -0,0 +1,56 @@
"""Application service façade for scoped persistence operations."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.orm import Session
from packages.hub_core.domain import Approval, AuditEvent, Connector, Operation, ReconcileRun, Scope
from .repositories import (
ApprovalRepository,
AuditEventRepository,
ConnectorRepository,
OperationRepository,
ReconcileRunRepository,
)
class ControlPlanePersistenceService:
"""Explicit service boundary used by future API/worker adapters."""
def __init__(self, session: Session, scope: Scope) -> None:
self.session = session
self.scope = scope
def transition_connector_health(
self, connector_id: UUID, target: str, *, expected_version: int
) -> Connector:
return ConnectorRepository(self.session, self.scope).transition_health(
connector_id, target, expected_version=expected_version
)
def transition_reconcile_run(
self, run_id: UUID, target: str, *, expected_version: int
) -> ReconcileRun:
return ReconcileRunRepository(self.session, self.scope).transition_status(
run_id, target, expected_version=expected_version
)
def transition_operation(
self, operation_id: UUID, target: str, *, expected_version: int
) -> Operation:
return OperationRepository(self.session, self.scope).transition_state(
operation_id, target, expected_version=expected_version
)
def transition_approval(
self, approval_id: UUID, target: str, *, expected_version: int
) -> Approval:
return ApprovalRepository(self.session, self.scope).transition_state(
approval_id, target, expected_version=expected_version
)
def record_audit(self, event: AuditEvent) -> AuditEvent:
return AuditEventRepository(self.session, self.scope).create(event)
+6
View File
@@ -0,0 +1,6 @@
"""Policy simulation and restricted audit management boundary."""
from .router import build_policy_router
from .service import PolicyService
__all__ = ["PolicyService", "build_policy_router"]
+59
View File
@@ -0,0 +1,59 @@
"""Authenticated, project-scoped policy simulation and audit read routes."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from packages.hub_policy import InvocationContext
from .service import PolicyService
def _deny_by_default(_: Request) -> None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="policy authorization required"
)
def build_policy_router(
service: PolicyService, *, authorize: Callable[[Request], Any] = _deny_by_default
) -> APIRouter:
def authorized(request: Request) -> None:
authorize(request)
router = APIRouter(
prefix="/governance", tags=["governance"], dependencies=[Depends(authorized)]
)
@router.post("/simulate")
def simulate(context: InvocationContext) -> dict[str, Any]:
return service.simulate(context)
@router.get("/audit")
def audit(
request: Request, workspace: str, project: str, limit: int = 100, offset: int = 0
) -> dict[str, Any]:
del request
try:
return service.audit_search(
workspace=workspace, project=project, limit=limit, offset=offset
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.get("/audit/export-metadata")
def export_metadata(workspace: str, project: str) -> dict[str, Any]:
return service.audit_export_metadata(workspace=workspace, project=project)
@router.put("/policies/{slug}")
def update_policy(slug: str, payload: dict[str, Any]) -> None:
del slug, payload
try:
service.update_policy()
except PermissionError as exc:
raise HTTPException(status_code=405, detail=str(exc)) from exc
return router
+48
View File
@@ -0,0 +1,48 @@
"""Control-plane service for policy simulation and policy-scoped audit reads."""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from uuid import UUID
from packages.hub_policy import AuditLog, InvocationContext, PolicyEngine, PolicySimulator
class PolicyService:
def __init__(
self,
engine: PolicyEngine,
audit: AuditLog,
*,
known_projects: Iterable[str] = (),
known_clients: Iterable[UUID] = (),
known_tools: Iterable[str] = (),
) -> None:
self.engine = engine
self.audit = audit
self.simulator = PolicySimulator(
engine,
known_projects=known_projects,
known_clients=known_clients,
known_tools=known_tools,
)
def simulate(self, context: InvocationContext) -> dict[str, Any]:
"""Simulation is side-effect free and uses the exact runtime evaluator."""
return self.simulator.simulate(context).model_dump(mode="json")
def audit_search(
self, *, workspace: str, project: str, limit: int = 100, offset: int = 0
) -> dict[str, Any]:
page = self.audit.search(workspace=workspace, project=project, limit=limit, offset=offset)
return {
"items": [item.model_dump(mode="json") for item in page.items],
"nextOffset": page.next_offset,
}
def audit_export_metadata(self, *, workspace: str, project: str) -> dict[str, Any]:
return self.audit.export_metadata(workspace=workspace, project=project)
def update_policy(self, *_: Any, **__: Any) -> None:
raise PermissionError("policies are catalog-managed; direct mutation is prohibited")