"""Platform owner console: providers, models, routing policies and rate cards. Everything in this router is invisible to customers. Two rules are enforced here and tested: 1. Only a platform owner may call any of it. 2. A provider API key is **never** returned — not masked-and-then-leaked, not in a nested object. Only ``masked_key`` leaves this module. """ from __future__ import annotations from datetime import datetime from fastapi import APIRouter, Depends, Query, Request, status from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession from app.application.services.audit_service import record_audit from app.core.crypto import encrypt, mask from app.core.database import get_session from app.core.errors import ConflictError, NotFoundError, ValidationError from app.domain.ai_control.models import ( AiModel, Capability, ModelCredential, Provider, ProviderType, RateCard, RateUnit, RoutingMode, RoutingPolicy, ) from app.domain.audit.models import AuditAction from app.domain.base import utcnow from app.domain.identity.models import User from app.infrastructure.repositories.ai_control import ( AiModelRepository, ModelCredentialRepository, ProviderRepository, RateCardRepository, RoutingPolicyRepository, ) from app.infrastructure.repositories.audit import ( ObservabilityRepository, UsageLedgerRepository, ) from app.infrastructure.repositories.identity import ( MembershipRepository, OrganizationRepository, ) from app.interfaces.http.deps import client_meta, require_platform_owner router = APIRouter( prefix="/admin", tags=["admin"], # Deny-by-default for the whole console. This is a router-level dependency on # purpose: an endpoint added later is protected without its author having to # remember. Relying on per-endpoint checks alone already caused a real # privilege-escalation bug — every GET here once shipped without the check, # letting any authenticated customer list all providers and every # organization on the platform. # # Endpoints that also declare `owner: User = Depends(require_platform_owner)` # do so because they *use* the object (for the audit actor); FastAPI caches # the dependency within a request, so it is not evaluated twice. dependencies=[Depends(require_platform_owner)], ) # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- class ProviderOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str name: str type: str base_url: str is_active: bool created_at: datetime class CreateProviderRequest(BaseModel): name: str = Field(min_length=2, max_length=120) type: ProviderType = ProviderType.MOCK base_url: str = Field(default="", max_length=512) class CredentialOut(BaseModel): """Deliberately has no field capable of holding the plaintext key.""" id: str provider_id: str name: str masked_key: str is_active: bool class CreateCredentialRequest(BaseModel): name: str = Field(min_length=1, max_length=120) api_key: str = Field(min_length=4, max_length=512) class ModelOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str provider_id: str internal_name: str display_name_for_admin: str capabilities: list[str] input_price_per_1m: float output_price_per_1m: float is_active: bool class CreateModelRequest(BaseModel): provider_id: str internal_name: str = Field(min_length=1, max_length=160) display_name_for_admin: str = Field(min_length=1, max_length=160) capabilities: list[Capability] = Field(default_factory=list) input_price_per_1m: float = Field(default=0.0, ge=0) output_price_per_1m: float = Field(default=0.0, ge=0) class RoutingPolicyOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str name: str description: str mode: str is_default: bool is_active: bool class CreateRoutingPolicyRequest(BaseModel): name: str = Field(min_length=2, max_length=120) mode: RoutingMode = RoutingMode.BALANCED description: str = Field(default="", max_length=500) is_default: bool = False budget_limits_json: dict = Field(default_factory=dict) fallback_policy_json: dict = Field(default_factory=dict) class RateCardOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str name: str unit: str customer_price: float internal_cost_estimate: float is_active: bool class UpsertRateCardRequest(BaseModel): unit: RateUnit name: str = Field(default="", max_length=120) customer_price: float = Field(ge=0) internal_cost_estimate: float = Field(default=0.0, ge=0) is_active: bool = True class AdminOrganizationOut(BaseModel): id: str name: str slug: str member_count: int created_at: datetime class AdminUsageRow(BaseModel): organization_id: str quantity: float total_price: float # --------------------------------------------------------------------------- # Providers # --------------------------------------------------------------------------- @router.get("/providers", response_model=list[ProviderOut]) async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]: providers = await ProviderRepository(session).list() return [ ProviderOut( id=p.id, name=p.name, type=p.type.value, base_url=p.base_url, is_active=p.is_active, created_at=p.created_at, ) for p in providers ] @router.post("/providers", response_model=ProviderOut, status_code=status.HTTP_201_CREATED) async def create_provider( payload: CreateProviderRequest, request: Request, owner: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> ProviderOut: repo = ProviderRepository(session) if await repo.get_by_name(payload.name) is not None: raise ConflictError("ارائه‌دهنده‌ای با این نام وجود دارد.") provider = await repo.create( Provider(name=payload.name, type=payload.type, base_url=payload.base_url) ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id, target_type="provider", target_id=provider.id, metadata={"name": provider.name, "type": provider.type.value}, ip=ip, user_agent=ua, ) return ProviderOut( id=provider.id, name=provider.name, type=provider.type.value, base_url=provider.base_url, is_active=provider.is_active, created_at=provider.created_at, ) @router.post( "/providers/{provider_id}/credentials", response_model=CredentialOut, status_code=status.HTTP_201_CREATED, ) async def create_credential( provider_id: str, payload: CreateCredentialRequest, request: Request, owner: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> CredentialOut: provider = await ProviderRepository(session).get(provider_id) if provider is None: raise NotFoundError("ارائه‌دهنده یافت نشد.") credential = await ModelCredentialRepository(session).create( ModelCredential( provider_id=provider_id, name=payload.name, # Encrypted at rest; the plaintext exists only inside this request. encrypted_api_key=encrypt(payload.api_key), ) ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id, target_type="credential", target_id=credential.id, metadata={"provider_id": provider_id, "name": credential.name}, ip=ip, user_agent=ua, ) return CredentialOut( id=credential.id, provider_id=credential.provider_id, name=credential.name, masked_key=mask(payload.api_key), is_active=credential.is_active, ) @router.get("/providers/{provider_id}/credentials", response_model=list[CredentialOut]) async def list_credentials( provider_id: str, session: AsyncSession = Depends(get_session) ) -> list[CredentialOut]: credentials = await ModelCredentialRepository(session).list_for_provider(provider_id) return [ CredentialOut( id=c.id, provider_id=c.provider_id, name=c.name, # We cannot recover the plaintext to mask it — and we must not try. masked_key="••••••", is_active=c.is_active, ) for c in credentials ] # --------------------------------------------------------------------------- # Models # --------------------------------------------------------------------------- @router.get("/models", response_model=list[ModelOut]) async def list_models( provider_id: str | None = Query(default=None), session: AsyncSession = Depends(get_session), ) -> list[ModelOut]: models = await AiModelRepository(session).list(provider_id=provider_id) return [ ModelOut( id=m.id, provider_id=m.provider_id, internal_name=m.internal_name, display_name_for_admin=m.display_name_for_admin, capabilities=list(m.capabilities), input_price_per_1m=m.input_price_per_1m, output_price_per_1m=m.output_price_per_1m, is_active=m.is_active, ) for m in models ] @router.post("/models", response_model=ModelOut, status_code=status.HTTP_201_CREATED) async def create_model( payload: CreateModelRequest, request: Request, owner: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> ModelOut: if await ProviderRepository(session).get(payload.provider_id) is None: raise NotFoundError("ارائه‌دهنده یافت نشد.") model = await AiModelRepository(session).create( AiModel( provider_id=payload.provider_id, internal_name=payload.internal_name, display_name_for_admin=payload.display_name_for_admin, capabilities=[str(c) for c in payload.capabilities], input_price_per_1m=payload.input_price_per_1m, output_price_per_1m=payload.output_price_per_1m, ) ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id, target_type="model", target_id=model.id, metadata={"internal_name": model.internal_name}, ip=ip, user_agent=ua, ) return ModelOut( id=model.id, provider_id=model.provider_id, internal_name=model.internal_name, display_name_for_admin=model.display_name_for_admin, capabilities=list(model.capabilities), input_price_per_1m=model.input_price_per_1m, output_price_per_1m=model.output_price_per_1m, is_active=model.is_active, ) # --------------------------------------------------------------------------- # Routing policies # --------------------------------------------------------------------------- @router.get("/routing-policies", response_model=list[RoutingPolicyOut]) async def list_routing_policies( session: AsyncSession = Depends(get_session), ) -> list[RoutingPolicyOut]: policies = await RoutingPolicyRepository(session).list() return [ RoutingPolicyOut( id=p.id, name=p.name, description=p.description, mode=p.mode.value, is_default=p.is_default, is_active=p.is_active, ) for p in policies ] @router.post( "/routing-policies", response_model=RoutingPolicyOut, status_code=status.HTTP_201_CREATED ) async def create_routing_policy( payload: CreateRoutingPolicyRequest, request: Request, owner: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> RoutingPolicyOut: policy = await RoutingPolicyRepository(session).create( RoutingPolicy( name=payload.name, mode=payload.mode, description=payload.description, is_default=payload.is_default, budget_limits_json=payload.budget_limits_json, fallback_policy_json=payload.fallback_policy_json, ) ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.ROUTING_CHANGE, actor_user_id=owner.id, target_type="routing_policy", target_id=policy.id, metadata={"name": policy.name, "mode": policy.mode.value}, ip=ip, user_agent=ua, ) return RoutingPolicyOut( id=policy.id, name=policy.name, description=policy.description, mode=policy.mode.value, is_default=policy.is_default, is_active=policy.is_active, ) # --------------------------------------------------------------------------- # Rate cards # --------------------------------------------------------------------------- @router.get("/rate-cards", response_model=list[RateCardOut]) async def list_rate_cards(session: AsyncSession = Depends(get_session)) -> list[RateCardOut]: cards = await RateCardRepository(session).list() return [ RateCardOut( id=c.id, name=c.name, unit=c.unit.value, customer_price=c.customer_price, internal_cost_estimate=c.internal_cost_estimate, is_active=c.is_active, ) for c in cards ] @router.put("/rate-cards", response_model=RateCardOut) async def upsert_rate_card( payload: UpsertRateCardRequest, request: Request, owner: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> RateCardOut: card = await RateCardRepository(session).upsert( RateCard( name=payload.name or payload.unit.value.replace("_", " "), unit=payload.unit, customer_price=payload.customer_price, internal_cost_estimate=payload.internal_cost_estimate, is_active=payload.is_active, ) ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.BILLING_CREDIT, actor_user_id=owner.id, target_type="rate_card", target_id=card.id, metadata={"unit": card.unit.value, "customer_price": card.customer_price}, ip=ip, user_agent=ua, ) return RateCardOut( id=card.id, name=card.name, unit=card.unit.value, customer_price=card.customer_price, internal_cost_estimate=card.internal_cost_estimate, is_active=card.is_active, ) # --------------------------------------------------------------------------- # Cross-organization visibility # --------------------------------------------------------------------------- @router.get("/organizations", response_model=list[AdminOrganizationOut]) async def list_all_organizations( session: AsyncSession = Depends(get_session), ) -> list[AdminOrganizationOut]: organizations = await OrganizationRepository(session).list_all() memberships = MembershipRepository(session) out: list[AdminOrganizationOut] = [] for org in organizations: members = await memberships.list_for_org(org.id) out.append( AdminOrganizationOut( id=org.id, name=org.name, slug=org.slug, member_count=len(members), created_at=org.created_at, ) ) return out @router.get("/usage", response_model=list[AdminUsageRow]) async def platform_usage( session: AsyncSession = Depends(get_session), ) -> list[AdminUsageRow]: rows = await UsageLedgerRepository(session).platform_totals() return [ AdminUsageRow( organization_id=org_id, quantity=quantity, total_price=total_price ) for org_id, quantity, total_price in rows ] @router.post("/validate-model-choice") async def validate_model_choice( mode: RoutingMode, _: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> dict: """Dry-run of routing: which model would this mode pick, and why?""" model = await AiModelRepository(session).pick_for_mode(mode) if model is None: raise ValidationError("هیچ مدل فعالی برای این حالت مسیریابی وجود ندارد.") return { "mode": mode.value, "model_id": model.id, "internal_name": model.internal_name, "capabilities": list(model.capabilities), "estimated_input_price_per_1m": model.input_price_per_1m, "estimated_output_price_per_1m": model.output_price_per_1m, } # --------------------------------------------------------------------------- # Error console # # Every failure the platform catches lands in ``error_events``. This is where a # platform owner reads them: grouped by fingerprint so one recurring bug is one # line, with the full record (stack, request id, tenant, actor) one click away. # # Resolution is *per fingerprint* for the group view — fixing a bug means the # whole group is dealt with — and per row for a single occurrence. # --------------------------------------------------------------------------- class ErrorEventOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str kind: str message: str stack: str severity: str source: str error_type: str fingerprint: str status_code: int | None method: str path: str organization_id: str | None user_id: str | None run_id: str | None request_id: str ip: str user_agent: str metadata_json: dict resolved_at: datetime | None resolved_note: str created_at: datetime class ErrorGroupOut(BaseModel): fingerprint: str occurrences: int open_count: int first_seen: datetime last_seen: datetime kind: str error_type: str severity: str source: str path: str sample_message: str class ErrorSummaryOut(BaseModel): open_by_severity: dict[str, int] total_open: int class ErrorListOut(BaseModel): items: list[ErrorEventOut] total: int limit: int offset: int class ResolveErrorIn(BaseModel): note: str = Field(default="", max_length=2000) class ResolveErrorOut(BaseModel): fingerprint: str resolved: int #: Newest-first page size ceiling. An unbounded ``limit`` would let one request #: pull the entire table — including every stack trace — into memory. MAX_ERROR_PAGE_SIZE = 200 @router.get("/errors", response_model=ErrorListOut) async def list_errors( severity: str | None = Query(default=None), source: str | None = Query(default=None), status_filter: str | None = Query(default=None, alias="status"), kind: str | None = Query(default=None), organization_id: str | None = Query(default=None), fingerprint: str | None = Query(default=None), search: str | None = Query(default=None, max_length=200), since: datetime | None = Query(default=None), limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE), offset: int = Query(default=0, ge=0), session: AsyncSession = Depends(get_session), ) -> ErrorListOut: """Individual occurrences, newest first.""" repo = ObservabilityRepository(session) filters = { "severity": severity, "source": source, "status": status_filter, "kind": kind, "organization_id": organization_id, "fingerprint": fingerprint, "search": search, "since": since, } items = await repo.list_errors(limit=limit, offset=offset, **filters) total = await repo.count_errors(**filters) return ErrorListOut( items=[ErrorEventOut.model_validate(item) for item in items], total=total, limit=limit, offset=offset, ) @router.get("/errors/summary", response_model=ErrorSummaryOut) async def error_summary( session: AsyncSession = Depends(get_session), ) -> ErrorSummaryOut: """Unresolved counts, for the console's header tiles.""" by_severity = await ObservabilityRepository(session).count_open_by_severity() return ErrorSummaryOut( open_by_severity=by_severity, total_open=sum(by_severity.values()) ) # Declared before ``/errors/{error_id}`` on purpose: FastAPI matches routes in # declaration order, so the other way round "groups" would be captured as an id. @router.get("/errors/groups", response_model=list[ErrorGroupOut]) async def list_error_groups( status_filter: str | None = Query(default=None, alias="status"), severity: str | None = Query(default=None), source: str | None = Query(default=None), organization_id: str | None = Query(default=None), since: datetime | None = Query(default=None), limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE), session: AsyncSession = Depends(get_session), ) -> list[ErrorGroupOut]: """One row per distinct bug, most recently active first.""" groups = await ObservabilityRepository(session).list_error_groups( limit=limit, since=since, status=status_filter, severity=severity, source=source, organization_id=organization_id, ) return [ErrorGroupOut(**group) for group in groups] @router.get("/errors/{error_id}", response_model=ErrorEventOut) async def get_error( error_id: str, session: AsyncSession = Depends(get_session), ) -> ErrorEventOut: event = await ObservabilityRepository(session).get_error(error_id) if event is None: raise NotFoundError("خطای مورد نظر یافت نشد.") return ErrorEventOut.model_validate(event) @router.post("/errors/{error_id}/resolve", response_model=ResolveErrorOut) async def resolve_error( error_id: str, payload: ResolveErrorIn, request: Request, owner: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> ResolveErrorOut: """Mark one occurrence as dealt with.""" repo = ObservabilityRepository(session) event = await repo.resolve_error(error_id, note=payload.note, resolved_at=utcnow()) if event is None: raise NotFoundError("خطای مورد نظر یافت نشد.") ip, ua = client_meta(request) await record_audit( session, action=AuditAction.ERROR_RESOLVE, actor_user_id=owner.id, target_type="error_event", target_id=error_id, metadata={"fingerprint": event.fingerprint, "note": payload.note[:200]}, ip=ip, user_agent=ua, ) return ResolveErrorOut(fingerprint=event.fingerprint, resolved=1) @router.post("/errors/groups/{fingerprint}/resolve", response_model=ResolveErrorOut) async def resolve_error_group( fingerprint: str, payload: ResolveErrorIn, request: Request, owner: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> ResolveErrorOut: """Mark every open occurrence of one bug as dealt with.""" repo = ObservabilityRepository(session) resolved = await repo.resolve_error_group( fingerprint, note=payload.note, resolved_at=utcnow() ) if resolved == 0: raise NotFoundError("موردی برای بستن پیدا نشد؛ ممکن است قبلاً بسته شده باشد.") ip, ua = client_meta(request) await record_audit( session, action=AuditAction.ERROR_RESOLVE, actor_user_id=owner.id, target_type="error_group", target_id=fingerprint, metadata={"resolved": resolved, "note": payload.note[:200]}, ip=ip, user_agent=ua, ) return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved) @router.post("/errors/groups/{fingerprint}/reopen", response_model=ResolveErrorOut) async def reopen_error_group( fingerprint: str, _: User = Depends(require_platform_owner), session: AsyncSession = Depends(get_session), ) -> ResolveErrorOut: """Undo a resolution — for when the bug comes back.""" resolved = await ObservabilityRepository(session).reopen_error_group(fingerprint) return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)