320 lines
10 KiB
Python
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",
|
|
]
|