183 lines
7.4 KiB
Python
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
|