Implement governed function assignment workflows

This commit is contained in:
2026-07-31 19:40:27 +02:00
parent f025b0c25b
commit c14719d55a
16 changed files with 4079 additions and 27 deletions
@@ -0,0 +1,551 @@
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,
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,
"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),
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"]