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"]
|
||||
@@ -284,7 +284,14 @@ def _plan_assignment_update(
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> AssignmentMutationPlan:
|
||||
values = payload.model_dump(exclude_unset=True, exclude={"change_request_id"})
|
||||
values = payload.model_dump(
|
||||
exclude_unset=True,
|
||||
exclude={
|
||||
"change_request_id",
|
||||
"governance_override_reason",
|
||||
"governance_override_evidence",
|
||||
},
|
||||
)
|
||||
organization_unit_id: str | None = None
|
||||
if "function_id" in values and values["function_id"] is not None:
|
||||
function = _organization_function(str(values["function_id"]), tenant_id)
|
||||
@@ -330,16 +337,32 @@ def _actor_id(principal: ApiPrincipal) -> str:
|
||||
return principal.membership_id or principal.account_id
|
||||
|
||||
|
||||
def _payload_for_control(resource_type: str, operation: str, payload: object) -> dict[str, Any]:
|
||||
def _payload_for_control(
|
||||
resource_type: str, operation: str, payload: object
|
||||
) -> dict[str, Any]:
|
||||
if hasattr(payload, "model_dump"):
|
||||
values = payload.model_dump(mode="json", exclude={"change_request_id"}, exclude_unset=True) # type: ignore[attr-defined]
|
||||
values = payload.model_dump( # type: ignore[attr-defined]
|
||||
mode="json",
|
||||
exclude={
|
||||
"change_request_id",
|
||||
"governance_override_reason",
|
||||
"governance_override_evidence",
|
||||
},
|
||||
exclude_unset=True,
|
||||
)
|
||||
else:
|
||||
values = {}
|
||||
return {"resource_type": resource_type, "operation": operation, "payload": values}
|
||||
|
||||
|
||||
def _target_for_control(tenant_id: str, resource_type: str, operation: str, resource_id: str | None = None) -> dict[str, Any]:
|
||||
target: dict[str, Any] = {"tenant_id": tenant_id, "resource_type": resource_type, "operation": operation}
|
||||
def _target_for_control(
|
||||
tenant_id: str, resource_type: str, operation: str, resource_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
target: dict[str, Any] = {
|
||||
"tenant_id": tenant_id,
|
||||
"resource_type": resource_type,
|
||||
"operation": operation,
|
||||
}
|
||||
if resource_id is not None:
|
||||
target["resource_id"] = resource_id
|
||||
return target
|
||||
@@ -440,6 +463,48 @@ def _publish_assignment_event(
|
||||
)
|
||||
|
||||
|
||||
def _apply_governance_override(
|
||||
principal: ApiPrincipal,
|
||||
function: OrganizationFunctionRef,
|
||||
payload: object,
|
||||
settings: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
governance = function.settings.get("assignment_governance")
|
||||
if not isinstance(governance, dict) or not any(
|
||||
str(governance.get(key) or "unavailable").strip().casefold() != "unavailable"
|
||||
for key in ("request_profile", "grant_profile")
|
||||
):
|
||||
return settings
|
||||
if not principal.has("idm:function_change:admin"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Direct assignment to a governed function requires function "
|
||||
"change recovery permission."
|
||||
),
|
||||
)
|
||||
reason = str(getattr(payload, "governance_override_reason", None) or "").strip()
|
||||
if not reason:
|
||||
raise _invalid(
|
||||
"Direct assignment to a governed function requires an emergency override reason."
|
||||
)
|
||||
evidence = [
|
||||
str(item).strip()
|
||||
for item in getattr(payload, "governance_override_evidence", ())
|
||||
if str(item).strip()
|
||||
]
|
||||
return {
|
||||
**settings,
|
||||
"governance_override": {
|
||||
"reason": reason,
|
||||
"evidence": evidence,
|
||||
"actor_account_id": principal.account_id,
|
||||
"recorded_at": utc_now().isoformat(),
|
||||
"function_id": function.id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/settings", response_model=IdmSettingsItem)
|
||||
def get_idm_settings(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -545,6 +610,12 @@ def create_organization_function_assignment(
|
||||
_ensure_identity(payload.identity_id)
|
||||
_ensure_account_link(payload.identity_id, payload.account_id)
|
||||
function = _organization_function(payload.function_id, tenant_id)
|
||||
item_settings = _apply_governance_override(
|
||||
principal,
|
||||
function,
|
||||
payload,
|
||||
dict(payload.settings),
|
||||
)
|
||||
item = IdmOrganizationFunctionAssignment(
|
||||
tenant_id=tenant_id,
|
||||
identity_id=payload.identity_id,
|
||||
@@ -558,7 +629,7 @@ def create_organization_function_assignment(
|
||||
valid_from=payload.valid_from,
|
||||
valid_until=payload.valid_until,
|
||||
is_active=payload.is_active,
|
||||
settings=payload.settings,
|
||||
settings=item_settings,
|
||||
)
|
||||
if item.delegated_from_assignment_id is not None:
|
||||
_get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
|
||||
@@ -614,6 +685,13 @@ def update_organization_function_assignment(
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
plan.apply(item)
|
||||
function = _organization_function(item.function_id, tenant_id)
|
||||
item.settings = _apply_governance_override(
|
||||
principal,
|
||||
function,
|
||||
payload,
|
||||
dict(item.settings),
|
||||
)
|
||||
now = utc_now()
|
||||
event_types = lifecycle_event_types(
|
||||
plan.before,
|
||||
@@ -678,3 +756,9 @@ def _identity_candidate(identity: IdentityRef) -> OrganizationIdentityCandidate:
|
||||
account_ids=list(identity.account_ids),
|
||||
status=identity.status,
|
||||
)
|
||||
|
||||
|
||||
from .function_changes import router as function_changes_router # noqa: E402
|
||||
|
||||
|
||||
router.include_router(function_changes_router)
|
||||
|
||||
@@ -65,6 +65,10 @@ class OrganizationFunctionAssignmentCreateRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
change_request_id: str | None = None
|
||||
governance_override_reason: str | None = Field(default=None, max_length=4_000)
|
||||
governance_override_evidence: list[str] = Field(
|
||||
default_factory=list, max_length=100
|
||||
)
|
||||
|
||||
|
||||
class OrganizationFunctionAssignmentUpdateRequest(BaseModel):
|
||||
@@ -80,6 +84,10 @@ class OrganizationFunctionAssignmentUpdateRequest(BaseModel):
|
||||
is_active: bool | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
change_request_id: str | None = None
|
||||
governance_override_reason: str | None = Field(default=None, max_length=4_000)
|
||||
governance_override_evidence: list[str] = Field(
|
||||
default_factory=list, max_length=100
|
||||
)
|
||||
|
||||
|
||||
class IdmSettingsItem(BaseModel):
|
||||
@@ -97,3 +105,120 @@ class IdmSettingsUpdateRequest(BaseModel):
|
||||
audit_detail_level: AuditDetailLevel | None = None
|
||||
change_retention_days: int | None = Field(default=None, ge=0)
|
||||
settings: dict[str, Any] | None = None
|
||||
|
||||
|
||||
FunctionAssignmentChangeKind = Literal["request", "grant"]
|
||||
FunctionAssignmentChangeAction = Literal[
|
||||
"approve",
|
||||
"reject",
|
||||
"accept",
|
||||
"request_changes",
|
||||
"respond",
|
||||
"withdraw",
|
||||
"recover",
|
||||
]
|
||||
|
||||
|
||||
class FunctionAssignmentChangeCreateRequest(BaseModel):
|
||||
kind: FunctionAssignmentChangeKind
|
||||
function_id: str = Field(min_length=1, max_length=36)
|
||||
candidate_identity_id: str = Field(min_length=1, max_length=36)
|
||||
candidate_account_id: str | None = Field(default=None, max_length=36)
|
||||
justification: str = Field(min_length=1, max_length=8_000)
|
||||
evidence: list[str] = Field(default_factory=list, max_length=100)
|
||||
requested_valid_from: datetime | None = None
|
||||
requested_valid_until: datetime | None = None
|
||||
applies_to_subunits: bool = False
|
||||
assignment_source: Literal["governance", "delegated"] = "governance"
|
||||
represented_assignment_id: str | None = Field(default=None, max_length=36)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FunctionAssignmentChangeActionRequest(BaseModel):
|
||||
action: FunctionAssignmentChangeAction
|
||||
base_revision: int = Field(ge=1)
|
||||
comment: str | None = Field(default=None, max_length=4_000)
|
||||
evidence: list[str] = Field(default_factory=list, max_length=100)
|
||||
|
||||
|
||||
class FunctionAssignmentChangeEventItem(BaseModel):
|
||||
id: str
|
||||
sequence: int
|
||||
action: str
|
||||
from_state: str | None = None
|
||||
to_state: str
|
||||
actor_account_id: str | None = None
|
||||
actor_identity_id: str | None = None
|
||||
actor_assignment_id: str | None = None
|
||||
comment: str | None = None
|
||||
evidence: list[str]
|
||||
policy_decision: dict[str, Any]
|
||||
workflow_step_id: str | None = None
|
||||
details: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentChangeItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
kind: FunctionAssignmentChangeKind
|
||||
state: str
|
||||
profile: str
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
candidate_identity_id: str
|
||||
candidate_account_id: str | None = None
|
||||
initiator_account_id: str
|
||||
initiator_identity_id: str | None = None
|
||||
represented_assignment_id: str | None = None
|
||||
justification: str
|
||||
evidence: list[str]
|
||||
requested_valid_from: datetime | None = None
|
||||
requested_valid_until: datetime | None = None
|
||||
applies_to_subunits: bool
|
||||
assignment_source: str
|
||||
required_steps: list[str]
|
||||
completed_steps: list[str]
|
||||
policy_decision: dict[str, Any]
|
||||
workflow_definition_id: str | None = None
|
||||
workflow_definition_revision_id: str | None = None
|
||||
workflow_definition_revision: int | None = None
|
||||
workflow_definition_hash: str | None = None
|
||||
workflow_instance_id: str | None = None
|
||||
workflow_current_step_id: str | None = None
|
||||
resulting_assignment_id: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
outcome_reason: str | None = None
|
||||
resource_revision: int
|
||||
etag: str
|
||||
metadata: dict[str, Any]
|
||||
events: list[FunctionAssignmentChangeEventItem] = Field(default_factory=list)
|
||||
available_actions: list[str] = Field(default_factory=list)
|
||||
availability_reason: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentChangeList(BaseModel):
|
||||
changes: list[FunctionAssignmentChangeItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
|
||||
|
||||
class FunctionAssignmentCapabilityItem(BaseModel):
|
||||
kind: FunctionAssignmentChangeKind
|
||||
function_id: str
|
||||
available: bool
|
||||
reason: str | None = None
|
||||
profile: str = "unavailable"
|
||||
required_steps: list[str] = Field(default_factory=list)
|
||||
requirements: list[str] = Field(default_factory=list)
|
||||
authority_function_id: str | None = None
|
||||
evidence_required: bool = False
|
||||
recipient_acceptance_required: bool = False
|
||||
maximum_validity_days: int | None = None
|
||||
workflow_available: bool = False
|
||||
policy_available: bool = False
|
||||
|
||||
@@ -2,17 +2,37 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_core.core.notifications import (
|
||||
NotificationDispatchRequest,
|
||||
notification_dispatch_provider,
|
||||
)
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
from govoplan_idm.backend.assignment_events import emit_assignment_event
|
||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmFunctionAssignmentChange,
|
||||
IdmFunctionAssignmentChangeEvent,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
)
|
||||
from govoplan_idm.backend.function_assignment_changes import OPEN_STATES
|
||||
|
||||
|
||||
class SqlIdmAssignmentLifecycle:
|
||||
"""Claim and publish elapsed assignments exactly once per validity window."""
|
||||
|
||||
def __init__(self, *, registry: object | None = None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def process_expired(
|
||||
self,
|
||||
session: object,
|
||||
@@ -83,11 +103,157 @@ class SqlIdmAssignmentLifecycle:
|
||||
resource_type="organization_function_assignment_expiry",
|
||||
resource_id=touched_tenant_id,
|
||||
)
|
||||
expired_change_ids = self._expire_open_changes(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=now,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"selected": len(candidates),
|
||||
"expired": len(expired_ids),
|
||||
"assignment_ids": expired_ids,
|
||||
"expired_changes": len(expired_change_ids),
|
||||
"change_ids": expired_change_ids,
|
||||
}
|
||||
|
||||
def _expire_open_changes(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
effective_at: datetime,
|
||||
limit: int,
|
||||
) -> list[str]:
|
||||
query = session.query(IdmFunctionAssignmentChange).filter(
|
||||
IdmFunctionAssignmentChange.state.in_(OPEN_STATES),
|
||||
IdmFunctionAssignmentChange.expires_at.is_not(None),
|
||||
IdmFunctionAssignmentChange.expires_at <= effective_at,
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(IdmFunctionAssignmentChange.tenant_id == tenant_id)
|
||||
candidates = (
|
||||
query.order_by(
|
||||
IdmFunctionAssignmentChange.expires_at.asc(),
|
||||
IdmFunctionAssignmentChange.id.asc(),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
expired_ids: list[str] = []
|
||||
for change in candidates:
|
||||
previous_state = change.state
|
||||
claimed = (
|
||||
session.query(IdmFunctionAssignmentChange)
|
||||
.filter(
|
||||
IdmFunctionAssignmentChange.id == change.id,
|
||||
IdmFunctionAssignmentChange.state == previous_state,
|
||||
IdmFunctionAssignmentChange.expires_at.is_not(None),
|
||||
IdmFunctionAssignmentChange.expires_at <= effective_at,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
IdmFunctionAssignmentChange.state: "expired",
|
||||
IdmFunctionAssignmentChange.outcome_reason: (
|
||||
"The governed function assignment change expired."
|
||||
),
|
||||
IdmFunctionAssignmentChange.resource_revision: (
|
||||
IdmFunctionAssignmentChange.resource_revision + 1
|
||||
),
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if claimed != 1:
|
||||
continue
|
||||
session.refresh(change)
|
||||
sequence = (
|
||||
int(
|
||||
session.scalar(
|
||||
session.query(
|
||||
func.max(IdmFunctionAssignmentChangeEvent.sequence)
|
||||
)
|
||||
.filter(IdmFunctionAssignmentChangeEvent.change_id == change.id)
|
||||
.statement
|
||||
)
|
||||
or 0
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
session.add(
|
||||
IdmFunctionAssignmentChangeEvent(
|
||||
tenant_id=change.tenant_id,
|
||||
change_id=change.id,
|
||||
sequence=sequence,
|
||||
action="expired",
|
||||
from_state=previous_state,
|
||||
to_state="expired",
|
||||
policy_decision=dict(change.policy_decision),
|
||||
details={"effective_at": effective_at.isoformat()},
|
||||
created_at=effective_at,
|
||||
)
|
||||
)
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type="idm.function_change.expired.v1",
|
||||
module_id="idm",
|
||||
payload={
|
||||
"kind": change.kind,
|
||||
"state": change.state,
|
||||
"function_id": change.function_id,
|
||||
"candidate_identity_id": change.candidate_identity_id,
|
||||
"resource_revision": change.resource_revision,
|
||||
},
|
||||
actor=EventActorRef(type="system"),
|
||||
tenant=EventTenantRef(id=change.tenant_id),
|
||||
subject=EventObjectRef(
|
||||
type="organization_function",
|
||||
id=change.function_id,
|
||||
),
|
||||
resource=EventObjectRef(
|
||||
type="function_assignment_change",
|
||||
id=change.id,
|
||||
),
|
||||
classification="internal",
|
||||
),
|
||||
)
|
||||
self._notify_expiry(session, change)
|
||||
expired_ids.append(change.id)
|
||||
return expired_ids
|
||||
|
||||
def _notify_expiry(
|
||||
self,
|
||||
session: Session,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
) -> None:
|
||||
provider = notification_dispatch_provider(self._registry)
|
||||
if provider is None:
|
||||
return
|
||||
recipient_ids = {
|
||||
change.initiator_account_id,
|
||||
change.candidate_account_id,
|
||||
}
|
||||
for account_id in sorted(item for item in recipient_ids if item):
|
||||
provider.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=change.tenant_id,
|
||||
source_module="idm",
|
||||
source_resource_type="function_assignment_change",
|
||||
source_resource_id=change.id,
|
||||
event_kind="function_assignment_change.expired",
|
||||
recipient_type="account",
|
||||
recipient_id=account_id,
|
||||
subject="Function assignment change expired",
|
||||
body_text=(
|
||||
"The governed function assignment change expired "
|
||||
"before all required decisions were completed."
|
||||
),
|
||||
action_url=f"/idm?change={change.id}",
|
||||
payload={"change_id": change.id, "state": "expired"},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SqlIdmAssignmentLifecycle"]
|
||||
|
||||
@@ -4,9 +4,20 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.core.concurrency import strong_resource_etag
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
@@ -59,4 +70,180 @@ class IdmTenantSettings(Base, TimestampMixin):
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
__all__ = ["IdmOrganizationFunctionAssignment", "IdmTenantSettings", "new_uuid"]
|
||||
class IdmFunctionAssignmentChange(Base, TimestampMixin):
|
||||
__tablename__ = "idm_function_assignment_changes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"kind",
|
||||
"initiator_account_id",
|
||||
"idempotency_key",
|
||||
name="uq_idm_function_assignment_change_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_function_assignment_changes_tenant_state",
|
||||
"tenant_id",
|
||||
"state",
|
||||
"updated_at",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_function_assignment_changes_candidate",
|
||||
"tenant_id",
|
||||
"candidate_identity_id",
|
||||
"state",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_function_assignment_changes_expiry",
|
||||
"state",
|
||||
"expires_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
profile: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
function_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations_functions.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
organization_unit_id: Mapped[str] = mapped_column(
|
||||
String(36), nullable=False, index=True
|
||||
)
|
||||
candidate_identity_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("identity_identities.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
candidate_account_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
initiator_account_id: Mapped[str] = mapped_column(
|
||||
String(36), nullable=False, index=True
|
||||
)
|
||||
initiator_identity_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
represented_assignment_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("idm_organization_function_assignments.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
justification: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
evidence: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
requested_valid_from: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
requested_valid_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
applies_to_subunits: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False
|
||||
)
|
||||
assignment_source: Mapped[str] = mapped_column(
|
||||
String(50), default="governance", nullable=False
|
||||
)
|
||||
required_steps: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
completed_steps: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
policy_decision: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
workflow_definition_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
workflow_definition_revision_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True
|
||||
)
|
||||
workflow_definition_revision: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
)
|
||||
workflow_definition_hash: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True
|
||||
)
|
||||
workflow_instance_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, unique=True
|
||||
)
|
||||
workflow_current_step_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True
|
||||
)
|
||||
resulting_assignment_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("idm_organization_function_assignments.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
outcome_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata", JSON, default=dict, nullable=False
|
||||
)
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"idm_function_assignment_change",
|
||||
self.id,
|
||||
self.resource_revision,
|
||||
)
|
||||
|
||||
|
||||
class IdmFunctionAssignmentChangeEvent(Base):
|
||||
__tablename__ = "idm_function_assignment_change_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"change_id",
|
||||
"sequence",
|
||||
name="uq_idm_function_assignment_change_event_sequence",
|
||||
),
|
||||
Index(
|
||||
"ix_idm_function_assignment_change_events_tenant_change",
|
||||
"tenant_id",
|
||||
"change_id",
|
||||
"sequence",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
change_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("idm_function_assignment_changes.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
from_state: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
to_state: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
actor_account_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
actor_identity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
actor_assignment_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
evidence: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
policy_decision: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON, default=dict, nullable=False
|
||||
)
|
||||
workflow_step_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IdmFunctionAssignmentChange",
|
||||
"IdmFunctionAssignmentChangeEvent",
|
||||
"IdmOrganizationFunctionAssignment",
|
||||
"IdmTenantSettings",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,29 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH, IdentityDirectory
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
CAPABILITY_IDENTITY_SEARCH,
|
||||
IdentityDirectory,
|
||||
)
|
||||
from govoplan_core.core.idm import (
|
||||
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||
CAPABILITY_IDM_DIRECTORY,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
)
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
|
||||
)
|
||||
from govoplan_core.core.workflows import CAPABILITY_WORKFLOW_ORCHESTRATION
|
||||
from govoplan_core.core.organizations import (
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
OrganizationDirectory,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
@@ -26,11 +41,22 @@ from govoplan_core.core.modules import (
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata
|
||||
from govoplan_idm.backend.workflow_definitions import (
|
||||
function_assignment_workflow_definitions,
|
||||
)
|
||||
|
||||
|
||||
MODULE_VERSION = "0.1.8"
|
||||
|
||||
IDM_READ_SCOPES = (
|
||||
"idm:organization_assignment:read",
|
||||
"idm:organization_assignment:write",
|
||||
"idm:settings:read",
|
||||
"idm:function_change:read",
|
||||
"idm:function_request:create",
|
||||
"idm:function_grant:create",
|
||||
"idm:function_change:decide",
|
||||
"idm:function_change:admin",
|
||||
"organizations:function:assign",
|
||||
)
|
||||
|
||||
@@ -75,6 +101,31 @@ PERMISSIONS = (
|
||||
"Manage IDM settings",
|
||||
"Update IDM governance and assignment-change policy settings.",
|
||||
),
|
||||
_permission(
|
||||
"idm:function_change:read",
|
||||
"View function assignment changes",
|
||||
"View governed function requests, grants, decisions, and outcomes.",
|
||||
),
|
||||
_permission(
|
||||
"idm:function_request:create",
|
||||
"Request organization functions",
|
||||
"Request assignment to an eligible organization function.",
|
||||
),
|
||||
_permission(
|
||||
"idm:function_grant:create",
|
||||
"Propose organization function grants",
|
||||
"Bestow an organization function through its governed grant profile.",
|
||||
),
|
||||
_permission(
|
||||
"idm:function_change:decide",
|
||||
"Decide function assignment changes",
|
||||
"Approve, reject, or accept governed function assignment changes when eligible.",
|
||||
),
|
||||
_permission(
|
||||
"idm:function_change:admin",
|
||||
"Recover function assignment changes",
|
||||
"Inspect and recover blocked or failed function assignment workflows.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
@@ -88,6 +139,21 @@ ROLE_TEMPLATES = (
|
||||
"idm:organization_assignment:write",
|
||||
"idm:settings:read",
|
||||
"idm:settings:write",
|
||||
"idm:function_change:read",
|
||||
"idm:function_request:create",
|
||||
"idm:function_grant:create",
|
||||
"idm:function_change:decide",
|
||||
"idm:function_change:admin",
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="idm_function_participant",
|
||||
name="IDM function participant",
|
||||
description="Request functions and participate in governed assignment decisions.",
|
||||
permissions=(
|
||||
"idm:function_change:read",
|
||||
"idm:function_request:create",
|
||||
"idm:function_change:decide",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -113,20 +179,30 @@ def _idm_directory(context: ModuleContext) -> object:
|
||||
|
||||
|
||||
def _assignment_lifecycle(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_idm.backend.assignment_lifecycle import (
|
||||
SqlIdmAssignmentLifecycle,
|
||||
)
|
||||
|
||||
return SqlIdmAssignmentLifecycle()
|
||||
return SqlIdmAssignmentLifecycle(registry=context.registry)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="idm",
|
||||
name="IDM",
|
||||
version="0.1.8",
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("identity", "organizations"),
|
||||
optional_dependencies=("access", "audit"),
|
||||
optional_dependencies=(
|
||||
"access",
|
||||
"audit",
|
||||
"notifications",
|
||||
"policy",
|
||||
"workflow_engine",
|
||||
),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
|
||||
CAPABILITY_WORKFLOW_ORCHESTRATION,
|
||||
),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -141,7 +217,11 @@ manifest = ModuleManifest(
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||
version="0.1.8",
|
||||
version=MODULE_VERSION,
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name="idm.function_assignment_changes",
|
||||
version="1.0.0",
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
@@ -172,6 +252,8 @@ manifest = ModuleManifest(
|
||||
persistent_table_uninstall_guard(
|
||||
idm_models.IdmOrganizationFunctionAssignment,
|
||||
idm_models.IdmTenantSettings,
|
||||
idm_models.IdmFunctionAssignmentChange,
|
||||
idm_models.IdmFunctionAssignmentChangeEvent,
|
||||
label="IDM",
|
||||
),
|
||||
),
|
||||
@@ -180,6 +262,9 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
|
||||
},
|
||||
workflow_definitions=function_assignment_workflow_definitions(
|
||||
module_version=MODULE_VERSION,
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="idm.organization_identity_bridge",
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
"""Add governed function assignment change aggregates.
|
||||
|
||||
Revision ID: a0b1c2d3e4f5
|
||||
Revises: 9a0b1c2d3e4f
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a0b1c2d3e4f5"
|
||||
down_revision = "9a0b1c2d3e4f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"idm_function_assignment_changes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("state", sa.String(length=40), nullable=False),
|
||||
sa.Column("profile", sa.String(length=60), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("candidate_identity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("candidate_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("initiator_account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("initiator_identity_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("represented_assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("justification", sa.Text(), nullable=False),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("requested_valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("requested_valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
|
||||
sa.Column("assignment_source", sa.String(length=50), nullable=False),
|
||||
sa.Column("required_steps", sa.JSON(), nullable=False),
|
||||
sa.Column("completed_steps", sa.JSON(), nullable=False),
|
||||
sa.Column("policy_decision", sa.JSON(), nullable=False),
|
||||
sa.Column("workflow_definition_id", sa.String(length=36), nullable=True),
|
||||
sa.Column(
|
||||
"workflow_definition_revision_id", sa.String(length=36), nullable=True
|
||||
),
|
||||
sa.Column("workflow_definition_revision", sa.Integer(), nullable=True),
|
||||
sa.Column("workflow_definition_hash", sa.String(length=64), nullable=True),
|
||||
sa.Column("workflow_instance_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("workflow_current_step_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("resulting_assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("outcome_reason", sa.Text(), nullable=True),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["candidate_identity_id"],
|
||||
["identity_identities.id"],
|
||||
name=op.f(
|
||||
"fk_idm_function_assignment_changes_candidate_identity_id_identity_identities"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_id"],
|
||||
["organizations_functions.id"],
|
||||
name=op.f(
|
||||
"fk_idm_function_assignment_changes_function_id_organizations_functions"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["represented_assignment_id"],
|
||||
["idm_organization_function_assignments.id"],
|
||||
name=op.f(
|
||||
"fk_idm_function_assignment_changes_represented_assignment_id_idm_assignments"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["resulting_assignment_id"],
|
||||
["idm_organization_function_assignments.id"],
|
||||
name=op.f(
|
||||
"fk_idm_function_assignment_changes_resulting_assignment_id_idm_assignments"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_idm_function_assignment_changes")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"kind",
|
||||
"initiator_account_id",
|
||||
"idempotency_key",
|
||||
name="uq_idm_function_assignment_change_idempotency",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"workflow_instance_id",
|
||||
name=op.f("uq_idm_function_assignment_changes_workflow_instance_id"),
|
||||
),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_idm_function_assignment_changes_tenant_id", ["tenant_id"]),
|
||||
("ix_idm_function_assignment_changes_kind", ["kind"]),
|
||||
("ix_idm_function_assignment_changes_state", ["state"]),
|
||||
("ix_idm_function_assignment_changes_function_id", ["function_id"]),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_organization_unit_id",
|
||||
["organization_unit_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_candidate_identity_id",
|
||||
["candidate_identity_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_candidate_account_id",
|
||||
["candidate_account_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_initiator_account_id",
|
||||
["initiator_account_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_initiator_identity_id",
|
||||
["initiator_identity_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_represented_assignment_id",
|
||||
["represented_assignment_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_workflow_definition_id",
|
||||
["workflow_definition_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_resulting_assignment_id",
|
||||
["resulting_assignment_id"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_tenant_state",
|
||||
["tenant_id", "state", "updated_at"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_candidate",
|
||||
["tenant_id", "candidate_identity_id", "state"],
|
||||
),
|
||||
(
|
||||
"ix_idm_function_assignment_changes_expiry",
|
||||
["state", "expires_at"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "idm_function_assignment_changes", columns, unique=False)
|
||||
|
||||
op.create_table(
|
||||
"idm_function_assignment_change_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("change_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("action", sa.String(length=50), nullable=False),
|
||||
sa.Column("from_state", sa.String(length=40), nullable=True),
|
||||
sa.Column("to_state", sa.String(length=40), nullable=False),
|
||||
sa.Column("actor_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_identity_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("comment", sa.Text(), nullable=True),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("policy_decision", sa.JSON(), nullable=False),
|
||||
sa.Column("workflow_step_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("details", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["change_id"],
|
||||
["idm_function_assignment_changes.id"],
|
||||
name=op.f(
|
||||
"fk_idm_function_assignment_change_events_change_id_idm_function_assignment_changes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id", name=op.f("pk_idm_function_assignment_change_events")
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"change_id",
|
||||
"sequence",
|
||||
name="uq_idm_function_assignment_change_event_sequence",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_function_assignment_change_events_tenant_id",
|
||||
"idm_function_assignment_change_events",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_function_assignment_change_events_change_id",
|
||||
"idm_function_assignment_change_events",
|
||||
["change_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_function_assignment_change_events_action",
|
||||
"idm_function_assignment_change_events",
|
||||
["action"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_function_assignment_change_events_actor_account_id",
|
||||
"idm_function_assignment_change_events",
|
||||
["actor_account_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_idm_function_assignment_change_events_tenant_change",
|
||||
"idm_function_assignment_change_events",
|
||||
["tenant_id", "change_id", "sequence"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("idm_function_assignment_change_events")
|
||||
op.drop_table("idm_function_assignment_changes")
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||
|
||||
|
||||
def function_assignment_workflow_definitions(
|
||||
*,
|
||||
module_version: str,
|
||||
) -> tuple[WorkflowDefinitionContribution, ...]:
|
||||
return (
|
||||
_contribution(
|
||||
module_version=module_version,
|
||||
definition_key="function-assignment-request",
|
||||
name="Request an organization function",
|
||||
description=(
|
||||
"Governed holder, authority, and optional recipient decisions "
|
||||
"for a self-requested organization function assignment."
|
||||
),
|
||||
kind="request",
|
||||
),
|
||||
_contribution(
|
||||
module_version=module_version,
|
||||
definition_key="function-assignment-grant",
|
||||
name="Bestow an organization function",
|
||||
description=(
|
||||
"Governed holder, authority, and recipient decisions for an "
|
||||
"organization function grant."
|
||||
),
|
||||
kind="grant",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _contribution(
|
||||
*,
|
||||
module_version: str,
|
||||
definition_key: str,
|
||||
name: str,
|
||||
description: str,
|
||||
kind: str,
|
||||
) -> WorkflowDefinitionContribution:
|
||||
return WorkflowDefinitionContribution(
|
||||
origin_module_id="idm",
|
||||
origin_module_version=module_version,
|
||||
definition_key=definition_key,
|
||||
name=name,
|
||||
description=description,
|
||||
graph=_graph(kind=kind),
|
||||
scope_type="system",
|
||||
inherit_to_lower_scopes=True,
|
||||
allow_start=True,
|
||||
allow_reuse=False,
|
||||
allow_automation=False,
|
||||
execution_mode="guided",
|
||||
activate_on_install=True,
|
||||
metadata={
|
||||
"domain": "idm.function_assignment_change",
|
||||
"change_kind": kind,
|
||||
"state_owner": "idm",
|
||||
},
|
||||
policy_metadata={
|
||||
"governance_capability": ("policy.functionAssignmentGovernance"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _graph(*, kind: str) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "workflow.start.manual",
|
||||
"label": "Submitted",
|
||||
"position": {"x": 20, "y": 120},
|
||||
"config": {
|
||||
"input_schema_ref": (f"govoplan/idm/function-assignment-{kind}.v1"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "holder_review",
|
||||
"type": "workflow.review",
|
||||
"label": "Holder review",
|
||||
"position": {"x": 230, "y": 120},
|
||||
"config": {
|
||||
"title": "Holder review",
|
||||
"reviewer": "effective-holder",
|
||||
"required_evidence": [],
|
||||
"view_surface_ids": [
|
||||
"idm.action.view-function-assignments",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "authority_review",
|
||||
"type": "workflow.review",
|
||||
"label": "Authority clearance",
|
||||
"position": {"x": 470, "y": 120},
|
||||
"config": {
|
||||
"title": "Authority clearance",
|
||||
"reviewer": "designated-authority",
|
||||
"required_evidence": [],
|
||||
"view_surface_ids": [
|
||||
"idm.action.view-function-assignments",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "recipient_review",
|
||||
"type": "workflow.review",
|
||||
"label": "Recipient acceptance",
|
||||
"position": {"x": 730, "y": 120},
|
||||
"config": {
|
||||
"title": "Recipient acceptance",
|
||||
"reviewer": "candidate",
|
||||
"required_evidence": [],
|
||||
"view_surface_ids": [
|
||||
"idm.action.view-function-assignments",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "completed",
|
||||
"type": "workflow.end.completed",
|
||||
"label": "Approved",
|
||||
"position": {"x": 990, "y": 70},
|
||||
"config": {"output_mapping": {}},
|
||||
},
|
||||
{
|
||||
"id": "rejected",
|
||||
"type": "workflow.end.cancelled",
|
||||
"label": "Rejected",
|
||||
"position": {"x": 990, "y": 230},
|
||||
"config": {"reason": "Function assignment change rejected"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "start-holder",
|
||||
"source": "start",
|
||||
"target": "holder_review",
|
||||
},
|
||||
{
|
||||
"id": "holder-authority",
|
||||
"source": "holder_review",
|
||||
"source_port": "approved",
|
||||
"target": "authority_review",
|
||||
},
|
||||
{
|
||||
"id": "holder-rejected",
|
||||
"source": "holder_review",
|
||||
"source_port": "rejected",
|
||||
"target": "rejected",
|
||||
},
|
||||
{
|
||||
"id": "authority-recipient",
|
||||
"source": "authority_review",
|
||||
"source_port": "approved",
|
||||
"target": "recipient_review",
|
||||
},
|
||||
{
|
||||
"id": "authority-rejected",
|
||||
"source": "authority_review",
|
||||
"source_port": "rejected",
|
||||
"target": "rejected",
|
||||
},
|
||||
{
|
||||
"id": "recipient-completed",
|
||||
"source": "recipient_review",
|
||||
"source_port": "approved",
|
||||
"target": "completed",
|
||||
},
|
||||
{
|
||||
"id": "recipient-rejected",
|
||||
"source": "recipient_review",
|
||||
"source_port": "rejected",
|
||||
"target": "rejected",
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"notation": "govoplan.workflow.native",
|
||||
"domain": "idm.function_assignment_change",
|
||||
"change_kind": kind,
|
||||
"optional_steps": [
|
||||
"holder_review",
|
||||
"authority_review",
|
||||
"recipient_review",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["function_assignment_workflow_definitions"]
|
||||
Reference in New Issue
Block a user