Add governed reusable pipelines and triggers
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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.membership_id or 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."
|
||||
)
|
||||
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,
|
||||
) -> 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,
|
||||
"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",
|
||||
]
|
||||
Reference in New Issue
Block a user