from __future__ import annotations from datetime import datetime from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field, SecretStr from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.db.session import get_session from govoplan_core.security.credential_envelopes import ( CredentialEnvelope, CredentialEnvelopeError, create_credential_envelope, credential_envelope_summary, get_managed_credential_envelope, list_managed_credential_envelopes, normalize_credential_scope, retire_credential_envelope, update_credential_envelope, ) CredentialScopeType = Literal["system", "tenant", "group", "user", "campaign"] class CredentialEnvelopeResponse(BaseModel): id: str tenant_id: str | None = None scope_type: CredentialScopeType scope_id: str | None = None name: str description: str | None = None credential_kind: str public_data: dict[str, Any] = Field(default_factory=dict) secret_keys: list[str] = Field(default_factory=list) secret_configured: bool = False allowed_modules: list[str] = Field(default_factory=list) allowed_server_refs: list[str] = Field(default_factory=list) inherit_to_lower_scopes: bool = False is_active: bool = True revision: str created_at: datetime | None = None updated_at: datetime | None = None deleted_at: datetime | None = None class CredentialEnvelopeListResponse(BaseModel): credentials: list[CredentialEnvelopeResponse] = Field(default_factory=list) class CredentialEnvelopeCreateRequest(BaseModel): scope_type: CredentialScopeType = "tenant" scope_id: str | None = Field(default=None, max_length=255) name: str = Field(min_length=1, max_length=255) description: str | None = None credential_kind: str = Field(default="username_password", max_length=40) public_data: dict[str, Any] = Field(default_factory=dict) secret_data: dict[str, SecretStr] = Field(default_factory=dict) allowed_modules: list[str] = Field(default_factory=list) allowed_server_refs: list[str] = Field(default_factory=list) inherit_to_lower_scopes: bool = False is_active: bool = True class CredentialEnvelopeUpdateRequest(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=255) description: str | None = None credential_kind: str | None = Field(default=None, max_length=40) public_data: dict[str, Any] | None = None secret_data: dict[str, SecretStr] | None = None clear_secret: bool = False allowed_modules: list[str] | None = None allowed_server_refs: list[str] | None = None inherit_to_lower_scopes: bool | None = None is_active: bool | None = None router = APIRouter(prefix="/credentials", tags=["credentials"]) @router.get("", response_model=CredentialEnvelopeListResponse) def list_credentials( scope_type: CredentialScopeType = Query(default="tenant"), scope_id: str | None = Query(default=None), include_inactive: bool = Query(default=False), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> CredentialEnvelopeListResponse: tenant_id, normalized_scope_type, normalized_scope_id = _target_scope( principal, scope_type=scope_type, scope_id=scope_id, write=False, ) rows = list_managed_credential_envelopes( session, tenant_id=tenant_id, include_inactive=include_inactive, ) return CredentialEnvelopeListResponse( credentials=[ _response(row) for row in rows if row.scope_type == normalized_scope_type and row.scope_id == normalized_scope_id ] ) @router.post( "", response_model=CredentialEnvelopeResponse, status_code=status.HTTP_201_CREATED, ) def create_credential( payload: CredentialEnvelopeCreateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> CredentialEnvelopeResponse: tenant_id, scope_type, scope_id = _target_scope( principal, scope_type=payload.scope_type, scope_id=payload.scope_id, write=True, ) try: row = create_credential_envelope( session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id, name=payload.name, description=payload.description, credential_kind=payload.credential_kind, public_data=payload.public_data, secret_data=_secret_values(payload.secret_data), allowed_modules=payload.allowed_modules, allowed_server_refs=payload.allowed_server_refs, inherit_to_lower_scopes=payload.inherit_to_lower_scopes, is_active=payload.is_active, user_id=principal.membership_id, metadata={"created_by_module": "core"}, ) session.commit() session.refresh(row) return _response(row) except CredentialEnvelopeError as exc: session.rollback() raise _credential_error(exc) from exc @router.patch("/{credential_id}", response_model=CredentialEnvelopeResponse) def update_credential( credential_id: str, payload: CredentialEnvelopeUpdateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> CredentialEnvelopeResponse: try: row = _managed_row_for_write(session, principal, credential_id) row = update_credential_envelope( session, row, name=payload.name, description=payload.description, description_supplied="description" in payload.model_fields_set, credential_kind=payload.credential_kind, public_data=payload.public_data, secret_data=( _secret_values(payload.secret_data) if payload.secret_data is not None else None ), clear_secret=payload.clear_secret, allowed_modules=payload.allowed_modules, allowed_server_refs=payload.allowed_server_refs, inherit_to_lower_scopes=payload.inherit_to_lower_scopes, is_active=payload.is_active, user_id=principal.membership_id, ) session.commit() session.refresh(row) return _response(row) except CredentialEnvelopeError as exc: session.rollback() raise _credential_error(exc) from exc @router.delete("/{credential_id}", response_model=CredentialEnvelopeResponse) def delete_credential( credential_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> CredentialEnvelopeResponse: try: row = _managed_row_for_write(session, principal, credential_id) retire_credential_envelope( session, row, user_id=principal.membership_id, ) response = _response(row) session.commit() return response except CredentialEnvelopeError as exc: session.rollback() raise _credential_error(exc) from exc def _managed_row_for_write( session: Session, principal: ApiPrincipal, credential_id: str, ) -> CredentialEnvelope: system_row = None if _can_manage_system_credentials(principal): try: system_row = get_managed_credential_envelope( session, credential_id=credential_id, tenant_id=None, for_update=True, ) except CredentialEnvelopeError: pass row = system_row or get_managed_credential_envelope( session, credential_id=credential_id, tenant_id=principal.tenant_id, for_update=True, ) _target_scope( principal, scope_type=row.scope_type, scope_id=row.scope_id, write=True, ) return row def _target_scope( principal: ApiPrincipal, *, scope_type: str, scope_id: str | None, write: bool, ) -> tuple[str | None, str, str | None]: requested_type = str(scope_type or "tenant").strip().casefold() requested_id = str(scope_id).strip() if scope_id else None if requested_type == "system": if not ( _can_manage_system_credentials(principal) if write else _can_read_system_credentials(principal) ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="System credential permission is required.", ) return _normalize_target_scope( tenant_id=None, scope_type="system", scope_id=None, ) if requested_type == "user" and requested_id == principal.membership_id: own_scope = ( has_scope(principal, "access:credential:manage_own") or has_scope(principal, "mail:secret:manage_own") ) if own_scope: return _normalize_target_scope( tenant_id=principal.tenant_id, scope_type=requested_type, scope_id=requested_id, ) allowed = ( _can_manage_tenant_credentials(principal) if write else _can_read_tenant_credentials(principal) ) if not allowed: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Tenant credential permission is required.", ) return _normalize_target_scope( tenant_id=principal.tenant_id, scope_type=requested_type, scope_id=requested_id, ) def _normalize_target_scope( *, tenant_id: str | None, scope_type: str, scope_id: str | None, ) -> tuple[str | None, str, str | None]: try: return normalize_credential_scope( tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id, ) except CredentialEnvelopeError as exc: raise _credential_error(exc) from exc def _can_read_system_credentials(principal: ApiPrincipal) -> bool: return any( has_scope(principal, scope) for scope in ( "access:system_credential:read", "access:system_setting:read", "system:settings:read", ) ) def _can_manage_system_credentials(principal: ApiPrincipal) -> bool: return any( has_scope(principal, scope) for scope in ( "access:system_credential:write", "access:system_setting:write", "system:settings:write", ) ) def _can_read_tenant_credentials(principal: ApiPrincipal) -> bool: return any( has_scope(principal, scope) for scope in ( "access:credential:read", "access:setting:read", "admin:settings:read", "mail_servers:read", ) ) def _can_manage_tenant_credentials(principal: ApiPrincipal) -> bool: return any( has_scope(principal, scope) for scope in ( "access:credential:write", "access:setting:write", "admin:settings:write", "mail_servers:manage_credentials", ) ) def _secret_values(values: dict[str, SecretStr]) -> dict[str, str]: return { str(key): value.get_secret_value() for key, value in values.items() if str(key).strip() and value.get_secret_value() } def _response(row: CredentialEnvelope) -> CredentialEnvelopeResponse: return CredentialEnvelopeResponse.model_validate( credential_envelope_summary(row) ) def _credential_error(exc: CredentialEnvelopeError) -> HTTPException: return HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) __all__ = ["router"]