"""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