580 lines
20 KiB
Python
580 lines
20 KiB
Python
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"]
|