feat: add governed ownership workflows and admin tree navigation
This commit is contained in:
@@ -0,0 +1,621 @@
|
||||
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"]
|
||||
Reference in New Issue
Block a user