Implement governed function assignment workflows
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
|
||||
from govoplan_core.core.concurrency import (
|
||||
ConcurrencyError,
|
||||
MissingPreconditionError,
|
||||
RevisionConflictError,
|
||||
assert_revision_precondition,
|
||||
)
|
||||
from govoplan_core.core.identity import (
|
||||
CAPABILITY_IDENTITY_DIRECTORY,
|
||||
IdentityDirectory,
|
||||
)
|
||||
from govoplan_core.core.organizations import (
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
OrganizationDirectory,
|
||||
OrganizationFunctionRef,
|
||||
)
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.core.workflows import CAPABILITY_WORKFLOW_ORCHESTRATION
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_idm.backend.db.models import IdmFunctionAssignmentChange
|
||||
from govoplan_idm.backend.function_assignment_changes import (
|
||||
FunctionAssignmentChangeConflict,
|
||||
FunctionAssignmentChangeUnavailable,
|
||||
available_change_actions,
|
||||
change_events,
|
||||
create_function_assignment_change,
|
||||
resolve_submission_capability,
|
||||
transition_function_assignment_change,
|
||||
visible_change_filter,
|
||||
)
|
||||
|
||||
from .schemas import (
|
||||
FunctionAssignmentCapabilityItem,
|
||||
FunctionAssignmentChangeActionRequest,
|
||||
FunctionAssignmentChangeCreateRequest,
|
||||
FunctionAssignmentChangeEventItem,
|
||||
FunctionAssignmentChangeItem,
|
||||
FunctionAssignmentChangeKind,
|
||||
FunctionAssignmentChangeList,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/function-assignment-changes")
|
||||
READ_SCOPES = (
|
||||
"idm:function_change:read",
|
||||
"idm:function_request:create",
|
||||
"idm:function_grant:create",
|
||||
"idm:function_change:decide",
|
||||
"idm:function_change:admin",
|
||||
"idm:organization_assignment:write",
|
||||
)
|
||||
|
||||
|
||||
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
if not any(principal.has(scope) for scope in scopes):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires one of: {', '.join(scopes)}",
|
||||
)
|
||||
|
||||
|
||||
def _registry():
|
||||
registry = get_registry()
|
||||
if registry is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="The module registry is unavailable.",
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
def _organization_directory() -> OrganizationDirectory:
|
||||
registry = _registry()
|
||||
capability = registry.capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
if not isinstance(capability, OrganizationDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="The Organizations directory is unavailable.",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _identity_directory() -> IdentityDirectory:
|
||||
registry = _registry()
|
||||
capability = registry.capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
if not isinstance(capability, IdentityDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="The Identity directory is unavailable.",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _function(function_id: str, tenant_id: str) -> OrganizationFunctionRef:
|
||||
function = _organization_directory().get_function(function_id)
|
||||
if function is None or function.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Organization function not found.",
|
||||
)
|
||||
return function
|
||||
|
||||
|
||||
def _historical_function(
|
||||
function_id: str,
|
||||
tenant_id: str,
|
||||
) -> OrganizationFunctionRef | None:
|
||||
try:
|
||||
function = _organization_directory().get_function(function_id)
|
||||
except HTTPException:
|
||||
return None
|
||||
if function is None or function.tenant_id != tenant_id:
|
||||
return None
|
||||
return function
|
||||
|
||||
|
||||
def _validate_candidate(identity_id: str, account_id: str | None) -> None:
|
||||
directory = _identity_directory()
|
||||
if directory.get_identity(identity_id) is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Identity not found.",
|
||||
)
|
||||
if account_id is not None and not any(
|
||||
link.account_id == account_id
|
||||
for link in directory.accounts_for_identity(identity_id)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Account is not linked to the selected identity.",
|
||||
)
|
||||
|
||||
|
||||
def _change_query(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
):
|
||||
statement = select(IdmFunctionAssignmentChange).where(
|
||||
IdmFunctionAssignmentChange.tenant_id == principal.tenant_id
|
||||
)
|
||||
visibility = visible_change_filter(session, principal)
|
||||
return statement.where(visibility) if visibility is not None else statement
|
||||
|
||||
|
||||
def _get_change(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
change_id: str,
|
||||
) -> IdmFunctionAssignmentChange:
|
||||
change = session.scalar(
|
||||
_change_query(session, principal).where(
|
||||
IdmFunctionAssignmentChange.id == change_id
|
||||
)
|
||||
)
|
||||
if change is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Function assignment change not found.",
|
||||
)
|
||||
return change
|
||||
|
||||
|
||||
def _event_item(event) -> FunctionAssignmentChangeEventItem:
|
||||
return FunctionAssignmentChangeEventItem(
|
||||
id=event.id,
|
||||
sequence=event.sequence,
|
||||
action=event.action,
|
||||
from_state=event.from_state,
|
||||
to_state=event.to_state,
|
||||
actor_account_id=event.actor_account_id,
|
||||
actor_identity_id=event.actor_identity_id,
|
||||
actor_assignment_id=event.actor_assignment_id,
|
||||
comment=event.comment,
|
||||
evidence=list(event.evidence),
|
||||
policy_decision=dict(event.policy_decision),
|
||||
workflow_step_id=event.workflow_step_id,
|
||||
details=dict(event.details),
|
||||
created_at=event.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _change_item(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
*,
|
||||
include_events: bool,
|
||||
) -> FunctionAssignmentChangeItem:
|
||||
function = _historical_function(change.function_id, change.tenant_id)
|
||||
if function is None:
|
||||
actions, reason = [], "The referenced organization function is no longer available."
|
||||
else:
|
||||
try:
|
||||
actions, reason = available_change_actions(
|
||||
session,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
change=change,
|
||||
function=function,
|
||||
)
|
||||
except FunctionAssignmentChangeUnavailable as exc:
|
||||
actions, reason = [], str(exc)
|
||||
return FunctionAssignmentChangeItem(
|
||||
id=change.id,
|
||||
tenant_id=change.tenant_id,
|
||||
kind=change.kind,
|
||||
state=change.state,
|
||||
profile=change.profile,
|
||||
function_id=change.function_id,
|
||||
organization_unit_id=change.organization_unit_id,
|
||||
candidate_identity_id=change.candidate_identity_id,
|
||||
candidate_account_id=change.candidate_account_id,
|
||||
initiator_account_id=change.initiator_account_id,
|
||||
initiator_identity_id=change.initiator_identity_id,
|
||||
represented_assignment_id=change.represented_assignment_id,
|
||||
justification=change.justification,
|
||||
evidence=list(change.evidence),
|
||||
requested_valid_from=change.requested_valid_from,
|
||||
requested_valid_until=change.requested_valid_until,
|
||||
applies_to_subunits=change.applies_to_subunits,
|
||||
assignment_source=change.assignment_source,
|
||||
required_steps=list(change.required_steps),
|
||||
completed_steps=list(change.completed_steps),
|
||||
policy_decision=dict(change.policy_decision),
|
||||
workflow_definition_id=change.workflow_definition_id,
|
||||
workflow_definition_revision_id=change.workflow_definition_revision_id,
|
||||
workflow_definition_revision=change.workflow_definition_revision,
|
||||
workflow_definition_hash=change.workflow_definition_hash,
|
||||
workflow_instance_id=change.workflow_instance_id,
|
||||
workflow_current_step_id=change.workflow_current_step_id,
|
||||
resulting_assignment_id=change.resulting_assignment_id,
|
||||
expires_at=change.expires_at,
|
||||
outcome_reason=change.outcome_reason,
|
||||
resource_revision=change.resource_revision,
|
||||
etag=change.strong_etag,
|
||||
metadata=dict(change.metadata_),
|
||||
events=(
|
||||
[
|
||||
_event_item(event)
|
||||
for event in change_events(session, change_id=change.id)
|
||||
]
|
||||
if include_events
|
||||
else []
|
||||
),
|
||||
available_actions=actions,
|
||||
availability_reason=reason,
|
||||
created_at=change.created_at,
|
||||
updated_at=change.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _set_etag(response: Response, change: IdmFunctionAssignmentChange) -> None:
|
||||
response.headers["ETag"] = change.strong_etag
|
||||
|
||||
|
||||
def _record_change_audit(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
change: IdmFunctionAssignmentChange,
|
||||
action: str,
|
||||
from_state: str | None,
|
||||
) -> None:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_AUDIT_RECORDER):
|
||||
return
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=f"idm.function_assignment_change.{action}",
|
||||
object_type="function_assignment_change",
|
||||
object_id=change.id,
|
||||
details={
|
||||
"kind": change.kind,
|
||||
"from_state": from_state,
|
||||
"to_state": change.state,
|
||||
"function_id": change.function_id,
|
||||
"candidate_identity_id": change.candidate_identity_id,
|
||||
"represented_assignment_id": change.represented_assignment_id,
|
||||
"policy_decision": dict(change.policy_decision),
|
||||
"workflow_definition_id": change.workflow_definition_id,
|
||||
"workflow_definition_revision_id": change.workflow_definition_revision_id,
|
||||
"workflow_instance_id": change.workflow_instance_id,
|
||||
"evidence": list(change.evidence),
|
||||
"resulting_assignment_id": change.resulting_assignment_id,
|
||||
"resource_revision": change.resource_revision,
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
|
||||
|
||||
def _mutation_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, MissingPreconditionError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
||||
detail=exc.as_dict(),
|
||||
)
|
||||
if isinstance(exc, RevisionConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
||||
detail=exc.as_dict(),
|
||||
)
|
||||
if isinstance(exc, ConcurrencyError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "concurrency_conflict", "message": str(exc)},
|
||||
)
|
||||
if isinstance(exc, FunctionAssignmentChangeConflict):
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/capability", response_model=FunctionAssignmentCapabilityItem)
|
||||
def get_function_assignment_capability(
|
||||
kind: FunctionAssignmentChangeKind,
|
||||
function_id: str = Query(min_length=1, max_length=36),
|
||||
candidate_identity_id: str | None = Query(default=None, max_length=36),
|
||||
candidate_account_id: str | None = Query(default=None, max_length=36),
|
||||
has_evidence: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> FunctionAssignmentCapabilityItem:
|
||||
_require_any(principal, *READ_SCOPES)
|
||||
candidate_identity_id = candidate_identity_id or principal.identity_id
|
||||
if candidate_identity_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="A candidate identity is required.",
|
||||
)
|
||||
function = _function(function_id, principal.tenant_id)
|
||||
registry = _registry()
|
||||
decision, reason = resolve_submission_capability(
|
||||
session,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
kind=kind,
|
||||
function=function,
|
||||
candidate_identity_id=candidate_identity_id,
|
||||
candidate_account_id=candidate_account_id,
|
||||
has_evidence=has_evidence,
|
||||
)
|
||||
return FunctionAssignmentCapabilityItem(
|
||||
kind=kind,
|
||||
function_id=function_id,
|
||||
available=bool(decision and decision.allowed),
|
||||
reason=reason,
|
||||
profile=decision.profile if decision else "unavailable",
|
||||
required_steps=list(decision.required_steps) if decision else [],
|
||||
requirements=list(decision.requirements) if decision else [],
|
||||
authority_function_id=(decision.authority_function_id if decision else None),
|
||||
evidence_required=bool(decision and decision.evidence_required),
|
||||
recipient_acceptance_required=bool(
|
||||
decision and decision.recipient_acceptance_required
|
||||
),
|
||||
maximum_validity_days=(decision.maximum_validity_days if decision else None),
|
||||
workflow_available=registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION),
|
||||
policy_available=registry.has_capability(
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=FunctionAssignmentChangeList)
|
||||
def list_function_assignment_changes(
|
||||
kind: FunctionAssignmentChangeKind | None = None,
|
||||
state_filter: str | None = Query(default=None, alias="state", max_length=40),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> FunctionAssignmentChangeList:
|
||||
_require_any(principal, *READ_SCOPES)
|
||||
statement = _change_query(session, principal)
|
||||
if kind is not None:
|
||||
statement = statement.where(IdmFunctionAssignmentChange.kind == kind)
|
||||
if state_filter:
|
||||
statement = statement.where(IdmFunctionAssignmentChange.state == state_filter)
|
||||
total = int(
|
||||
session.scalar(select(func.count()).select_from(statement.subquery())) or 0
|
||||
)
|
||||
rows = list(
|
||||
session.scalars(
|
||||
statement.order_by(
|
||||
IdmFunctionAssignmentChange.updated_at.desc(),
|
||||
IdmFunctionAssignmentChange.id.desc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
)
|
||||
return FunctionAssignmentChangeList(
|
||||
changes=[
|
||||
_change_item(session, principal, change, include_events=False)
|
||||
for change in rows
|
||||
],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=max(1, ceil(total / page_size)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{change_id}", response_model=FunctionAssignmentChangeItem)
|
||||
def get_function_assignment_change(
|
||||
change_id: str,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> FunctionAssignmentChangeItem:
|
||||
_require_any(principal, *READ_SCOPES)
|
||||
change = _get_change(session, principal, change_id)
|
||||
_set_etag(response, change)
|
||||
return _change_item(session, principal, change, include_events=True)
|
||||
|
||||
|
||||
@router.post("", response_model=FunctionAssignmentChangeItem, status_code=201)
|
||||
def create_governed_function_assignment_change(
|
||||
payload: FunctionAssignmentChangeCreateRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> FunctionAssignmentChangeItem:
|
||||
required = (
|
||||
"idm:function_request:create"
|
||||
if payload.kind == "request"
|
||||
else "idm:function_grant:create"
|
||||
)
|
||||
_require_any(
|
||||
principal,
|
||||
required,
|
||||
"idm:function_change:admin",
|
||||
"idm:organization_assignment:write",
|
||||
)
|
||||
_validate_candidate(payload.candidate_identity_id, payload.candidate_account_id)
|
||||
try:
|
||||
change, replayed = create_function_assignment_change(
|
||||
session,
|
||||
principal=principal,
|
||||
registry=_registry(),
|
||||
function=_function(payload.function_id, principal.tenant_id),
|
||||
payload=payload,
|
||||
)
|
||||
if not replayed:
|
||||
_record_change_audit(
|
||||
session,
|
||||
principal,
|
||||
change=change,
|
||||
action="created",
|
||||
from_state=None,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
FunctionAssignmentChangeConflict,
|
||||
FunctionAssignmentChangeUnavailable,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _mutation_error(exc) from exc
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The function assignment change conflicts with existing data.",
|
||||
) from exc
|
||||
session.refresh(change)
|
||||
response.status_code = status.HTTP_200_OK if replayed else status.HTTP_201_CREATED
|
||||
_set_etag(response, change)
|
||||
return _change_item(session, principal, change, include_events=True)
|
||||
|
||||
|
||||
@router.post("/{change_id}/actions", response_model=FunctionAssignmentChangeItem)
|
||||
def act_on_function_assignment_change(
|
||||
change_id: str,
|
||||
payload: FunctionAssignmentChangeActionRequest,
|
||||
response: Response,
|
||||
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> FunctionAssignmentChangeItem:
|
||||
change = _get_change(session, principal, change_id)
|
||||
from_state = change.state
|
||||
if payload.action == "recover":
|
||||
_require_any(principal, "idm:function_change:admin")
|
||||
elif payload.action not in {"withdraw", "respond"}:
|
||||
_require_any(
|
||||
principal,
|
||||
"idm:function_change:decide",
|
||||
"idm:function_change:admin",
|
||||
"idm:organization_assignment:write",
|
||||
)
|
||||
try:
|
||||
assert_revision_precondition(
|
||||
if_match,
|
||||
resource_type="idm_function_assignment_change",
|
||||
resource_id=change.id,
|
||||
submitted_base_revision=payload.base_revision,
|
||||
)
|
||||
change = transition_function_assignment_change(
|
||||
session,
|
||||
principal=principal,
|
||||
registry=_registry(),
|
||||
change=change,
|
||||
function=_function(change.function_id, change.tenant_id),
|
||||
action=payload.action,
|
||||
base_revision=payload.base_revision,
|
||||
comment=payload.comment,
|
||||
evidence=payload.evidence,
|
||||
)
|
||||
_record_change_audit(
|
||||
session,
|
||||
principal,
|
||||
change=change,
|
||||
action=payload.action,
|
||||
from_state=from_state,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
ConcurrencyError,
|
||||
FunctionAssignmentChangeConflict,
|
||||
FunctionAssignmentChangeUnavailable,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _mutation_error(exc) from exc
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The function assignment transition conflicts with existing data.",
|
||||
) from exc
|
||||
session.refresh(change)
|
||||
_set_etag(response, change)
|
||||
return _change_item(session, principal, change, include_events=True)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -284,7 +284,14 @@ def _plan_assignment_update(
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> AssignmentMutationPlan:
|
||||
values = payload.model_dump(exclude_unset=True, exclude={"change_request_id"})
|
||||
values = payload.model_dump(
|
||||
exclude_unset=True,
|
||||
exclude={
|
||||
"change_request_id",
|
||||
"governance_override_reason",
|
||||
"governance_override_evidence",
|
||||
},
|
||||
)
|
||||
organization_unit_id: str | None = None
|
||||
if "function_id" in values and values["function_id"] is not None:
|
||||
function = _organization_function(str(values["function_id"]), tenant_id)
|
||||
@@ -330,16 +337,32 @@ def _actor_id(principal: ApiPrincipal) -> str:
|
||||
return principal.membership_id or principal.account_id
|
||||
|
||||
|
||||
def _payload_for_control(resource_type: str, operation: str, payload: object) -> dict[str, Any]:
|
||||
def _payload_for_control(
|
||||
resource_type: str, operation: str, payload: object
|
||||
) -> dict[str, Any]:
|
||||
if hasattr(payload, "model_dump"):
|
||||
values = payload.model_dump(mode="json", exclude={"change_request_id"}, exclude_unset=True) # type: ignore[attr-defined]
|
||||
values = payload.model_dump( # type: ignore[attr-defined]
|
||||
mode="json",
|
||||
exclude={
|
||||
"change_request_id",
|
||||
"governance_override_reason",
|
||||
"governance_override_evidence",
|
||||
},
|
||||
exclude_unset=True,
|
||||
)
|
||||
else:
|
||||
values = {}
|
||||
return {"resource_type": resource_type, "operation": operation, "payload": values}
|
||||
|
||||
|
||||
def _target_for_control(tenant_id: str, resource_type: str, operation: str, resource_id: str | None = None) -> dict[str, Any]:
|
||||
target: dict[str, Any] = {"tenant_id": tenant_id, "resource_type": resource_type, "operation": operation}
|
||||
def _target_for_control(
|
||||
tenant_id: str, resource_type: str, operation: str, resource_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
target: dict[str, Any] = {
|
||||
"tenant_id": tenant_id,
|
||||
"resource_type": resource_type,
|
||||
"operation": operation,
|
||||
}
|
||||
if resource_id is not None:
|
||||
target["resource_id"] = resource_id
|
||||
return target
|
||||
@@ -440,6 +463,48 @@ def _publish_assignment_event(
|
||||
)
|
||||
|
||||
|
||||
def _apply_governance_override(
|
||||
principal: ApiPrincipal,
|
||||
function: OrganizationFunctionRef,
|
||||
payload: object,
|
||||
settings: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
governance = function.settings.get("assignment_governance")
|
||||
if not isinstance(governance, dict) or not any(
|
||||
str(governance.get(key) or "unavailable").strip().casefold() != "unavailable"
|
||||
for key in ("request_profile", "grant_profile")
|
||||
):
|
||||
return settings
|
||||
if not principal.has("idm:function_change:admin"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Direct assignment to a governed function requires function "
|
||||
"change recovery permission."
|
||||
),
|
||||
)
|
||||
reason = str(getattr(payload, "governance_override_reason", None) or "").strip()
|
||||
if not reason:
|
||||
raise _invalid(
|
||||
"Direct assignment to a governed function requires an emergency override reason."
|
||||
)
|
||||
evidence = [
|
||||
str(item).strip()
|
||||
for item in getattr(payload, "governance_override_evidence", ())
|
||||
if str(item).strip()
|
||||
]
|
||||
return {
|
||||
**settings,
|
||||
"governance_override": {
|
||||
"reason": reason,
|
||||
"evidence": evidence,
|
||||
"actor_account_id": principal.account_id,
|
||||
"recorded_at": utc_now().isoformat(),
|
||||
"function_id": function.id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/settings", response_model=IdmSettingsItem)
|
||||
def get_idm_settings(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -545,6 +610,12 @@ def create_organization_function_assignment(
|
||||
_ensure_identity(payload.identity_id)
|
||||
_ensure_account_link(payload.identity_id, payload.account_id)
|
||||
function = _organization_function(payload.function_id, tenant_id)
|
||||
item_settings = _apply_governance_override(
|
||||
principal,
|
||||
function,
|
||||
payload,
|
||||
dict(payload.settings),
|
||||
)
|
||||
item = IdmOrganizationFunctionAssignment(
|
||||
tenant_id=tenant_id,
|
||||
identity_id=payload.identity_id,
|
||||
@@ -558,7 +629,7 @@ def create_organization_function_assignment(
|
||||
valid_from=payload.valid_from,
|
||||
valid_until=payload.valid_until,
|
||||
is_active=payload.is_active,
|
||||
settings=payload.settings,
|
||||
settings=item_settings,
|
||||
)
|
||||
if item.delegated_from_assignment_id is not None:
|
||||
_get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
|
||||
@@ -614,6 +685,13 @@ def update_organization_function_assignment(
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
plan.apply(item)
|
||||
function = _organization_function(item.function_id, tenant_id)
|
||||
item.settings = _apply_governance_override(
|
||||
principal,
|
||||
function,
|
||||
payload,
|
||||
dict(item.settings),
|
||||
)
|
||||
now = utc_now()
|
||||
event_types = lifecycle_event_types(
|
||||
plan.before,
|
||||
@@ -678,3 +756,9 @@ def _identity_candidate(identity: IdentityRef) -> OrganizationIdentityCandidate:
|
||||
account_ids=list(identity.account_ids),
|
||||
status=identity.status,
|
||||
)
|
||||
|
||||
|
||||
from .function_changes import router as function_changes_router # noqa: E402
|
||||
|
||||
|
||||
router.include_router(function_changes_router)
|
||||
|
||||
@@ -65,6 +65,10 @@ class OrganizationFunctionAssignmentCreateRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
change_request_id: str | None = None
|
||||
governance_override_reason: str | None = Field(default=None, max_length=4_000)
|
||||
governance_override_evidence: list[str] = Field(
|
||||
default_factory=list, max_length=100
|
||||
)
|
||||
|
||||
|
||||
class OrganizationFunctionAssignmentUpdateRequest(BaseModel):
|
||||
@@ -80,6 +84,10 @@ class OrganizationFunctionAssignmentUpdateRequest(BaseModel):
|
||||
is_active: bool | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
change_request_id: str | None = None
|
||||
governance_override_reason: str | None = Field(default=None, max_length=4_000)
|
||||
governance_override_evidence: list[str] = Field(
|
||||
default_factory=list, max_length=100
|
||||
)
|
||||
|
||||
|
||||
class IdmSettingsItem(BaseModel):
|
||||
@@ -97,3 +105,120 @@ class IdmSettingsUpdateRequest(BaseModel):
|
||||
audit_detail_level: AuditDetailLevel | None = None
|
||||
change_retention_days: int | None = Field(default=None, ge=0)
|
||||
settings: dict[str, Any] | None = None
|
||||
|
||||
|
||||
FunctionAssignmentChangeKind = Literal["request", "grant"]
|
||||
FunctionAssignmentChangeAction = Literal[
|
||||
"approve",
|
||||
"reject",
|
||||
"accept",
|
||||
"request_changes",
|
||||
"respond",
|
||||
"withdraw",
|
||||
"recover",
|
||||
]
|
||||
|
||||
|
||||
class FunctionAssignmentChangeCreateRequest(BaseModel):
|
||||
kind: FunctionAssignmentChangeKind
|
||||
function_id: str = Field(min_length=1, max_length=36)
|
||||
candidate_identity_id: str = Field(min_length=1, max_length=36)
|
||||
candidate_account_id: str | None = Field(default=None, max_length=36)
|
||||
justification: str = Field(min_length=1, max_length=8_000)
|
||||
evidence: list[str] = Field(default_factory=list, max_length=100)
|
||||
requested_valid_from: datetime | None = None
|
||||
requested_valid_until: datetime | None = None
|
||||
applies_to_subunits: bool = False
|
||||
assignment_source: Literal["governance", "delegated"] = "governance"
|
||||
represented_assignment_id: str | None = Field(default=None, max_length=36)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FunctionAssignmentChangeActionRequest(BaseModel):
|
||||
action: FunctionAssignmentChangeAction
|
||||
base_revision: int = Field(ge=1)
|
||||
comment: str | None = Field(default=None, max_length=4_000)
|
||||
evidence: list[str] = Field(default_factory=list, max_length=100)
|
||||
|
||||
|
||||
class FunctionAssignmentChangeEventItem(BaseModel):
|
||||
id: str
|
||||
sequence: int
|
||||
action: str
|
||||
from_state: str | None = None
|
||||
to_state: str
|
||||
actor_account_id: str | None = None
|
||||
actor_identity_id: str | None = None
|
||||
actor_assignment_id: str | None = None
|
||||
comment: str | None = None
|
||||
evidence: list[str]
|
||||
policy_decision: dict[str, Any]
|
||||
workflow_step_id: str | None = None
|
||||
details: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentChangeItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
kind: FunctionAssignmentChangeKind
|
||||
state: str
|
||||
profile: str
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
candidate_identity_id: str
|
||||
candidate_account_id: str | None = None
|
||||
initiator_account_id: str
|
||||
initiator_identity_id: str | None = None
|
||||
represented_assignment_id: str | None = None
|
||||
justification: str
|
||||
evidence: list[str]
|
||||
requested_valid_from: datetime | None = None
|
||||
requested_valid_until: datetime | None = None
|
||||
applies_to_subunits: bool
|
||||
assignment_source: str
|
||||
required_steps: list[str]
|
||||
completed_steps: list[str]
|
||||
policy_decision: dict[str, Any]
|
||||
workflow_definition_id: str | None = None
|
||||
workflow_definition_revision_id: str | None = None
|
||||
workflow_definition_revision: int | None = None
|
||||
workflow_definition_hash: str | None = None
|
||||
workflow_instance_id: str | None = None
|
||||
workflow_current_step_id: str | None = None
|
||||
resulting_assignment_id: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
outcome_reason: str | None = None
|
||||
resource_revision: int
|
||||
etag: str
|
||||
metadata: dict[str, Any]
|
||||
events: list[FunctionAssignmentChangeEventItem] = Field(default_factory=list)
|
||||
available_actions: list[str] = Field(default_factory=list)
|
||||
availability_reason: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentChangeList(BaseModel):
|
||||
changes: list[FunctionAssignmentChangeItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
|
||||
|
||||
class FunctionAssignmentCapabilityItem(BaseModel):
|
||||
kind: FunctionAssignmentChangeKind
|
||||
function_id: str
|
||||
available: bool
|
||||
reason: str | None = None
|
||||
profile: str = "unavailable"
|
||||
required_steps: list[str] = Field(default_factory=list)
|
||||
requirements: list[str] = Field(default_factory=list)
|
||||
authority_function_id: str | None = None
|
||||
evidence_required: bool = False
|
||||
recipient_acceptance_required: bool = False
|
||||
maximum_validity_days: int | None = None
|
||||
workflow_available: bool = False
|
||||
policy_available: bool = False
|
||||
|
||||
Reference in New Issue
Block a user