from __future__ import annotations from collections.abc import Mapping from govoplan_core.core.policy import ( FunctionAssignmentEscalationRule, FunctionAssignmentGovernanceDecision, FunctionAssignmentGovernanceRequest, PolicySourceStep, ) from govoplan_core.security.module_permissions import scopes_grant_compatible SUPPORTED_PROFILES = { "holder_grant", "holder_with_authority_clearance", "authority_only", "unavailable", } class FunctionAssignmentGovernancePolicyProvider: def resolve_function_assignment_action( self, session: object | None = None, *, request: FunctionAssignmentGovernanceRequest, ) -> FunctionAssignmentGovernanceDecision: del session policy = _policy(request.function_settings) profile = ( str(policy.get(f"{request.kind}_profile") or "unavailable") .strip() .casefold() ) if profile not in SUPPORTED_PROFILES: return _decision( request, policy, profile="unavailable", allowed=False, reason=f"Unsupported function assignment profile: {profile}.", requirements=("valid_profile",), ) required_steps = _required_steps( request.kind, profile, recipient_acceptance=_bool( policy.get("recipient_acceptance_required"), default=request.kind == "grant", ), ) authority_function_id = _text(policy.get("authority_function_id")) delegation_allowed = _bool( policy.get("delegation_allowed"), default=False, ) maximum_delegation_depth = ( _bounded_int( policy.get("maximum_delegation_depth"), default=1, minimum=1, maximum=20, ) if delegation_allowed else 0 ) maximum_delegated_validity_days = _optional_positive_int( policy.get("maximum_delegated_validity_days"), maximum=3650, ) escalation_rules, escalation_requirements = _escalation_rules(policy) requirements: list[str] = [] if "authority" in required_steps and authority_function_id is None: requirements.append("authority_function") evidence_required = _bool( policy.get("evidence_required"), default=False, ) if evidence_required and not request.context.get("has_evidence"): requirements.append("evidence") requirements.extend(escalation_requirements) allowed, reason = _action_decision( request, profile=profile, required_steps=required_steps, ) if profile == "unavailable": allowed = False reason = "Function assignment requests and grants are disabled." if requirements and request.action == "submit": allowed = False reason = _requirements_reason(requirements) return _decision( request, policy, profile=profile, allowed=allowed, reason=reason, required_steps=required_steps, authority_function_id=authority_function_id, evidence_required=evidence_required, delegation_allowed=delegation_allowed, maximum_delegation_depth=maximum_delegation_depth, maximum_delegated_validity_days=maximum_delegated_validity_days, escalation_rules=escalation_rules, requirements=tuple(requirements), ) def _action_decision( request: FunctionAssignmentGovernanceRequest, *, profile: str, required_steps: tuple[str, ...], ) -> tuple[bool, str | None]: context = request.context action = request.action if action == "submit": if request.kind == "request": if not bool(context.get("candidate_is_actor")): return False, "A function request must target the requesting identity." if profile == "authority_only": allowed = bool(context.get("actor_is_authority")) return ( allowed, None if allowed else "Only the designated authority may initiate this assignment.", ) return True, None if profile == "authority_only": allowed = bool(context.get("actor_is_authority")) reason = _route_reason( context, "authority", "Only the designated authority may initiate this grant.", ) else: allowed = bool(context.get("actor_is_holder")) reason = _route_reason( context, "holder", "An effective function holder must initiate this grant.", ) return allowed, None if allowed else reason if action == "approve_holder": allowed = "holder" in required_steps and bool(context.get("actor_is_holder")) return allowed, None if allowed else _route_reason( context, "holder", "A current holder must approve.", ) if action == "approve_authority": allowed = "authority" in required_steps and bool( context.get("actor_is_authority") ) return allowed, None if allowed else _route_reason( context, "authority", "The designated authority must approve.", ) if action == "approve_escalation": allowed = request.current_state == "escalated" and bool( context.get("actor_is_escalation_target") ) return allowed, None if allowed else _route_reason( context, "escalation", "A current holder of the explicit escalation target must approve.", ) if action == "accept_recipient": allowed = "recipient" in required_steps and bool( context.get("candidate_is_actor") ) return allowed, None if allowed else "The candidate must accept this grant." if action in {"reject", "request_changes"}: if request.current_state == "awaiting_holder": allowed = bool(context.get("actor_is_holder")) reason = "A current holder must act at the holder review step." elif request.current_state == "awaiting_authority": allowed = bool(context.get("actor_is_authority")) reason = "The designated authority must act at the authority step." elif request.current_state == "awaiting_recipient": allowed = bool(context.get("candidate_is_actor")) reason = "Only the candidate may act at recipient acceptance." elif request.current_state == "escalated": allowed = bool(context.get("actor_is_escalation_target")) reason = _route_reason( context, "escalation", "Only a current holder of the explicit escalation target may act.", ) else: allowed = False reason = "The current state does not accept this review action." return allowed, None if allowed else reason if action == "respond": allowed = request.current_state == "changes_requested" and ( bool(context.get("actor_is_initiator")) or bool(context.get("candidate_is_actor")) ) return ( allowed, None if allowed else "Only the initiator or candidate may respond to requested changes.", ) if action == "withdraw": allowed = bool(context.get("actor_is_initiator")) return ( allowed, None if allowed else "Only the initiator may withdraw this change.", ) if action == "recover": allowed = _has_scope(request, "idm:function_change:admin") return ( allowed, None if allowed else "Administrative recovery permission is required.", ) if action == "apply": allowed = bool(context.get("approvals_complete")) return allowed, None if allowed else "Required decisions are incomplete." return False, f"Unsupported function assignment action: {action}." def _required_steps( kind: str, profile: str, *, recipient_acceptance: bool, ) -> tuple[str, ...]: if profile == "holder_grant": steps = ["holder"] elif profile == "holder_with_authority_clearance": steps = ["holder", "authority"] elif profile == "authority_only": steps = ["authority"] else: steps = [] if kind == "grant" and recipient_acceptance: steps.append("recipient") return tuple(steps) def _decision( request: FunctionAssignmentGovernanceRequest, policy: Mapping[str, object], *, profile: str, allowed: bool, reason: str | None, required_steps: tuple[str, ...] = (), authority_function_id: str | None = None, evidence_required: bool = False, delegation_allowed: bool = False, maximum_delegation_depth: int = 0, maximum_delegated_validity_days: int | None = None, escalation_rules: tuple[FunctionAssignmentEscalationRule, ...] = (), requirements: tuple[str, ...] = (), ) -> FunctionAssignmentGovernanceDecision: recipient_required = "recipient" in required_steps return FunctionAssignmentGovernanceDecision( allowed=allowed, reason=reason, profile=profile, required_steps=required_steps, authority_function_id=authority_function_id, evidence_required=evidence_required, recipient_acceptance_required=recipient_required, separation_of_duties=_bool( policy.get("separation_of_duties"), default=True, ), quorum=_bounded_int(policy.get("quorum"), default=1, minimum=1, maximum=20), maximum_validity_days=_optional_positive_int( policy.get("maximum_validity_days"), maximum=3650, ), delegation_allowed=delegation_allowed, maximum_delegation_depth=maximum_delegation_depth, maximum_delegated_validity_days=maximum_delegated_validity_days, escalation_rules=escalation_rules, request_expiry_hours=_bounded_int( policy.get("request_expiry_hours"), default=336, minimum=1, maximum=8760, ), source_path=( PolicySourceStep( scope_type="tenant", scope_id=request.tenant_id, label="Organization function assignment policy", applied_fields=tuple(sorted(policy)), policy=policy, ), ), requirements=requirements, details={ "function_id": request.function_id, "kind": request.kind, "action": request.action, "actor_routes": dict(request.context.get("actor_routes") or {}), }, ) def _policy(settings: Mapping[str, object]) -> dict[str, object]: raw = settings.get("assignment_governance") return dict(raw) if isinstance(raw, Mapping) else {} def _requirements_reason(requirements: list[str]) -> str: labels = { "authority_function": "a designated authority function", "evidence": "the required evidence", "valid_profile": "a supported governance profile", "escalation_holder": "a valid holder-step escalation rule", "escalation_authority": "a valid authority-step escalation rule", "escalation_recipient": "a valid recipient-step escalation rule", } return ( "Submission requires " + ", ".join(labels.get(item, item.replace("_", " ")) for item in requirements) + "." ) def _escalation_rules( policy: Mapping[str, object], ) -> tuple[tuple[FunctionAssignmentEscalationRule, ...], list[str]]: raw = policy.get("escalation") if raw is None: return (), [] if not isinstance(raw, Mapping): return (), ["escalation_holder"] rules: list[FunctionAssignmentEscalationRule] = [] requirements: list[str] = [] for step in ("holder", "authority", "recipient"): value = raw.get(step) if value is None: continue if not isinstance(value, Mapping): requirements.append(f"escalation_{step}") continue target_function_id = _text(value.get("target_function_id")) timeout_hours = _optional_positive_int( value.get("timeout_hours"), maximum=8760, ) if target_function_id is None or timeout_hours is None: requirements.append(f"escalation_{step}") continue rules.append( FunctionAssignmentEscalationRule( step=step, # type: ignore[arg-type] target_function_id=target_function_id, timeout_hours=timeout_hours, ) ) return tuple(rules), requirements def _route_reason( context: Mapping[str, object], route: str, fallback: str, ) -> str: routes = context.get("actor_routes") if not isinstance(routes, Mapping): return fallback value = routes.get(route) if not isinstance(value, Mapping): return fallback return _text(value.get("reason")) or fallback def _has_scope( request: FunctionAssignmentGovernanceRequest, scope: str, ) -> bool: return scopes_grant_compatible(request.actor.scopes, scope) def _text(value: object) -> str | None: text = str(value).strip() if value is not None else "" return text or None def _bool(value: object, *, default: bool) -> bool: return value if isinstance(value, bool) else default def _bounded_int( value: object, *, default: int, minimum: int, maximum: int, ) -> int: try: number = int(value) if value is not None else default except (TypeError, ValueError): return default return max(minimum, min(number, maximum)) def _optional_positive_int(value: object, *, maximum: int) -> int | None: if value is None: return None try: number = int(value) except (TypeError, ValueError): return None return max(1, min(number, maximum)) __all__ = [ "FunctionAssignmentGovernancePolicyProvider", "SUPPORTED_PROFILES", ]