"""Pydantic schemas for the identity-consumer governance API.""" from __future__ import annotations import re from datetime import date, datetime from typing import Any from uuid import UUID from pydantic import BaseModel, Field, field_validator from app.identity.consumers.models import KNOWN_SCOPES, SCOPE_PERSONS_READ, SCOPE_PII_READ #: 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])?$") def _validate_scopes(scopes: list[str]) -> list[str]: cleaned = sorted({s.strip() for s in scopes if s.strip()}) unknown = [s for s in cleaned if s not in KNOWN_SCOPES] if unknown: raise ValueError(f"unknown scope(s): {', '.join(unknown)}") if not cleaned: raise ValueError("at least one scope is required") if SCOPE_PII_READ in cleaned and SCOPE_PERSONS_READ not in cleaned: raise ValueError( f"{SCOPE_PII_READ} is an add-on to {SCOPE_PERSONS_READ}; " "grant the base scope too" ) return cleaned class ConsumerCreateRequest(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] #: Optional field-level narrowing of the persons bundle. ``None`` = #: grant the full bundle set (the bundle-preset default). Validated #: against the scopes at the service layer (a field's bundle must be #: granted) — kept out of the validator here because it cross-checks #: another field. granted_fields: list[str] | None = None filter_config: dict[str, Any] = Field(default_factory=dict) rate_limit_per_minute: int | None = Field(default=None, ge=1, le=10000) @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 ConsumerUpdateRequest(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 #: ``None`` = leave the grant unchanged (unless ``scopes`` changes, #: which re-derives it). An explicit list re-narrows the persons #: bundle; validated against the effective scopes at the service layer. granted_fields: list[str] | None = None filter_config: dict[str, Any] | None = None rate_limit_per_minute: int | None = Field(default=None, ge=1, le=10000) #: ``active`` / ``disabled`` — the kill switch. status: str | None = None @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 ConsumerResponse(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] granted_fields: list[str] = [] filter_config: dict[str, Any] rate_limit_per_minute: int | None created_at: datetime updated_at: datetime active_token_count: int = 0 last_used_at: datetime | None = None #: Periodic re-certification (migration 287). last_certified_at: datetime | None = None recert_lapsed_at: datetime | None = None model_config = {"from_attributes": True} class ConsumerDetailResponse(ConsumerResponse): tokens: list[TokenResponse] = [] usage_recent_days: list[UsageDayResponse] = [] requests_30d: int = 0 __all__ = [ "ConsumerCreateRequest", "ConsumerDetailResponse", "ConsumerResponse", "ConsumerUpdateRequest", "TokenMintRequest", "TokenMintResponse", "TokenResponse", "UsageDayResponse", ]