Files
ITWorx_MCP_Hub-Public/services/control_api/clients/router.py
T

183 lines
7.4 KiB
Python

"""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,
ClientStatus,
)
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,
onboarding_status: Callable[[UUID], dict[str, Any]] | None = None,
verify_onboarding: Callable[[UUID, Any], dict[str, Any]] | None = None,
transition_client: Callable[[UUID, ClientStatus, Any], ClientRecord] | None = None,
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.get("/{client_id}/onboarding-status")
def onboarding(client_id: UUID, principal: Any = authenticated) -> dict[str, Any]:
authorize(principal, "client.read")
if onboarding_status is None:
raise HTTPException(status_code=404, detail="client onboarding status is unavailable")
try:
return onboarding_status(client_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail="onboarding client not found") from exc
@router.post("/{client_id}/verify")
def verify(client_id: UUID, principal: Any = authenticated) -> dict[str, Any]:
authorize(principal, "client.verify")
if verify_onboarding is None:
raise HTTPException(
status_code=404, detail="client onboarding verification is unavailable"
)
try:
return verify_onboarding(client_id, principal)
except KeyError as exc:
raise HTTPException(status_code=404, detail="onboarding 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 (
transition_client(client_id, ClientStatus.DISABLED, principal)
if transition_client is not None
else service.disable(client_id)
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="client not found") from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) 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 (
transition_client(client_id, ClientStatus.REVOKED, principal)
if transition_client is not None
else service.revoke(client_id)
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="client not found") from exc
return router