from __future__ import annotations from math import ceil from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from govoplan_core.audit.logging import audit_from_principal from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER from govoplan_core.core.concurrency import ( ConcurrencyError, MissingPreconditionError, RevisionConflictError, assert_revision_precondition, ) from govoplan_core.core.identity import ( CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory, ) from govoplan_core.core.organizations import ( CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory, OrganizationFunctionRef, ) from govoplan_core.core.policy import ( CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE, ) from govoplan_core.core.runtime import get_registry from govoplan_core.core.workflows import CAPABILITY_WORKFLOW_ORCHESTRATION from govoplan_core.db.session import get_session from govoplan_idm.backend.db.models import IdmFunctionAssignmentChange from govoplan_idm.backend.function_assignment_changes import ( FunctionAssignmentChangeConflict, FunctionAssignmentChangeUnavailable, available_change_actions, change_events, create_function_assignment_change, resolve_submission_capability, transition_function_assignment_change, visible_change_filter, ) from .schemas import ( FunctionAssignmentCapabilityItem, FunctionAssignmentChangeActionRequest, FunctionAssignmentChangeCreateRequest, FunctionAssignmentChangeEventItem, FunctionAssignmentChangeItem, FunctionAssignmentChangeKind, FunctionAssignmentChangeList, ) router = APIRouter(prefix="/function-assignment-changes") READ_SCOPES = ( "idm:function_change:read", "idm:function_request:create", "idm:function_grant:create", "idm:function_change:decide", "idm:function_change:admin", "idm:organization_assignment:write", ) def _require_any(principal: ApiPrincipal, *scopes: str) -> None: if not any(principal.has(scope) for scope in scopes): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {', '.join(scopes)}", ) def _registry(): registry = get_registry() if registry is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="The module registry is unavailable.", ) return registry def _organization_directory() -> OrganizationDirectory: registry = _registry() capability = registry.capability(CAPABILITY_ORGANIZATION_DIRECTORY) if not isinstance(capability, OrganizationDirectory): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="The Organizations directory is unavailable.", ) return capability def _identity_directory() -> IdentityDirectory: registry = _registry() capability = registry.capability(CAPABILITY_IDENTITY_DIRECTORY) if not isinstance(capability, IdentityDirectory): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="The Identity directory is unavailable.", ) return capability def _function(function_id: str, tenant_id: str) -> OrganizationFunctionRef: function = _organization_directory().get_function(function_id) if function is None or function.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Organization function not found.", ) return function def _historical_function( function_id: str, tenant_id: str, ) -> OrganizationFunctionRef | None: try: function = _organization_directory().get_function(function_id) except HTTPException: return None if function is None or function.tenant_id != tenant_id: return None return function def _validate_candidate(identity_id: str, account_id: str | None) -> None: directory = _identity_directory() if directory.get_identity(identity_id) is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found.", ) if account_id is not None and not any( link.account_id == account_id for link in directory.accounts_for_identity(identity_id) ): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Account is not linked to the selected identity.", ) def _change_query( session: Session, principal: ApiPrincipal, ): statement = select(IdmFunctionAssignmentChange).where( IdmFunctionAssignmentChange.tenant_id == principal.tenant_id ) visibility = visible_change_filter(session, principal) return statement.where(visibility) if visibility is not None else statement def _get_change( session: Session, principal: ApiPrincipal, change_id: str, ) -> IdmFunctionAssignmentChange: change = session.scalar( _change_query(session, principal).where( IdmFunctionAssignmentChange.id == change_id ) ) if change is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Function assignment change not found.", ) return change def _event_item(event) -> FunctionAssignmentChangeEventItem: return FunctionAssignmentChangeEventItem( id=event.id, sequence=event.sequence, action=event.action, from_state=event.from_state, to_state=event.to_state, actor_account_id=event.actor_account_id, actor_identity_id=event.actor_identity_id, actor_assignment_id=event.actor_assignment_id, comment=event.comment, evidence=list(event.evidence), policy_decision=dict(event.policy_decision), workflow_step_id=event.workflow_step_id, details=dict(event.details), created_at=event.created_at, ) def _change_item( session: Session, principal: ApiPrincipal, change: IdmFunctionAssignmentChange, *, include_events: bool, ) -> FunctionAssignmentChangeItem: function = _historical_function(change.function_id, change.tenant_id) if function is None: actions, reason = [], "The referenced organization function is no longer available." else: try: actions, reason = available_change_actions( session, principal=principal, registry=get_registry(), change=change, function=function, ) except FunctionAssignmentChangeUnavailable as exc: actions, reason = [], str(exc) return FunctionAssignmentChangeItem( id=change.id, tenant_id=change.tenant_id, kind=change.kind, state=change.state, profile=change.profile, function_id=change.function_id, organization_unit_id=change.organization_unit_id, candidate_identity_id=change.candidate_identity_id, candidate_account_id=change.candidate_account_id, initiator_account_id=change.initiator_account_id, initiator_identity_id=change.initiator_identity_id, represented_assignment_id=change.represented_assignment_id, justification=change.justification, evidence=list(change.evidence), requested_valid_from=change.requested_valid_from, requested_valid_until=change.requested_valid_until, applies_to_subunits=change.applies_to_subunits, assignment_source=change.assignment_source, required_steps=list(change.required_steps), completed_steps=list(change.completed_steps), policy_decision=dict(change.policy_decision), workflow_definition_id=change.workflow_definition_id, workflow_definition_revision_id=change.workflow_definition_revision_id, workflow_definition_revision=change.workflow_definition_revision, workflow_definition_hash=change.workflow_definition_hash, workflow_instance_id=change.workflow_instance_id, workflow_current_step_id=change.workflow_current_step_id, resulting_assignment_id=change.resulting_assignment_id, expires_at=change.expires_at, review_deadline_at=change.review_deadline_at, escalated_at=change.escalated_at, escalation_from_state=change.escalation_from_state, escalation_target_function_id=change.escalation_target_function_id, outcome_reason=change.outcome_reason, resource_revision=change.resource_revision, etag=change.strong_etag, metadata=dict(change.metadata_), events=( [ _event_item(event) for event in change_events(session, change_id=change.id) ] if include_events else [] ), available_actions=actions, availability_reason=reason, created_at=change.created_at, updated_at=change.updated_at, ) def _set_etag(response: Response, change: IdmFunctionAssignmentChange) -> None: response.headers["ETag"] = change.strong_etag def _record_change_audit( session: Session, principal: ApiPrincipal, *, change: IdmFunctionAssignmentChange, action: str, from_state: str | None, ) -> None: registry = get_registry() if registry is None or not registry.has_capability(CAPABILITY_AUDIT_RECORDER): return audit_from_principal( session, principal, action=f"idm.function_assignment_change.{action}", object_type="function_assignment_change", object_id=change.id, details={ "kind": change.kind, "from_state": from_state, "to_state": change.state, "function_id": change.function_id, "candidate_identity_id": change.candidate_identity_id, "represented_assignment_id": change.represented_assignment_id, "policy_decision": dict(change.policy_decision), "workflow_definition_id": change.workflow_definition_id, "workflow_definition_revision_id": change.workflow_definition_revision_id, "workflow_instance_id": change.workflow_instance_id, "evidence": list(change.evidence), "resulting_assignment_id": change.resulting_assignment_id, "review_deadline_at": ( change.review_deadline_at.isoformat() if change.review_deadline_at else None ), "escalated_at": ( change.escalated_at.isoformat() if change.escalated_at else None ), "escalation_from_state": change.escalation_from_state, "escalation_target_function_id": ( change.escalation_target_function_id ), "resource_revision": change.resource_revision, }, commit=False, ) def _mutation_error(exc: Exception) -> HTTPException: if isinstance(exc, MissingPreconditionError): return HTTPException( status_code=status.HTTP_428_PRECONDITION_REQUIRED, detail=exc.as_dict(), ) if isinstance(exc, RevisionConflictError): return HTTPException( status_code=status.HTTP_412_PRECONDITION_FAILED, detail=exc.as_dict(), ) if isinstance(exc, ConcurrencyError): return HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"code": "concurrency_conflict", "message": str(exc)}, ) if isinstance(exc, FunctionAssignmentChangeConflict): return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) return HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) @router.get("/capability", response_model=FunctionAssignmentCapabilityItem) def get_function_assignment_capability( kind: FunctionAssignmentChangeKind, function_id: str = Query(min_length=1, max_length=36), candidate_identity_id: str | None = Query(default=None, max_length=36), candidate_account_id: str | None = Query(default=None, max_length=36), has_evidence: bool = False, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> FunctionAssignmentCapabilityItem: _require_any(principal, *READ_SCOPES) candidate_identity_id = candidate_identity_id or principal.identity_id if candidate_identity_id is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="A candidate identity is required.", ) function = _function(function_id, principal.tenant_id) registry = _registry() decision, reason = resolve_submission_capability( session, principal=principal, registry=registry, kind=kind, function=function, candidate_identity_id=candidate_identity_id, candidate_account_id=candidate_account_id, has_evidence=has_evidence, ) return FunctionAssignmentCapabilityItem( kind=kind, function_id=function_id, available=bool(decision and decision.allowed), reason=reason, profile=decision.profile if decision else "unavailable", required_steps=list(decision.required_steps) if decision else [], requirements=list(decision.requirements) if decision else [], authority_function_id=(decision.authority_function_id if decision else None), evidence_required=bool(decision and decision.evidence_required), recipient_acceptance_required=bool( decision and decision.recipient_acceptance_required ), maximum_validity_days=(decision.maximum_validity_days if decision else None), delegation_allowed=bool(decision and decision.delegation_allowed), maximum_delegation_depth=( decision.maximum_delegation_depth if decision else 0 ), maximum_delegated_validity_days=( decision.maximum_delegated_validity_days if decision else None ), escalation_rules=( [rule.to_dict() for rule in decision.escalation_rules] if decision else [] ), workflow_available=registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION), policy_available=registry.has_capability( CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE ), ) @router.get("", response_model=FunctionAssignmentChangeList) def list_function_assignment_changes( kind: FunctionAssignmentChangeKind | None = None, state_filter: str | None = Query(default=None, alias="state", max_length=40), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> FunctionAssignmentChangeList: _require_any(principal, *READ_SCOPES) statement = _change_query(session, principal) if kind is not None: statement = statement.where(IdmFunctionAssignmentChange.kind == kind) if state_filter: statement = statement.where(IdmFunctionAssignmentChange.state == state_filter) total = int( session.scalar(select(func.count()).select_from(statement.subquery())) or 0 ) rows = list( session.scalars( statement.order_by( IdmFunctionAssignmentChange.updated_at.desc(), IdmFunctionAssignmentChange.id.desc(), ) .offset((page - 1) * page_size) .limit(page_size) ) ) return FunctionAssignmentChangeList( changes=[ _change_item(session, principal, change, include_events=False) for change in rows ], total=total, page=page, page_size=page_size, pages=max(1, ceil(total / page_size)), ) @router.get("/{change_id}", response_model=FunctionAssignmentChangeItem) def get_function_assignment_change( change_id: str, response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> FunctionAssignmentChangeItem: _require_any(principal, *READ_SCOPES) change = _get_change(session, principal, change_id) _set_etag(response, change) return _change_item(session, principal, change, include_events=True) @router.post("", response_model=FunctionAssignmentChangeItem, status_code=201) def create_governed_function_assignment_change( payload: FunctionAssignmentChangeCreateRequest, response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> FunctionAssignmentChangeItem: required = ( "idm:function_request:create" if payload.kind == "request" else "idm:function_grant:create" ) _require_any( principal, required, "idm:function_change:admin", "idm:organization_assignment:write", ) _validate_candidate(payload.candidate_identity_id, payload.candidate_account_id) try: change, replayed = create_function_assignment_change( session, principal=principal, registry=_registry(), function=_function(payload.function_id, principal.tenant_id), payload=payload, ) if not replayed: _record_change_audit( session, principal, change=change, action="created", from_state=None, ) session.commit() except ( FunctionAssignmentChangeConflict, FunctionAssignmentChangeUnavailable, ) as exc: session.rollback() raise _mutation_error(exc) from exc except IntegrityError as exc: session.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="The function assignment change conflicts with existing data.", ) from exc session.refresh(change) response.status_code = status.HTTP_200_OK if replayed else status.HTTP_201_CREATED _set_etag(response, change) return _change_item(session, principal, change, include_events=True) @router.post("/{change_id}/actions", response_model=FunctionAssignmentChangeItem) def act_on_function_assignment_change( change_id: str, payload: FunctionAssignmentChangeActionRequest, response: Response, if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> FunctionAssignmentChangeItem: change = _get_change(session, principal, change_id) from_state = change.state if payload.action == "recover": _require_any(principal, "idm:function_change:admin") elif payload.action not in {"withdraw", "respond"}: _require_any( principal, "idm:function_change:decide", "idm:function_change:admin", "idm:organization_assignment:write", ) try: assert_revision_precondition( if_match, resource_type="idm_function_assignment_change", resource_id=change.id, submitted_base_revision=payload.base_revision, ) change = transition_function_assignment_change( session, principal=principal, registry=_registry(), change=change, function=_function(change.function_id, change.tenant_id), action=payload.action, base_revision=payload.base_revision, comment=payload.comment, evidence=payload.evidence, ) _record_change_audit( session, principal, change=change, action=payload.action, from_state=from_state, ) session.commit() except ( ConcurrencyError, FunctionAssignmentChangeConflict, FunctionAssignmentChangeUnavailable, ) as exc: session.rollback() raise _mutation_error(exc) from exc except IntegrityError as exc: session.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="The function assignment transition conflicts with existing data.", ) from exc session.refresh(change) _set_etag(response, change) return _change_item(session, principal, change, include_events=True) __all__ = ["router"]