Implement governed function assignment workflows
This commit is contained in:
@@ -54,7 +54,7 @@ effective decision and provenance rather than IDM duplicating policy logic.
|
||||
|
||||
## IDM Aggregate
|
||||
|
||||
IDM should persist one function-assignment change aggregate for both journeys:
|
||||
IDM persists one function-assignment change aggregate for both journeys:
|
||||
|
||||
- change ID, tenant, kind (`request` or `grant`), function, unit, candidate
|
||||
identity/account, requested validity, and assignment source
|
||||
@@ -67,9 +67,9 @@ IDM should persist one function-assignment change aggregate for both journeys:
|
||||
- idempotency key and optimistic-concurrency revision
|
||||
|
||||
Candidate states are `draft`, `submitted`, `awaiting_holder`,
|
||||
`awaiting_authority`, `awaiting_recipient`, `approved`, `accepted`, `applied`,
|
||||
`rejected`, `withdrawn`, `expired`, `cancelled`, and `failed_manual_review`.
|
||||
Not every profile uses every state.
|
||||
`awaiting_authority`, `awaiting_recipient`, `changes_requested`, `blocked`,
|
||||
`approved`, `accepted`, `applied`, `rejected`, `withdrawn`, `expired`,
|
||||
`cancelled`, and `failed_manual_review`. Not every profile uses every state.
|
||||
|
||||
The workflow instance coordinates the process, but the IDM change record is the
|
||||
business source of truth. A workflow callback applies the assignment exactly
|
||||
@@ -97,6 +97,30 @@ reconciled without starting a second grant.
|
||||
functions or treated as an explicit emergency override with reason,
|
||||
provenance, and equivalent evidence.
|
||||
|
||||
## Configuration And Runtime Contract
|
||||
|
||||
Function settings use `assignment_governance`. Tenant defaults may be supplied
|
||||
through `settings.function_assignment_governance_defaults`; function values
|
||||
override only the corresponding defaults. Supported keys include
|
||||
`request_profile`, `grant_profile`, `authority_function_id`,
|
||||
`recipient_acceptance_required`, `evidence_required`,
|
||||
`separation_of_duties`, `quorum`, `maximum_validity_days`, and
|
||||
`request_expiry_hours`. Missing or malformed profiles fail closed.
|
||||
|
||||
IDM exposes governed changes at
|
||||
`/api/v1/idm/function-assignment-changes`. Mutations require a strong `If-Match`
|
||||
precondition and the aggregate revision. Requests and grants pin the exact
|
||||
Workflow definition revision and hash, retain append-only transition evidence,
|
||||
support review change requests and responses, and apply an assignment exactly
|
||||
once. Vacant holder or authority functions create a visible `blocked` state;
|
||||
the lifecycle worker expires overdue open changes durably.
|
||||
|
||||
Direct administration remains available for independently deployed IDM. When
|
||||
a function has an enabled governance profile, however, direct create or update
|
||||
requires `idm:function_change:admin` plus an emergency override reason. IDM
|
||||
stores the actor, time, reason, and evidence references with the assignment and
|
||||
includes them in the normal Audit change record.
|
||||
|
||||
## Module Boundaries
|
||||
|
||||
- Organizations: function definitions and assignment-policy profile reference.
|
||||
|
||||
@@ -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"]
|
||||
@@ -9,7 +9,11 @@ from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.db import models as identity_models # noqa: F401
|
||||
from govoplan_idm.backend.assignment_lifecycle import SqlIdmAssignmentLifecycle
|
||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmFunctionAssignmentChange,
|
||||
IdmFunctionAssignmentChangeEvent,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
)
|
||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
|
||||
|
||||
|
||||
@@ -20,6 +24,8 @@ class AssignmentExpiryTests(unittest.TestCase):
|
||||
self.database.engine,
|
||||
tables=[
|
||||
IdmOrganizationFunctionAssignment.__table__,
|
||||
IdmFunctionAssignmentChange.__table__,
|
||||
IdmFunctionAssignmentChangeEvent.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
@@ -143,6 +149,61 @@ class AssignmentExpiryTests(unittest.TestCase):
|
||||
with self.subTest(limit=value), self.assertRaises(ValueError):
|
||||
self.lifecycle.process_expired(session, limit=value)
|
||||
|
||||
def test_sweep_expires_open_governed_changes_once(self) -> None:
|
||||
boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc)
|
||||
with self.database.session() as session:
|
||||
session.add(
|
||||
IdmFunctionAssignmentChange(
|
||||
id="change-due",
|
||||
tenant_id="tenant-1",
|
||||
kind="request",
|
||||
state="awaiting_holder",
|
||||
profile="holder_grant",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
candidate_identity_id="identity-1",
|
||||
initiator_account_id="account-1",
|
||||
justification="Need the function",
|
||||
evidence=[],
|
||||
assignment_source="governance",
|
||||
required_steps=["holder"],
|
||||
completed_steps=[],
|
||||
policy_decision={},
|
||||
idempotency_key="request-1",
|
||||
expires_at=boundary - timedelta(seconds=1),
|
||||
metadata_={},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
events: list[PlatformEvent] = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("idm.function_change.expired.v1", events.append)
|
||||
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||
result = self.lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
session.commit()
|
||||
repeated = self.lifecycle.process_expired(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
effective_at=boundary,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(1, result["expired_changes"])
|
||||
self.assertEqual(["change-due"], result["change_ids"])
|
||||
self.assertEqual(0, repeated["expired_changes"])
|
||||
self.assertEqual(1, len(events))
|
||||
with self.database.session() as session:
|
||||
change = session.get(IdmFunctionAssignmentChange, "change-due")
|
||||
self.assertEqual("expired", change.state)
|
||||
self.assertEqual(2, change.resource_revision)
|
||||
history = session.query(IdmFunctionAssignmentChangeEvent).all()
|
||||
self.assertEqual(["expired"], [item.action for item in history])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.concurrency import RevisionConflictError
|
||||
from govoplan_core.core.organizations import OrganizationFunctionRef
|
||||
from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision
|
||||
from govoplan_core.core.workflows import WorkflowInstanceRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.db import models as identity_models # noqa: F401
|
||||
from govoplan_idm.backend.api.v1.schemas import FunctionAssignmentChangeCreateRequest
|
||||
from govoplan_idm.backend.api.v1.function_changes import _change_item
|
||||
from govoplan_idm.backend.db.models import (
|
||||
IdmFunctionAssignmentChange,
|
||||
IdmFunctionAssignmentChangeEvent,
|
||||
IdmOrganizationFunctionAssignment,
|
||||
IdmTenantSettings,
|
||||
)
|
||||
from govoplan_idm.backend.function_assignment_changes import (
|
||||
create_function_assignment_change,
|
||||
transition_function_assignment_change,
|
||||
)
|
||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
|
||||
|
||||
|
||||
class _Policy:
|
||||
def resolve_function_assignment_action(self, session=None, *, request):
|
||||
del session
|
||||
steps = ("holder", "authority")
|
||||
context = request.context
|
||||
allowed = {
|
||||
"submit": bool(context.get("candidate_is_actor")),
|
||||
"approve_holder": bool(context.get("actor_is_holder")),
|
||||
"approve_authority": bool(context.get("actor_is_authority")),
|
||||
"accept_recipient": bool(context.get("candidate_is_actor")),
|
||||
"request_changes": bool(
|
||||
context.get("actor_is_holder") or context.get("actor_is_authority")
|
||||
),
|
||||
"respond": bool(
|
||||
context.get("actor_is_initiator") or context.get("candidate_is_actor")
|
||||
),
|
||||
"reject": bool(
|
||||
context.get("actor_is_holder") or context.get("actor_is_authority")
|
||||
),
|
||||
"withdraw": bool(context.get("actor_is_initiator")),
|
||||
"recover": request.actor.account_id == "admin",
|
||||
"apply": bool(context.get("approvals_complete")),
|
||||
}.get(request.action, False)
|
||||
return FunctionAssignmentGovernanceDecision(
|
||||
allowed=allowed,
|
||||
reason=None if allowed else "Not eligible for this action.",
|
||||
profile="holder_with_authority_clearance",
|
||||
required_steps=steps,
|
||||
authority_function_id="authority-function",
|
||||
separation_of_duties=False,
|
||||
request_expiry_hours=24,
|
||||
)
|
||||
|
||||
|
||||
class _Workflow:
|
||||
nodes = ("holder_review", "authority_review", "recipient_review")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.instances: dict[str, WorkflowInstanceRef] = {}
|
||||
|
||||
def start_standard(self, session, principal, *, request):
|
||||
del session, principal
|
||||
instance_id = f"workflow-{request.idempotency_key}"
|
||||
existing = self.instances.get(instance_id)
|
||||
if existing is not None:
|
||||
return replace(existing, replayed=True)
|
||||
reference = WorkflowInstanceRef(
|
||||
id=instance_id,
|
||||
tenant_id=request.tenant_id,
|
||||
definition_id="definition-1",
|
||||
definition_revision_id="revision-1",
|
||||
definition_revision=1,
|
||||
definition_hash="a" * 64,
|
||||
status="waiting",
|
||||
current_step_id="step-holder_review",
|
||||
current_node_id="holder_review",
|
||||
)
|
||||
self.instances[instance_id] = reference
|
||||
return reference
|
||||
|
||||
def resolve_current_step(
|
||||
self,
|
||||
session,
|
||||
principal,
|
||||
*,
|
||||
tenant_id,
|
||||
instance_id,
|
||||
resolution,
|
||||
):
|
||||
del session, principal, tenant_id
|
||||
current = self.instances[instance_id]
|
||||
if resolution.expected_step_id != current.current_step_id:
|
||||
raise ValueError("Workflow current step changed.")
|
||||
if resolution.action == "changes":
|
||||
return current
|
||||
if resolution.action in {"reject", "cancel"}:
|
||||
result = replace(
|
||||
current,
|
||||
status="completed",
|
||||
current_step_id=None,
|
||||
current_node_id=None,
|
||||
)
|
||||
else:
|
||||
index = self.nodes.index(current.current_node_id or "") + 1
|
||||
if index >= len(self.nodes):
|
||||
result = replace(
|
||||
current,
|
||||
status="completed",
|
||||
current_step_id=None,
|
||||
current_node_id=None,
|
||||
)
|
||||
else:
|
||||
node = self.nodes[index]
|
||||
result = replace(
|
||||
current,
|
||||
current_step_id=f"step-{node}",
|
||||
current_node_id=node,
|
||||
)
|
||||
self.instances[instance_id] = result
|
||||
return result
|
||||
|
||||
def get_instance(self, session, *, tenant_id, instance_id):
|
||||
del session, tenant_id
|
||||
return self.instances[instance_id]
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self) -> None:
|
||||
self.policy = _Policy()
|
||||
self.workflow = _Workflow()
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in {
|
||||
"policy.functionAssignmentGovernance",
|
||||
"workflow.orchestration",
|
||||
}
|
||||
|
||||
def capability(self, name: str):
|
||||
if name == "policy.functionAssignmentGovernance":
|
||||
return self.policy
|
||||
if name == "workflow.orchestration":
|
||||
return self.workflow
|
||||
return None
|
||||
|
||||
|
||||
def principal(account_id: str, identity_id: str) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id=account_id,
|
||||
membership_id=f"membership-{account_id}",
|
||||
tenant_id="tenant-1",
|
||||
identity_id=identity_id,
|
||||
scopes=frozenset({"idm:function_change:decide"}),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
def function() -> OrganizationFunctionRef:
|
||||
return OrganizationFunctionRef(
|
||||
id="target-function",
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id="unit-1",
|
||||
slug="target",
|
||||
name="Target function",
|
||||
settings={
|
||||
"assignment_governance": {
|
||||
"request_profile": "holder_with_authority_clearance",
|
||||
"authority_function_id": "authority-function",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def payload() -> FunctionAssignmentChangeCreateRequest:
|
||||
return FunctionAssignmentChangeCreateRequest(
|
||||
kind="request",
|
||||
function_id="target-function",
|
||||
candidate_identity_id="candidate-identity",
|
||||
candidate_account_id="candidate",
|
||||
justification="The function is needed for the assigned work.",
|
||||
idempotency_key="request-1",
|
||||
)
|
||||
|
||||
|
||||
class FunctionAssignmentChangeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.database = configure_database("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.database.engine,
|
||||
tables=[
|
||||
IdmOrganizationFunctionAssignment.__table__,
|
||||
IdmFunctionAssignmentChange.__table__,
|
||||
IdmFunctionAssignmentChangeEvent.__table__,
|
||||
IdmTenantSettings.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.registry = _Registry()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_database(dispose=True)
|
||||
|
||||
def _add_reviewer_assignments(self, session) -> None:
|
||||
session.add_all(
|
||||
(
|
||||
IdmOrganizationFunctionAssignment(
|
||||
id="holder-assignment",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="holder-identity",
|
||||
account_id="holder",
|
||||
function_id="target-function",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
),
|
||||
IdmOrganizationFunctionAssignment(
|
||||
id="authority-assignment",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="authority-identity",
|
||||
account_id="authority",
|
||||
function_id="authority-function",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
is_active=True,
|
||||
settings={},
|
||||
),
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
def test_request_is_idempotent_and_applies_once_after_required_steps(self) -> None:
|
||||
with self.database.session() as session:
|
||||
self._add_reviewer_assignments(session)
|
||||
change, replayed = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal("candidate", "candidate-identity"),
|
||||
registry=self.registry,
|
||||
function=function(),
|
||||
payload=payload(),
|
||||
)
|
||||
session.flush()
|
||||
same, replay = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal("candidate", "candidate-identity"),
|
||||
registry=self.registry,
|
||||
function=function(),
|
||||
payload=payload(),
|
||||
)
|
||||
self.assertFalse(replayed)
|
||||
self.assertTrue(replay)
|
||||
self.assertEqual(change.id, same.id)
|
||||
self.assertEqual("awaiting_holder", change.state)
|
||||
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("holder", "holder-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="approve",
|
||||
base_revision=1,
|
||||
comment="Holder approval",
|
||||
evidence=(),
|
||||
)
|
||||
self.assertEqual("awaiting_authority", change.state)
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("authority", "authority-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="approve",
|
||||
base_revision=2,
|
||||
comment="Authority approval",
|
||||
evidence=(),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual("applied", change.state)
|
||||
self.assertIsNotNone(change.resulting_assignment_id)
|
||||
resulting = session.get(
|
||||
IdmOrganizationFunctionAssignment,
|
||||
change.resulting_assignment_id,
|
||||
)
|
||||
self.assertEqual("candidate-identity", resulting.identity_id)
|
||||
self.assertEqual("governance", resulting.source)
|
||||
self.assertEqual(
|
||||
change.id,
|
||||
resulting.settings["governance"]["change_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
session.query(IdmOrganizationFunctionAssignment)
|
||||
.filter(
|
||||
IdmOrganizationFunctionAssignment.identity_id
|
||||
== "candidate-identity"
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
|
||||
def test_vacant_function_blocks_and_revision_claim_rejects_stale_action(
|
||||
self,
|
||||
) -> None:
|
||||
with self.database.session() as session:
|
||||
change, _ = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal("candidate", "candidate-identity"),
|
||||
registry=self.registry,
|
||||
function=function(),
|
||||
payload=payload(),
|
||||
)
|
||||
session.flush()
|
||||
self.assertEqual("blocked", change.state)
|
||||
self.assertIn("vacant", change.outcome_reason)
|
||||
|
||||
self._add_reviewer_assignments(session)
|
||||
change.state = "awaiting_holder"
|
||||
change.resource_revision = 2
|
||||
session.flush()
|
||||
with self.assertRaises(RevisionConflictError):
|
||||
transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal("holder", "holder-identity"),
|
||||
registry=self.registry,
|
||||
change=change,
|
||||
function=function(),
|
||||
action="approve",
|
||||
base_revision=1,
|
||||
comment=None,
|
||||
evidence=(),
|
||||
)
|
||||
|
||||
def test_historical_change_remains_readable_after_function_removal(self) -> None:
|
||||
with self.database.session() as session:
|
||||
change, _ = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal("candidate", "candidate-identity"),
|
||||
registry=self.registry,
|
||||
function=function(),
|
||||
payload=payload(),
|
||||
)
|
||||
session.flush()
|
||||
|
||||
with patch(
|
||||
"govoplan_idm.backend.api.v1.function_changes._historical_function",
|
||||
return_value=None,
|
||||
):
|
||||
item = _change_item(
|
||||
session,
|
||||
principal("candidate", "candidate-identity"),
|
||||
change,
|
||||
include_events=False,
|
||||
)
|
||||
|
||||
self.assertEqual(change.id, item.id)
|
||||
self.assertEqual([], item.available_actions)
|
||||
self.assertIn("no longer available", item.availability_reason)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -99,10 +99,90 @@ export type OrganizationFunctionAssignmentPayload = {
|
||||
is_active?: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
change_request_id?: string | null;
|
||||
governance_override_reason?: string | null;
|
||||
governance_override_evidence?: string[];
|
||||
};
|
||||
|
||||
export type IdmSettingsPayload = Partial<Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings">>;
|
||||
|
||||
export type FunctionAssignmentChangeKind = "request" | "grant";
|
||||
export type FunctionAssignmentChangeAction = "approve" | "reject" | "accept" | "request_changes" | "respond" | "withdraw" | "recover";
|
||||
|
||||
export type FunctionAssignmentChangeEvent = {
|
||||
id: string;
|
||||
sequence: number;
|
||||
action: string;
|
||||
from_state?: string | null;
|
||||
to_state: string;
|
||||
actor_account_id?: string | null;
|
||||
actor_identity_id?: string | null;
|
||||
comment?: string | null;
|
||||
evidence: string[];
|
||||
policy_decision: Record<string, unknown>;
|
||||
workflow_step_id?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type FunctionAssignmentChange = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
kind: FunctionAssignmentChangeKind;
|
||||
state: string;
|
||||
profile: string;
|
||||
function_id: string;
|
||||
organization_unit_id: string;
|
||||
candidate_identity_id: string;
|
||||
candidate_account_id?: string | null;
|
||||
initiator_account_id: string;
|
||||
initiator_identity_id?: string | null;
|
||||
justification: string;
|
||||
evidence: string[];
|
||||
requested_valid_from?: string | null;
|
||||
requested_valid_until?: string | null;
|
||||
required_steps: string[];
|
||||
completed_steps: string[];
|
||||
policy_decision: Record<string, unknown>;
|
||||
workflow_definition_revision?: number | null;
|
||||
workflow_definition_hash?: string | null;
|
||||
workflow_instance_id?: string | null;
|
||||
resulting_assignment_id?: string | null;
|
||||
expires_at?: string | null;
|
||||
outcome_reason?: string | null;
|
||||
resource_revision: number;
|
||||
etag: string;
|
||||
metadata: Record<string, unknown>;
|
||||
events: FunctionAssignmentChangeEvent[];
|
||||
available_actions: FunctionAssignmentChangeAction[];
|
||||
availability_reason?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type FunctionAssignmentChangeList = {
|
||||
changes: FunctionAssignmentChange[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
export type FunctionAssignmentChangePayload = {
|
||||
kind: FunctionAssignmentChangeKind;
|
||||
function_id: string;
|
||||
candidate_identity_id: string;
|
||||
candidate_account_id?: string | null;
|
||||
justification: string;
|
||||
evidence?: string[];
|
||||
requested_valid_from?: string | null;
|
||||
requested_valid_until?: string | null;
|
||||
applies_to_subunits?: boolean;
|
||||
assignment_source?: "governance" | "delegated";
|
||||
represented_assignment_id?: string | null;
|
||||
idempotency_key: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
||||
return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
@@ -168,3 +248,40 @@ export function patchOrganizationFunctionAssignment(
|
||||
): Promise<OrganizationFunctionAssignmentItem> {
|
||||
return patch(settings, `/api/v1/idm/organization-function-assignments/${encodeURIComponent(id)}`, payload);
|
||||
}
|
||||
|
||||
export function getFunctionAssignmentChanges(settings: ApiSettings): Promise<FunctionAssignmentChangeList> {
|
||||
return apiFetch<FunctionAssignmentChangeList>(settings, "/api/v1/idm/function-assignment-changes?page_size=200");
|
||||
}
|
||||
|
||||
export function getFunctionAssignmentChange(settings: ApiSettings, id: string): Promise<FunctionAssignmentChange> {
|
||||
return apiFetch<FunctionAssignmentChange>(settings, `/api/v1/idm/function-assignment-changes/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function createFunctionAssignmentChange(
|
||||
settings: ApiSettings,
|
||||
payload: FunctionAssignmentChangePayload
|
||||
): Promise<FunctionAssignmentChange> {
|
||||
return post(settings, "/api/v1/idm/function-assignment-changes", payload);
|
||||
}
|
||||
|
||||
export function actOnFunctionAssignmentChange(
|
||||
settings: ApiSettings,
|
||||
change: FunctionAssignmentChange,
|
||||
action: FunctionAssignmentChangeAction,
|
||||
comment?: string
|
||||
): Promise<FunctionAssignmentChange> {
|
||||
return apiFetch<FunctionAssignmentChange>(
|
||||
settings,
|
||||
`/api/v1/idm/function-assignment-changes/${encodeURIComponent(change.id)}/actions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "If-Match": change.etag },
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
base_revision: change.resource_revision,
|
||||
comment: comment?.trim() || null,
|
||||
evidence: []
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { Check, Eye, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
||||
import {
|
||||
AdminIconButton,
|
||||
ApiError,
|
||||
Button,
|
||||
Card,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
hasScope,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
actOnFunctionAssignmentChange,
|
||||
createFunctionAssignmentChange,
|
||||
getFunctionAssignmentChange,
|
||||
getFunctionAssignmentChanges,
|
||||
type FunctionAssignmentChange,
|
||||
type FunctionAssignmentChangeAction,
|
||||
type FunctionAssignmentChangeKind,
|
||||
type IdentityOption,
|
||||
type OrganizationModel
|
||||
} from "../api/idm";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
model: OrganizationModel;
|
||||
identities: IdentityOption[];
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
kind: FunctionAssignmentChangeKind;
|
||||
functionId: string;
|
||||
identityId: string;
|
||||
accountId: string;
|
||||
justification: string;
|
||||
evidence: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
};
|
||||
|
||||
function emptyDraft(auth: AuthInfo, kind: FunctionAssignmentChangeKind): Draft {
|
||||
return {
|
||||
kind,
|
||||
functionId: "",
|
||||
identityId: kind === "request" ? auth.principal?.identity_id ?? "" : "",
|
||||
accountId: kind === "request" ? auth.principal?.account_id ?? "" : "",
|
||||
justification: "",
|
||||
evidence: "",
|
||||
validFrom: "",
|
||||
validUntil: ""
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
try {
|
||||
const body = JSON.parse(error.body) as { detail?: string | { message?: string } };
|
||||
if (typeof body.detail === "string") return body.detail;
|
||||
if (body.detail?.message) return body.detail.message;
|
||||
} catch {
|
||||
// Use the transport message.
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function dateTimeValue(value: string): string | null {
|
||||
return value ? new Date(value).toISOString() : null;
|
||||
}
|
||||
|
||||
function statusTone(state: string): string {
|
||||
if (state === "applied") return "success";
|
||||
if (["rejected", "expired", "withdrawn", "cancelled"].includes(state)) return "inactive";
|
||||
if (["blocked", "failed_manual_review"].includes(state)) return "danger";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function actionLabel(action: FunctionAssignmentChangeAction): string {
|
||||
return {
|
||||
approve: "Approve",
|
||||
reject: "Reject",
|
||||
accept: "Accept",
|
||||
request_changes: "Request changes",
|
||||
respond: "Respond",
|
||||
withdraw: "Withdraw",
|
||||
recover: "Recheck"
|
||||
}[action];
|
||||
}
|
||||
|
||||
function actionIcon(action: FunctionAssignmentChangeAction): JSX.Element {
|
||||
const props = { size: 16, "aria-hidden": true as const };
|
||||
if (action === "approve" || action === "accept") return <Check {...props} />;
|
||||
if (action === "reject") return <X {...props} />;
|
||||
if (action === "request_changes") return <Undo2 {...props} />;
|
||||
if (action === "recover") return <RotateCcw {...props} />;
|
||||
return <Undo2 {...props} />;
|
||||
}
|
||||
|
||||
export default function FunctionAssignmentChangesPanel({ settings, auth, model, identities }: Props) {
|
||||
const canRequest = hasScope(auth, "idm:function_request:create");
|
||||
const canGrant = hasScope(auth, "idm:function_grant:create") || hasScope(auth, "idm:organization_assignment:write");
|
||||
const visible = canRequest || canGrant || hasScope(auth, "idm:function_change:read") || hasScope(auth, "idm:function_change:decide") || hasScope(auth, "idm:function_change:admin");
|
||||
const initialKind: FunctionAssignmentChangeKind = canRequest ? "request" : "grant";
|
||||
const [changes, setChanges] = useState<FunctionAssignmentChange[]>([]);
|
||||
const [draft, setDraft] = useState<Draft>(() => emptyDraft(auth, initialKind));
|
||||
const [selected, setSelected] = useState<FunctionAssignmentChange | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [comment, setComment] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const initialChangeId = useMemo(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return new URLSearchParams(window.location.search).get("change") ?? "";
|
||||
}, []);
|
||||
const initialChangeOpened = useRef(false);
|
||||
const functionById = useMemo(() => new Map(model.functions.map((item) => [item.id, item])), [model.functions]);
|
||||
const identityById = useMemo(() => new Map(identities.map((item) => [item.id, item])), [identities]);
|
||||
const selectedIdentity = identityById.get(draft.identityId);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!visible) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await getFunctionAssignmentChanges(settings);
|
||||
setChanges(response.changes);
|
||||
if (initialChangeId && !initialChangeOpened.current) {
|
||||
initialChangeOpened.current = true;
|
||||
const detail = await getFunctionAssignmentChange(settings, initialChangeId);
|
||||
setSelected(detail);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [initialChangeId, settings, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
function setKind(kind: FunctionAssignmentChangeKind) {
|
||||
setDraft(emptyDraft(auth, kind));
|
||||
}
|
||||
|
||||
function setIdentity(identityId: string) {
|
||||
const identity = identityById.get(identityId);
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
identityId,
|
||||
accountId: identity?.primary_account_id ?? identity?.account_ids[0] ?? ""
|
||||
}));
|
||||
}
|
||||
|
||||
async function openDetail(item: FunctionAssignmentChange) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const detail = await getFunctionAssignmentChange(settings, item.id);
|
||||
setSelected(detail);
|
||||
setComment("");
|
||||
setDetailOpen(true);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createFunctionAssignmentChange(settings, {
|
||||
kind: draft.kind,
|
||||
function_id: draft.functionId,
|
||||
candidate_identity_id: draft.identityId,
|
||||
candidate_account_id: draft.accountId || null,
|
||||
justification: draft.justification.trim(),
|
||||
evidence: draft.evidence.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||
requested_valid_from: dateTimeValue(draft.validFrom),
|
||||
requested_valid_until: dateTimeValue(draft.validUntil),
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
metadata: {}
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setDraft(emptyDraft(auth, initialKind));
|
||||
setSelected(created);
|
||||
setDetailOpen(true);
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function performAction(action: FunctionAssignmentChangeAction) {
|
||||
if (!selected) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await actOnFunctionAssignmentChange(settings, selected, action, comment);
|
||||
setSelected(updated);
|
||||
setComment("");
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataGridColumn<FunctionAssignmentChange>[] = [
|
||||
{ id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, value: (row) => row.kind },
|
||||
{
|
||||
id: "function",
|
||||
header: "Function",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => functionById.get(row.function_id)?.name ?? row.function_id,
|
||||
render: (row) => functionById.get(row.function_id)?.name ?? row.function_id
|
||||
},
|
||||
{
|
||||
id: "candidate",
|
||||
header: "Candidate",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => identityById.get(row.candidate_identity_id)?.display_name ?? row.candidate_identity_id,
|
||||
render: (row) => identityById.get(row.candidate_identity_id)?.display_name ?? row.candidate_identity_id
|
||||
},
|
||||
{ id: "state", header: "State", width: 170, sortable: true, filterable: true, value: (row) => row.state, render: (row) => <StatusBadge status={statusTone(row.state)} label={row.state.replaceAll("_", " ")} /> },
|
||||
{ id: "progress", header: "Decisions", width: 140, value: (row) => `${row.completed_steps.length}/${row.required_steps.length}`, render: (row) => `${row.completed_steps.length} / ${row.required_steps.length}` },
|
||||
{ id: "updated", header: "Updated", width: 170, sortable: true, value: (row) => row.updated_at, render: (row) => new Date(row.updated_at).toLocaleString() },
|
||||
{ id: "actions", header: "Actions", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "view", label: "Open change", icon: <Eye size={16} aria-hidden="true" />, onClick: () => void openDetail(row) }]} /> }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<Card
|
||||
title="Function requests and grants"
|
||||
collapsible
|
||||
collapseKey="idm.function-assignment-changes"
|
||||
actions={(canRequest || canGrant) ? <AdminIconButton label="Start governed change" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={busy} onClick={() => setCreateOpen(true)} /> : undefined}
|
||||
>
|
||||
<DataGrid id="idm-function-assignment-changes" rows={changes} columns={columns} getRowKey={(row) => row.id} emptyText={loading ? "Loading changes..." : "No governed function changes"} initialFit="container" />
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
title="Start governed function change"
|
||||
className="admin-dialog admin-dialog-wide idm-change-dialog"
|
||||
onClose={() => !busy && setCreateOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<><Button type="button" onClick={() => setCreateOpen(false)} disabled={busy}>Cancel</Button><Button type="submit" form="idm-change-create" variant="primary" disabled={busy || !draft.functionId || !draft.identityId || !draft.justification.trim()}>Submit</Button></>}
|
||||
>
|
||||
<form id="idm-change-create" className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
|
||||
<div className="wide">
|
||||
<SegmentedControl
|
||||
ariaLabel="Function change kind"
|
||||
value={draft.kind}
|
||||
onChange={setKind}
|
||||
options={[
|
||||
{ id: "request", label: "Request function", disabled: !canRequest },
|
||||
{ id: "grant", label: "Grant function", disabled: !canGrant }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<FormField label="Function">
|
||||
<select value={draft.functionId} onChange={(event) => setDraft((current) => ({ ...current, functionId: event.target.value }))} disabled={busy}>
|
||||
<option value="">Select function</option>
|
||||
{model.functions.filter((item) => item.is_active).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Candidate identity">
|
||||
<select value={draft.identityId} onChange={(event) => setIdentity(event.target.value)} disabled={busy || draft.kind === "request"}>
|
||||
<option value="">Select identity</option>
|
||||
{identities.map((item) => <option key={item.id} value={item.id}>{item.display_name || item.external_subject || item.id}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Candidate account">
|
||||
<select value={draft.accountId} onChange={(event) => setDraft((current) => ({ ...current, accountId: event.target.value }))} disabled={busy}>
|
||||
<option value="">No linked account</option>
|
||||
{(selectedIdentity?.account_ids ?? []).map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Valid from">
|
||||
<input type="datetime-local" value={draft.validFrom} onChange={(event) => setDraft((current) => ({ ...current, validFrom: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
<FormField label="Valid until">
|
||||
<input type="datetime-local" value={draft.validUntil} onChange={(event) => setDraft((current) => ({ ...current, validUntil: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
<div className="wide">
|
||||
<FormField label="Justification">
|
||||
<textarea rows={4} value={draft.justification} onChange={(event) => setDraft((current) => ({ ...current, justification: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<FormField label="Evidence references (one per line)">
|
||||
<textarea rows={3} value={draft.evidence} onChange={(event) => setDraft((current) => ({ ...current, evidence: event.target.value }))} disabled={busy} />
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={detailOpen && selected !== null}
|
||||
title={selected ? `${selected.kind === "request" ? "Function request" : "Function grant"}: ${functionById.get(selected.function_id)?.name ?? selected.function_id}` : "Function change"}
|
||||
className="admin-dialog admin-dialog-wide idm-change-dialog"
|
||||
onClose={() => !busy && setDetailOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<Button type="button" onClick={() => setDetailOpen(false)} disabled={busy}>Close</Button>}
|
||||
>
|
||||
{selected && (
|
||||
<div className="idm-change-detail">
|
||||
<dl className="idm-change-summary">
|
||||
<div><dt>State</dt><dd><StatusBadge status={statusTone(selected.state)} label={selected.state.replaceAll("_", " ")} /></dd></div>
|
||||
<div><dt>Candidate</dt><dd>{identityById.get(selected.candidate_identity_id)?.display_name ?? selected.candidate_identity_id}</dd></div>
|
||||
<div><dt>Profile</dt><dd>{selected.profile}</dd></div>
|
||||
<div><dt>Workflow revision</dt><dd>{selected.workflow_definition_revision ?? "-"}</dd></div>
|
||||
<div><dt>Required decisions</dt><dd>{selected.required_steps.join(", ") || "None"}</dd></div>
|
||||
<div><dt>Completed decisions</dt><dd>{selected.completed_steps.join(", ") || "None"}</dd></div>
|
||||
<div className="wide"><dt>Justification</dt><dd>{selected.justification}</dd></div>
|
||||
{selected.outcome_reason && <div className="wide"><dt>Explanation</dt><dd>{selected.outcome_reason}</dd></div>}
|
||||
</dl>
|
||||
{selected.available_actions.length > 0 && (
|
||||
<div className="idm-change-actions">
|
||||
<FormField label="Decision comment">
|
||||
<textarea rows={2} value={comment} onChange={(event) => setComment(event.target.value)} disabled={busy} />
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
{selected.available_actions.map((action) => (
|
||||
<Button key={action} type="button" variant={action === "reject" ? "danger" : action === "approve" || action === "accept" ? "primary" : "secondary"} disabled={busy} onClick={() => void performAction(action)}>
|
||||
{actionIcon(action)} {actionLabel(action)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.availability_reason && selected.available_actions.length === 0 && <p className="idm-muted">{selected.availability_reason}</p>}
|
||||
<div>
|
||||
<h3>History</h3>
|
||||
<ol className="idm-change-history">
|
||||
{selected.events.map((event) => <li key={event.id}><strong>{event.action}</strong><span>{new Date(event.created_at).toLocaleString()}</span><span>{event.from_state ? `${event.from_state} -> ` : ""}{event.to_state}</span>{event.comment && <p>{event.comment}</p>}</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type OrganizationModel,
|
||||
type OrganizationUnitItem
|
||||
} from "../api/idm";
|
||||
import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel";
|
||||
|
||||
type IdmPageProps = {
|
||||
settings: ApiSettings;
|
||||
@@ -52,6 +53,8 @@ type AssignmentDraft = {
|
||||
delegated_from_assignment_id: string;
|
||||
acting_for_account_id: string;
|
||||
is_active: boolean;
|
||||
governance_override_reason: string;
|
||||
governance_override_evidence: string;
|
||||
};
|
||||
|
||||
type SettingsDraft = {
|
||||
@@ -91,7 +94,9 @@ function emptyAssignmentDraft(): AssignmentDraft {
|
||||
source: "direct",
|
||||
delegated_from_assignment_id: "",
|
||||
acting_for_account_id: "",
|
||||
is_active: true
|
||||
is_active: true,
|
||||
governance_override_reason: "",
|
||||
governance_override_evidence: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,7 +115,12 @@ function assignmentPayload(draft: AssignmentDraft): OrganizationFunctionAssignme
|
||||
delegated_from_assignment_id: textOrNull(draft.delegated_from_assignment_id),
|
||||
acting_for_account_id: textOrNull(draft.acting_for_account_id),
|
||||
is_active: draft.is_active,
|
||||
settings: {}
|
||||
settings: {},
|
||||
governance_override_reason: textOrNull(draft.governance_override_reason),
|
||||
governance_override_evidence: draft.governance_override_evidence
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +133,9 @@ function assignmentDraftFrom(item: OrganizationFunctionAssignmentItem): Assignme
|
||||
source: item.source || "direct",
|
||||
delegated_from_assignment_id: item.delegated_from_assignment_id ?? "",
|
||||
acting_for_account_id: item.acting_for_account_id ?? "",
|
||||
is_active: item.is_active
|
||||
is_active: item.is_active,
|
||||
governance_override_reason: "",
|
||||
governance_override_evidence: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +148,8 @@ function isAssignmentDirty(draft: AssignmentDraft): boolean {
|
||||
draft.source !== "direct" ||
|
||||
draft.delegated_from_assignment_id ||
|
||||
draft.acting_for_account_id ||
|
||||
draft.governance_override_reason ||
|
||||
draft.governance_override_evidence ||
|
||||
!draft.is_active
|
||||
);
|
||||
}
|
||||
@@ -224,6 +238,16 @@ function sourceLabel(value: string): string {
|
||||
return SOURCE_OPTIONS.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function isGovernedFunction(item: OrganizationFunctionItem | undefined): boolean {
|
||||
const governance = item?.settings.assignment_governance;
|
||||
if (!governance || typeof governance !== "object" || Array.isArray(governance)) return false;
|
||||
const values = governance as Record<string, unknown>;
|
||||
return ["request_profile", "grant_profile"].some((key) => {
|
||||
const value = String(values[key] ?? "unavailable").trim().toLowerCase();
|
||||
return Boolean(value && value !== "unavailable");
|
||||
});
|
||||
}
|
||||
|
||||
function idmInitialQuery(): { assignmentId: string; functionId: string } {
|
||||
if (typeof window === "undefined") return { assignmentId: "", functionId: "" };
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -265,6 +289,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
const identityOptionById = useMemo(() => mapById(identityOptions), [identityOptions]);
|
||||
const actingForOptionById = useMemo(() => mapById(actingForOptions), [actingForOptions]);
|
||||
const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id);
|
||||
const selectedFunctionIsGoverned = isGovernedFunction(functionById.get(assignmentDraft.function_id));
|
||||
|
||||
const identitySelectOptions = useMemo(() => {
|
||||
if (!assignmentDraft.identity_id || identityOptionById.has(assignmentDraft.identity_id)) return identityOptions;
|
||||
@@ -450,6 +475,10 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
setError("i18n:govoplan-idm.function_is_required.5cce5b41");
|
||||
return false;
|
||||
}
|
||||
if (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim()) {
|
||||
setError("Direct changes to this governed function require an emergency override reason.");
|
||||
return false;
|
||||
}
|
||||
const requestId = textOrNull(assignmentChangeRequestId);
|
||||
const payload = requestId ? { ...assignmentPayload(assignmentDraft), change_request_id: requestId } : assignmentPayload(assignmentDraft);
|
||||
const ok = await runAction(
|
||||
@@ -458,7 +487,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
);
|
||||
if (ok) discardAssignmentDraft();
|
||||
return ok;
|
||||
}, [assignmentChangeRequestId, assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, settings]);
|
||||
}, [assignmentChangeRequestId, assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, selectedFunctionIsGoverned, settings]);
|
||||
|
||||
const saveDrafts = useCallback(async (): Promise<boolean> => {
|
||||
if (hasDirtyAssignmentDraft && !(await submitAssignment())) return false;
|
||||
@@ -625,6 +654,13 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<FunctionAssignmentChangesPanel
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
model={model}
|
||||
identities={identityOptions}
|
||||
/>
|
||||
|
||||
<Card title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy} onClick={openCreateAssignment} />}>
|
||||
<DataGrid
|
||||
id="idm-organization-function-assignments"
|
||||
@@ -655,7 +691,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" onClick={discardAssignmentDraft} disabled={busy}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
|
||||
<Button type="submit" form={formId} variant="primary" disabled={!canManage || busy || !assignmentDraft.identity_id || !assignmentDraft.function_id}>
|
||||
<Button type="submit" form={formId} variant="primary" disabled={!canManage || busy || !assignmentDraft.identity_id || !assignmentDraft.function_id || (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim())}>
|
||||
{editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
||||
</Button>
|
||||
</>
|
||||
@@ -727,6 +763,29 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
<ToggleSwitch label="i18n:govoplan-idm.applies_to_subunits.2e31b50b" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} onChange={(applies_to_subunits) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits })} />
|
||||
<ToggleSwitch label="i18n:govoplan-idm.active.7bd0e9f8" checked={assignmentDraft.is_active} disabled={!canManage || busy} onChange={(is_active) => setAssignmentDraft({ ...assignmentDraft, is_active })} />
|
||||
</div>
|
||||
{selectedFunctionIsGoverned && (
|
||||
<div className="wide idm-governance-override">
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.
|
||||
</DismissibleAlert>
|
||||
<FormField label="Emergency override reason">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={assignmentDraft.governance_override_reason}
|
||||
onChange={(event) => setAssignmentDraft({ ...assignmentDraft, governance_override_reason: event.target.value })}
|
||||
disabled={!canManage || busy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Override evidence references (one per line)">
|
||||
<textarea
|
||||
rows={2}
|
||||
value={assignmentDraft.governance_override_evidence}
|
||||
onChange={(event) => setAssignmentDraft({ ...assignmentDraft, governance_override_evidence: event.target.value })}
|
||||
disabled={!canManage || busy}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
<div className="wide idm-dialog-change-request">
|
||||
<FormField label="i18n:govoplan-idm.change_request_id.b7d816db">
|
||||
<input value={assignmentChangeRequestId} onChange={(event) => setAssignmentChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
|
||||
|
||||
@@ -66,3 +66,87 @@
|
||||
.idm-dialog-change-request {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.idm-governance-override {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.idm-change-detail {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.idm-change-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.idm-change-summary > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.idm-change-summary > .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.idm-change-summary dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.idm-change-summary dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.idm-change-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-block: 14px;
|
||||
border-block: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.idm-change-history {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.idm-change-history li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 0.6fr) minmax(150px, 0.7fr) minmax(180px, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.idm-change-history p {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.idm-change-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.idm-change-summary > .wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.idm-change-history li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user