Implement governed function assignment workflows

This commit is contained in:
2026-07-31 19:40:27 +02:00
parent f025b0c25b
commit c14719d55a
16 changed files with 4079 additions and 27 deletions
@@ -0,0 +1,551 @@
from __future__ import annotations
from math import ceil
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
from govoplan_core.core.concurrency import (
ConcurrencyError,
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
)
from govoplan_core.core.identity import (
CAPABILITY_IDENTITY_DIRECTORY,
IdentityDirectory,
)
from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY,
OrganizationDirectory,
OrganizationFunctionRef,
)
from govoplan_core.core.policy import (
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.core.workflows import CAPABILITY_WORKFLOW_ORCHESTRATION
from govoplan_core.db.session import get_session
from govoplan_idm.backend.db.models import IdmFunctionAssignmentChange
from govoplan_idm.backend.function_assignment_changes import (
FunctionAssignmentChangeConflict,
FunctionAssignmentChangeUnavailable,
available_change_actions,
change_events,
create_function_assignment_change,
resolve_submission_capability,
transition_function_assignment_change,
visible_change_filter,
)
from .schemas import (
FunctionAssignmentCapabilityItem,
FunctionAssignmentChangeActionRequest,
FunctionAssignmentChangeCreateRequest,
FunctionAssignmentChangeEventItem,
FunctionAssignmentChangeItem,
FunctionAssignmentChangeKind,
FunctionAssignmentChangeList,
)
router = APIRouter(prefix="/function-assignment-changes")
READ_SCOPES = (
"idm:function_change:read",
"idm:function_request:create",
"idm:function_grant:create",
"idm:function_change:decide",
"idm:function_change:admin",
"idm:organization_assignment:write",
)
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
if not any(principal.has(scope) for scope in scopes):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of: {', '.join(scopes)}",
)
def _registry():
registry = get_registry()
if registry is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The module registry is unavailable.",
)
return registry
def _organization_directory() -> OrganizationDirectory:
registry = _registry()
capability = registry.capability(CAPABILITY_ORGANIZATION_DIRECTORY)
if not isinstance(capability, OrganizationDirectory):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The Organizations directory is unavailable.",
)
return capability
def _identity_directory() -> IdentityDirectory:
registry = _registry()
capability = registry.capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The Identity directory is unavailable.",
)
return capability
def _function(function_id: str, tenant_id: str) -> OrganizationFunctionRef:
function = _organization_directory().get_function(function_id)
if function is None or function.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Organization function not found.",
)
return function
def _historical_function(
function_id: str,
tenant_id: str,
) -> OrganizationFunctionRef | None:
try:
function = _organization_directory().get_function(function_id)
except HTTPException:
return None
if function is None or function.tenant_id != tenant_id:
return None
return function
def _validate_candidate(identity_id: str, account_id: str | None) -> None:
directory = _identity_directory()
if directory.get_identity(identity_id) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Identity not found.",
)
if account_id is not None and not any(
link.account_id == account_id
for link in directory.accounts_for_identity(identity_id)
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Account is not linked to the selected identity.",
)
def _change_query(
session: Session,
principal: ApiPrincipal,
):
statement = select(IdmFunctionAssignmentChange).where(
IdmFunctionAssignmentChange.tenant_id == principal.tenant_id
)
visibility = visible_change_filter(session, principal)
return statement.where(visibility) if visibility is not None else statement
def _get_change(
session: Session,
principal: ApiPrincipal,
change_id: str,
) -> IdmFunctionAssignmentChange:
change = session.scalar(
_change_query(session, principal).where(
IdmFunctionAssignmentChange.id == change_id
)
)
if change is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Function assignment change not found.",
)
return change
def _event_item(event) -> FunctionAssignmentChangeEventItem:
return FunctionAssignmentChangeEventItem(
id=event.id,
sequence=event.sequence,
action=event.action,
from_state=event.from_state,
to_state=event.to_state,
actor_account_id=event.actor_account_id,
actor_identity_id=event.actor_identity_id,
actor_assignment_id=event.actor_assignment_id,
comment=event.comment,
evidence=list(event.evidence),
policy_decision=dict(event.policy_decision),
workflow_step_id=event.workflow_step_id,
details=dict(event.details),
created_at=event.created_at,
)
def _change_item(
session: Session,
principal: ApiPrincipal,
change: IdmFunctionAssignmentChange,
*,
include_events: bool,
) -> FunctionAssignmentChangeItem:
function = _historical_function(change.function_id, change.tenant_id)
if function is None:
actions, reason = [], "The referenced organization function is no longer available."
else:
try:
actions, reason = available_change_actions(
session,
principal=principal,
registry=get_registry(),
change=change,
function=function,
)
except FunctionAssignmentChangeUnavailable as exc:
actions, reason = [], str(exc)
return FunctionAssignmentChangeItem(
id=change.id,
tenant_id=change.tenant_id,
kind=change.kind,
state=change.state,
profile=change.profile,
function_id=change.function_id,
organization_unit_id=change.organization_unit_id,
candidate_identity_id=change.candidate_identity_id,
candidate_account_id=change.candidate_account_id,
initiator_account_id=change.initiator_account_id,
initiator_identity_id=change.initiator_identity_id,
represented_assignment_id=change.represented_assignment_id,
justification=change.justification,
evidence=list(change.evidence),
requested_valid_from=change.requested_valid_from,
requested_valid_until=change.requested_valid_until,
applies_to_subunits=change.applies_to_subunits,
assignment_source=change.assignment_source,
required_steps=list(change.required_steps),
completed_steps=list(change.completed_steps),
policy_decision=dict(change.policy_decision),
workflow_definition_id=change.workflow_definition_id,
workflow_definition_revision_id=change.workflow_definition_revision_id,
workflow_definition_revision=change.workflow_definition_revision,
workflow_definition_hash=change.workflow_definition_hash,
workflow_instance_id=change.workflow_instance_id,
workflow_current_step_id=change.workflow_current_step_id,
resulting_assignment_id=change.resulting_assignment_id,
expires_at=change.expires_at,
outcome_reason=change.outcome_reason,
resource_revision=change.resource_revision,
etag=change.strong_etag,
metadata=dict(change.metadata_),
events=(
[
_event_item(event)
for event in change_events(session, change_id=change.id)
]
if include_events
else []
),
available_actions=actions,
availability_reason=reason,
created_at=change.created_at,
updated_at=change.updated_at,
)
def _set_etag(response: Response, change: IdmFunctionAssignmentChange) -> None:
response.headers["ETag"] = change.strong_etag
def _record_change_audit(
session: Session,
principal: ApiPrincipal,
*,
change: IdmFunctionAssignmentChange,
action: str,
from_state: str | None,
) -> None:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_AUDIT_RECORDER):
return
audit_from_principal(
session,
principal,
action=f"idm.function_assignment_change.{action}",
object_type="function_assignment_change",
object_id=change.id,
details={
"kind": change.kind,
"from_state": from_state,
"to_state": change.state,
"function_id": change.function_id,
"candidate_identity_id": change.candidate_identity_id,
"represented_assignment_id": change.represented_assignment_id,
"policy_decision": dict(change.policy_decision),
"workflow_definition_id": change.workflow_definition_id,
"workflow_definition_revision_id": change.workflow_definition_revision_id,
"workflow_instance_id": change.workflow_instance_id,
"evidence": list(change.evidence),
"resulting_assignment_id": change.resulting_assignment_id,
"resource_revision": change.resource_revision,
},
commit=False,
)
def _mutation_error(exc: Exception) -> HTTPException:
if isinstance(exc, MissingPreconditionError):
return HTTPException(
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
detail=exc.as_dict(),
)
if isinstance(exc, RevisionConflictError):
return HTTPException(
status_code=status.HTTP_412_PRECONDITION_FAILED,
detail=exc.as_dict(),
)
if isinstance(exc, ConcurrencyError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "concurrency_conflict", "message": str(exc)},
)
if isinstance(exc, FunctionAssignmentChangeConflict):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
@router.get("/capability", response_model=FunctionAssignmentCapabilityItem)
def get_function_assignment_capability(
kind: FunctionAssignmentChangeKind,
function_id: str = Query(min_length=1, max_length=36),
candidate_identity_id: str | None = Query(default=None, max_length=36),
candidate_account_id: str | None = Query(default=None, max_length=36),
has_evidence: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FunctionAssignmentCapabilityItem:
_require_any(principal, *READ_SCOPES)
candidate_identity_id = candidate_identity_id or principal.identity_id
if candidate_identity_id is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="A candidate identity is required.",
)
function = _function(function_id, principal.tenant_id)
registry = _registry()
decision, reason = resolve_submission_capability(
session,
principal=principal,
registry=registry,
kind=kind,
function=function,
candidate_identity_id=candidate_identity_id,
candidate_account_id=candidate_account_id,
has_evidence=has_evidence,
)
return FunctionAssignmentCapabilityItem(
kind=kind,
function_id=function_id,
available=bool(decision and decision.allowed),
reason=reason,
profile=decision.profile if decision else "unavailable",
required_steps=list(decision.required_steps) if decision else [],
requirements=list(decision.requirements) if decision else [],
authority_function_id=(decision.authority_function_id if decision else None),
evidence_required=bool(decision and decision.evidence_required),
recipient_acceptance_required=bool(
decision and decision.recipient_acceptance_required
),
maximum_validity_days=(decision.maximum_validity_days if decision else None),
workflow_available=registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION),
policy_available=registry.has_capability(
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE
),
)
@router.get("", response_model=FunctionAssignmentChangeList)
def list_function_assignment_changes(
kind: FunctionAssignmentChangeKind | None = None,
state_filter: str | None = Query(default=None, alias="state", max_length=40),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FunctionAssignmentChangeList:
_require_any(principal, *READ_SCOPES)
statement = _change_query(session, principal)
if kind is not None:
statement = statement.where(IdmFunctionAssignmentChange.kind == kind)
if state_filter:
statement = statement.where(IdmFunctionAssignmentChange.state == state_filter)
total = int(
session.scalar(select(func.count()).select_from(statement.subquery())) or 0
)
rows = list(
session.scalars(
statement.order_by(
IdmFunctionAssignmentChange.updated_at.desc(),
IdmFunctionAssignmentChange.id.desc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
)
)
return FunctionAssignmentChangeList(
changes=[
_change_item(session, principal, change, include_events=False)
for change in rows
],
total=total,
page=page,
page_size=page_size,
pages=max(1, ceil(total / page_size)),
)
@router.get("/{change_id}", response_model=FunctionAssignmentChangeItem)
def get_function_assignment_change(
change_id: str,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FunctionAssignmentChangeItem:
_require_any(principal, *READ_SCOPES)
change = _get_change(session, principal, change_id)
_set_etag(response, change)
return _change_item(session, principal, change, include_events=True)
@router.post("", response_model=FunctionAssignmentChangeItem, status_code=201)
def create_governed_function_assignment_change(
payload: FunctionAssignmentChangeCreateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FunctionAssignmentChangeItem:
required = (
"idm:function_request:create"
if payload.kind == "request"
else "idm:function_grant:create"
)
_require_any(
principal,
required,
"idm:function_change:admin",
"idm:organization_assignment:write",
)
_validate_candidate(payload.candidate_identity_id, payload.candidate_account_id)
try:
change, replayed = create_function_assignment_change(
session,
principal=principal,
registry=_registry(),
function=_function(payload.function_id, principal.tenant_id),
payload=payload,
)
if not replayed:
_record_change_audit(
session,
principal,
change=change,
action="created",
from_state=None,
)
session.commit()
except (
FunctionAssignmentChangeConflict,
FunctionAssignmentChangeUnavailable,
) as exc:
session.rollback()
raise _mutation_error(exc) from exc
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The function assignment change conflicts with existing data.",
) from exc
session.refresh(change)
response.status_code = status.HTTP_200_OK if replayed else status.HTTP_201_CREATED
_set_etag(response, change)
return _change_item(session, principal, change, include_events=True)
@router.post("/{change_id}/actions", response_model=FunctionAssignmentChangeItem)
def act_on_function_assignment_change(
change_id: str,
payload: FunctionAssignmentChangeActionRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FunctionAssignmentChangeItem:
change = _get_change(session, principal, change_id)
from_state = change.state
if payload.action == "recover":
_require_any(principal, "idm:function_change:admin")
elif payload.action not in {"withdraw", "respond"}:
_require_any(
principal,
"idm:function_change:decide",
"idm:function_change:admin",
"idm:organization_assignment:write",
)
try:
assert_revision_precondition(
if_match,
resource_type="idm_function_assignment_change",
resource_id=change.id,
submitted_base_revision=payload.base_revision,
)
change = transition_function_assignment_change(
session,
principal=principal,
registry=_registry(),
change=change,
function=_function(change.function_id, change.tenant_id),
action=payload.action,
base_revision=payload.base_revision,
comment=payload.comment,
evidence=payload.evidence,
)
_record_change_audit(
session,
principal,
change=change,
action=payload.action,
from_state=from_state,
)
session.commit()
except (
ConcurrencyError,
FunctionAssignmentChangeConflict,
FunctionAssignmentChangeUnavailable,
) as exc:
session.rollback()
raise _mutation_error(exc) from exc
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The function assignment transition conflicts with existing data.",
) from exc
session.refresh(change)
_set_etag(response, change)
return _change_item(session, principal, change, include_events=True)
__all__ = ["router"]
+90 -6
View File
@@ -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)
+125
View File
@@ -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"]
+189 -2
View File
@@ -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
+93 -8
View File
@@ -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",
@@ -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"]