Files
govoplan-dataflow/src/govoplan_dataflow/backend/governance.py
T
zemion c1111c605f
Module Package Release / publish-packages (push) Successful in 12s
feat(dataflow): govern reusable definition updates
2026-08-21 18:27:09 +02:00

320 lines
10 KiB
Python

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",
]