"""Pydantic schemas for the External API governance surface.""" from __future__ import annotations import re from datetime import date, datetime from uuid import UUID from pydantic import BaseModel, Field, field_validator from app.api_clients.person_visibility import normalize_person_visibility from app.api_clients.scopes import validate_scopes from app.api_clients.visibility import normalize_cloud_visibility #: Lowercase kebab identifier, 2..64 chars, no leading/trailing dash. _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$") class ApiClientCreateRequest(BaseModel): name: str = Field(min_length=1, max_length=128) slug: str = Field(min_length=2, max_length=64) description: str | None = Field(default=None, max_length=2000) owner_person_id: UUID scopes: list[str] rate_limit_per_minute: int | None = Field(default=None, ge=1, le=10000) #: ``{"providers": [...], "subscription_ids": [...]}`` — union of the #: two lists. Omitted / null = unrestricted (whole cloud inventory). cloud_visibility: dict | None = None #: Person filter (``FilterConfig`` shape) bounding WHICH people the #: client may read. Omitted / null = unrestricted (whole roster). person_visibility: dict | None = None @field_validator("cloud_visibility") @classmethod def _visibility_shape(cls, v: dict | None) -> dict | None: return normalize_cloud_visibility(v) @field_validator("person_visibility") @classmethod def _person_visibility_shape(cls, v: dict | None) -> dict | None: return normalize_person_visibility(v) @field_validator("slug") @classmethod def _slug_shape(cls, v: str) -> str: v = v.strip().lower() if not _SLUG_RE.match(v): raise ValueError( "slug must be lowercase letters/digits/dashes, " "2-64 chars, no leading/trailing dash" ) return v @field_validator("scopes") @classmethod def _scopes_known(cls, v: list[str]) -> list[str]: return validate_scopes(v) class ApiClientUpdateRequest(BaseModel): """PATCH body — every field optional; ``slug`` is immutable.""" name: str | None = Field(default=None, min_length=1, max_length=128) description: str | None = Field(default=None, max_length=2000) owner_person_id: UUID | None = None scopes: list[str] | None = None rate_limit_per_minute: int | None = Field(default=None, ge=1, le=10000) #: ``active`` / ``disabled`` — the kill switch. status: str | None = None #: Omitted = leave unchanged; explicit ``null`` / ``{}`` = clear the #: whitelist (back to the whole inventory). The router distinguishes #: the two via ``model_fields_set`` — widening a client's cloud #: visibility must be a deliberate edit, never a side effect of a #: PATCH that forgot the field. cloud_visibility: dict | None = None #: Same omitted-vs-explicit-null contract as ``cloud_visibility``: #: omitted = leave unchanged, explicit ``null`` / ``{}`` = clear the #: ceiling (back to the whole roster). Widening a client's person #: reach must be a deliberate edit, never a PATCH side effect. person_visibility: dict | None = None @field_validator("cloud_visibility") @classmethod def _visibility_shape(cls, v: dict | None) -> dict | None: return normalize_cloud_visibility(v) @field_validator("person_visibility") @classmethod def _person_visibility_shape(cls, v: dict | None) -> dict | None: return normalize_person_visibility(v) @field_validator("scopes") @classmethod def _scopes_known(cls, v: list[str] | None) -> list[str] | None: return None if v is None else validate_scopes(v) @field_validator("status") @classmethod def _status_known(cls, v: str | None) -> str | None: if v is not None and v not in ("active", "disabled"): raise ValueError("status must be 'active' or 'disabled'") return v class TokenMintRequest(BaseModel): name: str = Field(min_length=1, max_length=120) #: One of ``ALLOWED_EXPIRY_DAYS`` or null (= never expires). expires_in_days: int | None = None class TokenResponse(BaseModel): id: UUID name: str token_prefix: str expires_at: datetime | None revoked_at: datetime | None last_used_at: datetime | None last_used_ip: str | None created_at: datetime is_active: bool model_config = {"from_attributes": True} class TokenMintResponse(BaseModel): token: TokenResponse #: Shown exactly once — only the hash is stored. plaintext: str class UsageDayResponse(BaseModel): usage_date: date request_count: int model_config = {"from_attributes": True} class ApiClientResponse(BaseModel): id: UUID name: str slug: str description: str | None owner_person_id: UUID owner_display_name: str | None = None owner_work_email: str | None = None owner_employee_no: str | None = None owner_employment_status: str | None = None status: str scopes: list[str] rate_limit_per_minute: int | None created_at: datetime updated_at: datetime #: Non-null = soft-deleted. Only surfaces on ``?include_deleted=true``; #: the console needs it to render the deleted row (and its restore #: action) instead of leaving the slug's holder invisible. deleted_at: datetime | None = None active_token_count: int = 0 last_used_at: datetime | None = None #: ``None`` = unrestricted cloud visibility (the whole inventory). cloud_visibility: dict | None = None #: True when the stored value is present but not a JSON object, i.e. #: unreadable as a whitelist. The data plane fails closed on it (the #: client reads no subscriptions), so the console must not render the #: row as "unrestricted" — it needs re-saving. cloud_visibility_misconfigured: bool = False #: ``None`` = unrestricted person visibility (the whole roster). person_visibility: dict | None = None #: True when the stored value is present but not a JSON object, i.e. #: unreadable as a ceiling. The data plane fails closed on it (the #: client reads nobody), so the console must not render the row as #: "unrestricted" — it needs re-saving. person_visibility_misconfigured: bool = False model_config = {"from_attributes": True} class ApiClientDetailResponse(ApiClientResponse): tokens: list[TokenResponse] = [] usage_recent_days: list[UsageDayResponse] = [] #: ``{subscription_id: {external_id, name}}`` for every whitelisted #: subscription, so the console renders the grant as account names #: instead of bare UUIDs. Empty when unrestricted. cloud_visibility_labels: dict = {} requests_30d: int = 0 __all__ = [ "ApiClientCreateRequest", "ApiClientDetailResponse", "ApiClientResponse", "ApiClientUpdateRequest", "TokenMintRequest", "TokenMintResponse", "TokenResponse", "UsageDayResponse", ]