Implement governed function assignment workflows
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user