from __future__ import annotations from collections.abc import Mapping from typing import Literal from govoplan_core.auth import ApiPrincipal, has_scope from govoplan_core.core.policy import ( DefinitionGovernanceAction, DefinitionGovernanceRequest, DefinitionScopeRef, PolicyDecision, PolicySourceStep, definition_governance_policy, ) from govoplan_dataflow.backend.db.models import DataflowPipeline DefinitionAction = Literal[ "view", "edit", "run", "reuse", "derive", "automate", ] GOVERNANCE_ACTIONS: tuple[DefinitionAction, ...] = ( "view", "edit", "run", "reuse", "derive", "automate", ) def normalize_definition_scope( principal: ApiPrincipal, *, scope_type: str, scope_id: str | None, administrative: bool, ) -> tuple[str | None, str, str | None]: clean_type = scope_type.strip().casefold() clean_id = str(scope_id or "").strip() or None if clean_type == "system": if not has_scope(principal, "system:governance:write"): raise PermissionError( "System definitions require system governance permission." ) if clean_id is not None: raise ValueError("System definitions do not carry a scope ID.") return None, "system", None if clean_type == "tenant": if clean_id not in {None, principal.tenant_id}: raise PermissionError( "Definitions can only be created for the active tenant." ) return principal.tenant_id, "tenant", principal.tenant_id if clean_type == "group": if not clean_id: raise ValueError("Group definitions require a group ID.") if clean_id not in principal.group_ids and not administrative: raise PermissionError( "Definitions can only be created for one of the actor's groups." ) return principal.tenant_id, "group", clean_id if clean_type == "user": if not clean_id: clean_id = principal.account_id if ( clean_id not in {principal.membership_id, principal.account_id} and not administrative ): raise PermissionError( "Definitions can only be created for the current user." ) if clean_id == principal.membership_id: clean_id = principal.account_id return principal.tenant_id, "user", clean_id raise ValueError( "Definition scope must be system, tenant, group, or user." ) def definition_decision( pipeline: DataflowPipeline, *, principal: ApiPrincipal, registry: object | None, action: DefinitionGovernanceAction, ) -> PolicyDecision: source_scope = DefinitionScopeRef( scope_type=pipeline.scope_type, # type: ignore[arg-type] scope_id=pipeline.scope_id, ) request = DefinitionGovernanceRequest( module_id="dataflow", definition_ref=f"pipeline:{pipeline.id}", tenant_id=principal.tenant_id, definition_scope=source_scope, target_scope=_target_scope(pipeline, principal), definition_kind=pipeline.definition_kind, # type: ignore[arg-type] action=action, actor=principal.to_platform_principal(), status=pipeline.status, inherit_to_lower_scopes=pipeline.inherit_to_lower_scopes, allow_run=pipeline.allow_run, allow_reuse=pipeline.allow_reuse, allow_automation=pipeline.allow_automation, context=_ancestor_context(pipeline), ) provider = definition_governance_policy(registry) if provider is not None: return provider.resolve_definition_action(request=request) return _tenant_local_fallback(request) def definition_governance_payload( pipeline: DataflowPipeline, *, principal: ApiPrincipal, registry: object | None, source_update: Mapping[str, object] | None = None, ) -> dict[str, object]: actions = { action: definition_decision( pipeline, principal=principal, registry=registry, action=action, ).to_dict() for action in GOVERNANCE_ACTIONS } return { "scope_type": pipeline.scope_type, "scope_id": pipeline.scope_id, "definition_kind": pipeline.definition_kind, "inherit_to_lower_scopes": pipeline.inherit_to_lower_scopes, "allow_run": pipeline.allow_run, "allow_reuse": pipeline.allow_reuse, "allow_automation": pipeline.allow_automation, "derived_from_pipeline_id": pipeline.derived_from_pipeline_id, "derived_from_revision": pipeline.derived_from_revision, "derived_from_hash": pipeline.derived_from_hash, "source_available": bool( source_update and source_update.get("source_available") is True ), "source_name": ( source_update.get("source_name") if source_update else None ), "source_current_revision": ( source_update.get("source_current_revision") if source_update else None ), "source_current_hash": ( source_update.get("source_current_hash") if source_update else None ), "update_available": bool( source_update and source_update.get("update_available") is True ), "derivation_provenance": dict(pipeline.derivation_provenance), "actions": actions, } def require_definition_action( pipeline: DataflowPipeline, *, principal: ApiPrincipal, registry: object | None, action: DefinitionGovernanceAction, ) -> PolicyDecision: decision = definition_decision( pipeline, principal=principal, registry=registry, action=action, ) if not decision.allowed: raise PermissionError( decision.reason or f"Definition action is not allowed: {action}" ) return decision def _target_scope( pipeline: DataflowPipeline, principal: ApiPrincipal, ) -> DefinitionScopeRef: if ( pipeline.scope_type == "group" and pipeline.scope_id in principal.group_ids ): return DefinitionScopeRef("group", pipeline.scope_id) if pipeline.scope_type == "user" and pipeline.scope_id in { principal.membership_id, principal.account_id, }: return DefinitionScopeRef("user", pipeline.scope_id) return DefinitionScopeRef("tenant", principal.tenant_id) def _ancestor_context(pipeline: DataflowPipeline) -> dict[str, object]: provenance = pipeline.derivation_provenance limits = provenance.get("source_effective_limits") source = provenance.get("source_scope") context: dict[str, object] = {} if isinstance(limits, Mapping): context["ancestor_limits"] = dict(limits) if isinstance(source, Mapping): context["ancestor_source"] = dict(source) return context def _tenant_local_fallback( request: DefinitionGovernanceRequest, ) -> PolicyDecision: ancestor = request.context.get("ancestor_limits") ancestor_limits = ancestor if isinstance(ancestor, Mapping) else {} effective_limits = { "inherit_to_lower_scopes": ( request.inherit_to_lower_scopes and _fallback_ancestor_flag( ancestor_limits, "inherit_to_lower_scopes", ) ), "allow_run": request.allow_run and _fallback_ancestor_flag(ancestor_limits, "allow_run"), "allow_reuse": request.allow_reuse and _fallback_ancestor_flag(ancestor_limits, "allow_reuse"), "allow_automation": request.allow_automation and _fallback_ancestor_flag( ancestor_limits, "allow_automation", ), } local = ( request.definition_scope.scope_type == "tenant" and request.definition_scope.scope_id == request.tenant_id and request.actor.tenant_id == request.tenant_id ) allowed = False reason: str | None = None if not local: reason = ( "Inherited definitions require the Policy module; only local " "tenant definitions are available." ) elif request.action in {"view", "edit"}: allowed = True elif request.action == "run": allowed = ( request.definition_kind == "flow" and request.status == "active" and effective_limits["allow_run"] ) reason = ( None if allowed else "Only active local flows with execution enabled can run." ) else: reason = ( "Definition reuse and automation require the Policy module." ) return PolicyDecision( allowed=allowed, reason=reason, source_path=( PolicySourceStep( scope_type=request.definition_scope.scope_type, scope_id=request.definition_scope.scope_id, label="Tenant-local conservative fallback", applied_fields=( "definition_kind", "status", "allow_run", ), policy={ "policy_module_available": False, "definition_kind": request.definition_kind, "status": request.status, "allow_run": request.allow_run, }, ), ), requirements=( () if allowed else (f"dataflow.definition.{request.action}",) ), details={ "fallback": "tenant_local", "action": request.action, "effective_limits": effective_limits, }, ) def _fallback_ancestor_flag( limits: Mapping[str, object], key: str, ) -> bool: value = limits.get(key) return True if value is None else value is True __all__ = [ "GOVERNANCE_ACTIONS", "definition_decision", "definition_governance_payload", "normalize_definition_scope", "require_definition_action", ]