from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.core.ownership import ( OwnershipAuthorizationError, OwnershipIdempotencyConflict, OwnershipResourceRef, OwnershipSubjectRef, OwnershipTransfer, OwnershipTransferError, OwnershipTransferExpired, OwnershipTransferPolicy, accept_ownership_transfer, approve_administrative_recovery, approve_ownership_request, cancel_ownership_transfer, decline_ownership_transfer, execute_administrative_recovery, request_ownership, start_administrative_recovery, start_owner_initiated_transfer, ) from govoplan_core.core.registry import PlatformRegistry from govoplan_core.db.session import get_session RECENT_AUTHENTICATION_WINDOW = timedelta(minutes=15) class OwnershipSubjectRequest(BaseModel): type: str = Field(min_length=1, max_length=40, pattern=r"^[a-z][a-z0-9_-]*$") id: str = Field(min_length=1, max_length=255) class OwnershipResourceRequest(BaseModel): module_id: str = Field( min_length=1, max_length=100, pattern=r"^[a-z][a-z0-9_-]*$", ) resource_type: str = Field( min_length=1, max_length=100, pattern=r"^[a-z][a-z0-9_-]*$", ) resource_id: str = Field(min_length=1, max_length=255) class OwnershipTransferStartRequest(BaseModel): resource: OwnershipResourceRequest target_owner: OwnershipSubjectRequest idempotency_key: str = Field(min_length=1, max_length=200) reason: str | None = Field(default=None, max_length=4000) expiry_days: int | None = Field(default=None, ge=1, le=30) class OwnershipRequestStartRequest(BaseModel): resource: OwnershipResourceRequest target_owner: OwnershipSubjectRequest | None = None idempotency_key: str = Field(min_length=1, max_length=200) reason: str | None = Field(default=None, max_length=4000) expiry_days: int | None = Field(default=None, ge=1, le=30) class OwnershipRecoveryStartRequest(BaseModel): resource: OwnershipResourceRequest target_owner: OwnershipSubjectRequest idempotency_key: str = Field(min_length=1, max_length=200) reason: str = Field(min_length=1, max_length=4000) class OwnershipTransferResponse(BaseModel): id: str tenant_id: str resource_module: str resource_type: str resource_id: str kind: str status: str current_owner: OwnershipSubjectRequest target_owner: OwnershipSubjectRequest initiated_by: OwnershipSubjectRequest owner_approved_by: OwnershipSubjectRequest | None = None target_accepted_by: OwnershipSubjectRequest | None = None reason: str | None = None assurance_profile: str | None = None required_approvals: int approvals: list[dict[str, Any]] = Field(default_factory=list) decisions: list[dict[str, Any]] = Field(default_factory=list) expires_at: datetime execute_after: datetime | None = None completed_at: datetime | None = None declined_at: datetime | None = None cancelled_at: datetime | None = None expired_at: datetime | None = None revision: int metadata: dict[str, Any] = Field(default_factory=dict) created_at: datetime updated_at: datetime class OwnershipTransferListResponse(BaseModel): transfers: list[OwnershipTransferResponse] = Field(default_factory=list) router = APIRouter(prefix="/ownership/transfers", tags=["ownership"]) @router.get("", response_model=OwnershipTransferListResponse) def list_ownership_transfers( request: Request, resource_type: str | None = Query(default=None, max_length=100), resource_id: str | None = Query(default=None, max_length=255), transfer_status: str | None = Query(default=None, alias="status", max_length=50), limit: int = Query(default=50, ge=1, le=200), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferListResponse: actor = _actor(principal) query = session.query(OwnershipTransfer).filter( OwnershipTransfer.tenant_id == principal.tenant_id ) if resource_type: query = query.filter( OwnershipTransfer.resource_type == resource_type.strip().lower() ) if resource_id: query = query.filter(OwnershipTransfer.resource_id == resource_id) if transfer_status: query = query.filter(OwnershipTransfer.status == transfer_status) rows = ( query.order_by( OwnershipTransfer.created_at.desc(), OwnershipTransfer.id.desc(), ) .limit(min(limit * 5, 1000)) .all() ) visible = [ row for row in rows if _can_view_transfer(request, session, row, actor) ] return OwnershipTransferListResponse( transfers=[_response(row) for row in visible[:limit]] ) @router.get("/{transfer_id}", response_model=OwnershipTransferResponse) def get_ownership_transfer( transfer_id: str, request: Request, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferResponse: transfer = _visible_transfer( request, session, principal, transfer_id, ) return _response(transfer) @router.post( "", response_model=OwnershipTransferResponse, status_code=status.HTTP_201_CREATED, ) def create_owner_initiated_transfer( payload: OwnershipTransferStartRequest, request: Request, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferResponse: return _write( session, lambda: start_owner_initiated_transfer( session, tenant_id=principal.tenant_id, resource=_resource(payload.resource), provider=_provider(request, payload.resource), actor=_actor(principal), target_owner=_subject(payload.target_owner), idempotency_key=payload.idempotency_key, reason=payload.reason, expiry_days=payload.expiry_days, ), ) @router.post( "/requests", response_model=OwnershipTransferResponse, status_code=status.HTTP_201_CREATED, ) def create_ownership_request( payload: OwnershipRequestStartRequest, request: Request, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferResponse: return _write( session, lambda: request_ownership( session, tenant_id=principal.tenant_id, resource=_resource(payload.resource), provider=_provider(request, payload.resource), actor=_actor(principal), target_owner=( _subject(payload.target_owner) if payload.target_owner else None ), idempotency_key=payload.idempotency_key, reason=payload.reason, expiry_days=payload.expiry_days, ), ) @router.post( "/recoveries", response_model=OwnershipTransferResponse, status_code=status.HTTP_201_CREATED, ) def create_administrative_recovery( payload: OwnershipRecoveryStartRequest, request: Request, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferResponse: _require_recovery_authority(principal) return _write( session, lambda: start_administrative_recovery( session, tenant_id=principal.tenant_id, resource=_resource(payload.resource), provider=_provider(request, payload.resource), actor=_actor(principal), target_owner=_subject(payload.target_owner), idempotency_key=payload.idempotency_key, reason=payload.reason, policy=OwnershipTransferPolicy(), ), ) @router.post("/{transfer_id}/owner-approval", response_model=OwnershipTransferResponse) def approve_requested_transfer( transfer_id: str, request: Request, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferResponse: return _mutate_existing( request, session, principal, transfer_id, approve_ownership_request, ) @router.post("/{transfer_id}/acceptance", response_model=OwnershipTransferResponse) def accept_transfer( transfer_id: str, request: Request, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferResponse: return _mutate_existing( request, session, principal, transfer_id, accept_ownership_transfer, ) @router.post("/{transfer_id}/decline", response_model=OwnershipTransferResponse) def decline_transfer( transfer_id: str, request: Request, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferResponse: return _mutate_existing( request, session, principal, transfer_id, decline_ownership_transfer, ) @router.post("/{transfer_id}/cancel", response_model=OwnershipTransferResponse) def cancel_transfer( transfer_id: str, request: Request, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferResponse: return _mutate_existing( request, session, principal, transfer_id, cancel_ownership_transfer, ) @router.post( "/{transfer_id}/recovery-approval", response_model=OwnershipTransferResponse, ) def approve_recovery( transfer_id: str, request: Request, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferResponse: _require_recovery_authority(principal) return _mutate_existing( request, session, principal, transfer_id, approve_administrative_recovery, ) @router.post( "/{transfer_id}/recovery-execution", response_model=OwnershipTransferResponse, ) def execute_recovery( transfer_id: str, request: Request, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ) -> OwnershipTransferResponse: _require_recovery_authority(principal) return _mutate_existing( request, session, principal, transfer_id, execute_administrative_recovery, ) def _mutate_existing( request: Request, session: Session, principal: ApiPrincipal, transfer_id: str, operation, ) -> OwnershipTransferResponse: transfer = _tenant_transfer( session, principal, transfer_id, for_update=True, ) provider = _provider_for_transfer(request, transfer) return _write( session, lambda: operation( session, transfer=transfer, provider=provider, actor=_actor(principal), ), ) def _write(session: Session, operation) -> OwnershipTransferResponse: try: transfer = operation() session.commit() session.refresh(transfer) return _response(transfer) except OwnershipAuthorizationError as exc: session.rollback() raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=str(exc), ) from exc except OwnershipIdempotencyConflict as exc: session.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc), ) from exc except OwnershipTransferExpired as exc: session.commit() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc), ) from exc except OwnershipTransferError as exc: session.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc), ) from exc def _tenant_transfer( session: Session, principal: ApiPrincipal, transfer_id: str, *, for_update: bool = False, ) -> OwnershipTransfer: query = session.query(OwnershipTransfer).filter( OwnershipTransfer.id == transfer_id, OwnershipTransfer.tenant_id == principal.tenant_id, ) if for_update: query = query.with_for_update() transfer = query.one_or_none() if transfer is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Ownership transfer not found", ) return transfer def _visible_transfer( request: Request, session: Session, principal: ApiPrincipal, transfer_id: str, ) -> OwnershipTransfer: transfer = _tenant_transfer(session, principal, transfer_id) if not _can_view_transfer(request, session, transfer, _actor(principal)): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Ownership transfer not found", ) return transfer def _can_view_transfer( request: Request, session: Session, transfer: OwnershipTransfer, actor: OwnershipSubjectRef, ) -> bool: provider = _provider_for_transfer(request, transfer) decision = provider.authorize_ownership_action( session, tenant_id=transfer.tenant_id, resource_id=transfer.resource_id, action="view_transfer", actor=actor, current_owner=OwnershipSubjectRef( type=transfer.current_owner_type, id=transfer.current_owner_id, ), target_owner=OwnershipSubjectRef( type=transfer.target_owner_type, id=transfer.target_owner_id, ), ) return decision.allowed def _provider(request: Request, resource: OwnershipResourceRequest): registry = _registry(request) registration = registry.ownership_provider_registration(resource.resource_type) if ( registration is None or registration.module_id != resource.module_id ): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Owned resource type is not available", ) return registration.provider def _provider_for_transfer(request: Request, transfer: OwnershipTransfer): return _provider( request, OwnershipResourceRequest( module_id=transfer.resource_module, resource_type=transfer.resource_type, resource_id=transfer.resource_id, ), ) def _registry(request: Request) -> PlatformRegistry: registry = getattr(request.app.state, "govoplan_registry", None) if not isinstance(registry, PlatformRegistry): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Platform registry is not available", ) return registry def _actor(principal: ApiPrincipal) -> OwnershipSubjectRef: actor_id = principal.membership_id if not actor_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Ownership actions require a tenant membership", ) return OwnershipSubjectRef( type="user", id=actor_id, label=principal.display_name, scopes=principal.scopes, group_ids=principal.group_ids, recently_authenticated=_recently_authenticated(principal), ) def _recently_authenticated(principal: ApiPrincipal) -> bool: auth_session = principal.auth_session created_at = getattr(auth_session, "created_at", None) if not isinstance(created_at, datetime): return False if created_at.tzinfo is None: created_at = created_at.replace(tzinfo=timezone.utc) elapsed = datetime.now(timezone.utc) - created_at return timedelta(0) <= elapsed <= RECENT_AUTHENTICATION_WINDOW def _require_recovery_authority(principal: ApiPrincipal) -> None: if not ( principal.has("admin:settings:write") or principal.has("system:settings:write") ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Administrative ownership recovery authority is required", ) def _resource(payload: OwnershipResourceRequest) -> OwnershipResourceRef: return OwnershipResourceRef( module_id=payload.module_id, resource_type=payload.resource_type, resource_id=payload.resource_id, ) def _subject(payload: OwnershipSubjectRequest) -> OwnershipSubjectRef: return OwnershipSubjectRef(type=payload.type, id=payload.id) def _optional_subject( subject_type: str | None, subject_id: str | None, ) -> OwnershipSubjectRequest | None: if not subject_type or not subject_id: return None return OwnershipSubjectRequest(type=subject_type, id=subject_id) def _response(transfer: OwnershipTransfer) -> OwnershipTransferResponse: return OwnershipTransferResponse( id=transfer.id, tenant_id=transfer.tenant_id, resource_module=transfer.resource_module, resource_type=transfer.resource_type, resource_id=transfer.resource_id, kind=transfer.kind, status=transfer.status, current_owner=OwnershipSubjectRequest( type=transfer.current_owner_type, id=transfer.current_owner_id, ), target_owner=OwnershipSubjectRequest( type=transfer.target_owner_type, id=transfer.target_owner_id, ), initiated_by=OwnershipSubjectRequest( type=transfer.initiated_by_type, id=transfer.initiated_by_id, ), owner_approved_by=_optional_subject( transfer.owner_approved_by_type, transfer.owner_approved_by_id, ), target_accepted_by=_optional_subject( transfer.target_accepted_by_type, transfer.target_accepted_by_id, ), reason=transfer.reason, assurance_profile=transfer.assurance_profile, required_approvals=transfer.required_approvals, approvals=list(transfer.approvals or []), decisions=list(transfer.decisions or []), expires_at=transfer.expires_at, execute_after=transfer.execute_after, completed_at=transfer.completed_at, declined_at=transfer.declined_at, cancelled_at=transfer.cancelled_at, expired_at=transfer.expired_at, revision=transfer.revision, metadata=dict(transfer.metadata_ or {}), created_at=transfer.created_at, updated_at=transfer.updated_at, ) __all__ = ["router"]