feat(workflow-engine): add governed DSAR coverage
This commit is contained in:
@@ -1,5 +1,22 @@
|
||||
# GovOPlaN Workflow Engine
|
||||
|
||||
## Data-subject requests
|
||||
|
||||
Workflow Engine publishes `privacy.dsar.workflow_engine` for exact definition,
|
||||
revision, instance, step, event, trigger, delivery, and wait references and for
|
||||
structured instance authorization, work assignments, automation authority, and
|
||||
minimized operator attribution. It never exports graphs/BPMN, inputs, context,
|
||||
outputs, handoffs, events/configuration, authorization snapshots, errors,
|
||||
replay keys, external references, hashes, or credentials. Owning service, case,
|
||||
form, and other source modules locate and correct facts inside arbitrary
|
||||
runtime payloads.
|
||||
|
||||
Subject-linked automation can be disabled and revoked, while exact terminal
|
||||
delivery detail can be minimized idempotently without changing its replay key.
|
||||
Definitions, instances, live work, assignments, transition and decision
|
||||
events, waits, and institutional attribution require authorized review or
|
||||
retention.
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
@@ -0,0 +1,879 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceEvent,
|
||||
WorkflowInstanceStep,
|
||||
WorkflowTrigger,
|
||||
WorkflowTriggerDelivery,
|
||||
WorkflowWaitState,
|
||||
)
|
||||
|
||||
|
||||
WORKFLOW_ENGINE_DSAR_CAPABILITY = dsar_capability_name("workflow_engine")
|
||||
_MAX_RECORDS = 5_000
|
||||
_CONFLICT = object()
|
||||
_DIRECT_ALIASES = {
|
||||
"definition_id": ("workflow_engine.definition", "workflow.definition"),
|
||||
"revision_id": (
|
||||
"workflow_engine.definition_revision",
|
||||
"workflow_engine.revision",
|
||||
),
|
||||
"instance_id": ("workflow_engine.instance", "workflow.instance"),
|
||||
"step_id": ("workflow_engine.step", "workflow.step"),
|
||||
"event_id": ("workflow_engine.event", "workflow.event"),
|
||||
"trigger_id": ("workflow_engine.trigger", "workflow.trigger"),
|
||||
"delivery_id": (
|
||||
"workflow_engine.trigger_delivery",
|
||||
"workflow_engine.delivery",
|
||||
),
|
||||
"wait_id": ("workflow_engine.wait_state", "workflow_engine.wait"),
|
||||
}
|
||||
_RESOURCE_MODELS = {
|
||||
"workflow_definition": WorkflowDefinition,
|
||||
"workflow_definition_revision": WorkflowDefinitionRevision,
|
||||
"workflow_instance": WorkflowInstance,
|
||||
"workflow_instance_step": WorkflowInstanceStep,
|
||||
"workflow_instance_event": WorkflowInstanceEvent,
|
||||
"workflow_trigger": WorkflowTrigger,
|
||||
"workflow_trigger_delivery": WorkflowTriggerDelivery,
|
||||
"workflow_wait_state": WorkflowWaitState,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Selectors:
|
||||
account_id: str | None
|
||||
identity_id: str | None
|
||||
membership_id: str | None
|
||||
direct: dict[str, str]
|
||||
|
||||
@property
|
||||
def subject_ids(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
value
|
||||
for value in (self.account_id, self.identity_id, self.membership_id)
|
||||
if value
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Match:
|
||||
resource_type: str
|
||||
row: Any
|
||||
category: str
|
||||
|
||||
|
||||
class WorkflowEngineDsarProvider:
|
||||
provider_id = "workflow_engine"
|
||||
module_id = "workflow_engine"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _selectors(subject)
|
||||
if selectors is None or not (selectors.subject_ids or selectors.direct):
|
||||
return ()
|
||||
direct = _direct_matches(db, tenant_id=tenant_id, selectors=selectors)
|
||||
if direct is None:
|
||||
return ()
|
||||
if direct:
|
||||
if selectors.subject_ids and not all(
|
||||
_correlates(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
match=match,
|
||||
subject_ids=selectors.subject_ids,
|
||||
)
|
||||
for match in direct
|
||||
):
|
||||
return ()
|
||||
matches = direct
|
||||
else:
|
||||
matches = _canonical_matches(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
selectors=selectors,
|
||||
)
|
||||
|
||||
records: list[DsarRecordRef] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for match in matches:
|
||||
key = (match.resource_type, str(match.row.id))
|
||||
if key in seen:
|
||||
continue
|
||||
if len(records) >= _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
|
||||
)
|
||||
seen.add(key)
|
||||
records.append(_record(match))
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _selectors(subject) is None:
|
||||
raise ValueError("Workflow Engine DSAR subject selectors conflict.")
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
kind = _planned_kind(record)
|
||||
executable = kind in {"anonymize", "revoke"}
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=(
|
||||
f"workflow_engine:{kind}:{record.resource_type}:"
|
||||
f"{record.resource_id}"
|
||||
),
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind=kind,
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=(
|
||||
f"Minimize {record.title}"
|
||||
if kind == "anonymize"
|
||||
else f"{kind.replace('_', ' ').title()} {record.title}"
|
||||
),
|
||||
rationale=_rationale(record, kind=kind),
|
||||
executable=executable,
|
||||
irreversible=kind == "anonymize",
|
||||
metadata={"record_category": record.category},
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
db = _session(session)
|
||||
selectors = _selectors(subject)
|
||||
if selectors is None:
|
||||
raise ValueError("Workflow Engine DSAR subject selectors conflict.")
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if not action.executable:
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Review live work, third parties, decision evidence, "
|
||||
"retention, and downstream effects before changing state."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
continue
|
||||
model = _RESOURCE_MODELS[action.resource_type]
|
||||
row = (
|
||||
db.query(model)
|
||||
.filter(model.tenant_id == tenant_id, model.id == action.resource_id)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
status = "unchanged"
|
||||
summary = "Workflow row was already absent or minimized."
|
||||
else:
|
||||
match = _Match(action.resource_type, row, "execution")
|
||||
if not (
|
||||
_directly_targets(selectors, match)
|
||||
or _correlates(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
match=match,
|
||||
subject_ids=selectors.subject_ids,
|
||||
)
|
||||
or _already_revoked(action.resource_type, row)
|
||||
):
|
||||
raise ValueError(
|
||||
"Workflow Engine DSAR action is not corroborated by the subject."
|
||||
)
|
||||
status, summary = _execute_action(
|
||||
db,
|
||||
resource_type=action.resource_type,
|
||||
row=row,
|
||||
kind=action.kind,
|
||||
)
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status=status,
|
||||
summary=summary,
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _direct_matches(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
selectors: _Selectors,
|
||||
) -> list[_Match] | None:
|
||||
matches: list[_Match] = []
|
||||
for selector, raw_value in selectors.direct.items():
|
||||
value = _strip_prefix(raw_value)
|
||||
if selector == "definition_id":
|
||||
definition = _one(session, WorkflowDefinition, tenant_id, value)
|
||||
if definition is None:
|
||||
return None
|
||||
current = _definition_package(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
definition=definition,
|
||||
)
|
||||
elif selector == "instance_id":
|
||||
instance = _one(session, WorkflowInstance, tenant_id, value)
|
||||
if instance is None:
|
||||
return None
|
||||
current = _instance_package(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
instance=instance,
|
||||
)
|
||||
else:
|
||||
model, resource_type = {
|
||||
"revision_id": (
|
||||
WorkflowDefinitionRevision,
|
||||
"workflow_definition_revision",
|
||||
),
|
||||
"step_id": (WorkflowInstanceStep, "workflow_instance_step"),
|
||||
"event_id": (WorkflowInstanceEvent, "workflow_instance_event"),
|
||||
"trigger_id": (WorkflowTrigger, "workflow_trigger"),
|
||||
"delivery_id": (
|
||||
WorkflowTriggerDelivery,
|
||||
"workflow_trigger_delivery",
|
||||
),
|
||||
"wait_id": (WorkflowWaitState, "workflow_wait_state"),
|
||||
}[selector]
|
||||
row = _one(session, model, tenant_id, value)
|
||||
if row is None:
|
||||
return None
|
||||
current = [_Match(resource_type, row, _direct_category(resource_type, row))]
|
||||
matches.extend(current)
|
||||
if len(matches) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
def _definition_package(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
definition: WorkflowDefinition,
|
||||
) -> list[_Match]:
|
||||
matches = [_Match("workflow_definition", definition, "workflow_definition_package")]
|
||||
specs = (
|
||||
(WorkflowDefinitionRevision, "workflow_definition_revision"),
|
||||
(WorkflowInstance, "workflow_instance"),
|
||||
(WorkflowTrigger, "workflow_trigger"),
|
||||
(WorkflowTriggerDelivery, "workflow_trigger_delivery"),
|
||||
)
|
||||
instances: list[WorkflowInstance] = []
|
||||
for model, resource_type in specs:
|
||||
rows = (
|
||||
session.query(model)
|
||||
.filter(model.tenant_id == tenant_id, model.definition_id == definition.id)
|
||||
.order_by(model.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if model is WorkflowInstance:
|
||||
instances = rows
|
||||
matches.extend(
|
||||
_Match(resource_type, row, "workflow_definition_package") for row in rows
|
||||
)
|
||||
instance_ids = {row.id for row in instances}
|
||||
if instance_ids:
|
||||
for model, resource_type in (
|
||||
(WorkflowInstanceStep, "workflow_instance_step"),
|
||||
(WorkflowInstanceEvent, "workflow_instance_event"),
|
||||
(WorkflowWaitState, "workflow_wait_state"),
|
||||
):
|
||||
rows = (
|
||||
session.query(model)
|
||||
.filter(
|
||||
model.tenant_id == tenant_id,
|
||||
model.instance_id.in_(instance_ids),
|
||||
)
|
||||
.order_by(model.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
matches.extend(
|
||||
_Match(resource_type, row, "workflow_definition_package")
|
||||
for row in rows
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
def _instance_package(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
instance: WorkflowInstance,
|
||||
) -> list[_Match]:
|
||||
matches = [_Match("workflow_instance", instance, "workflow_instance_package")]
|
||||
specs = (
|
||||
(WorkflowInstanceStep, "workflow_instance_step"),
|
||||
(WorkflowInstanceEvent, "workflow_instance_event"),
|
||||
(WorkflowWaitState, "workflow_wait_state"),
|
||||
)
|
||||
for model, resource_type in specs:
|
||||
rows = (
|
||||
session.query(model)
|
||||
.filter(model.tenant_id == tenant_id, model.instance_id == instance.id)
|
||||
.order_by(model.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
matches.extend(
|
||||
_Match(resource_type, row, "workflow_instance_package") for row in rows
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
def _canonical_matches(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
selectors: _Selectors,
|
||||
) -> list[_Match]:
|
||||
subject_ids = selectors.subject_ids
|
||||
if not subject_ids:
|
||||
return []
|
||||
triggers = (
|
||||
session.query(WorkflowTrigger)
|
||||
.filter(
|
||||
WorkflowTrigger.tenant_id == tenant_id,
|
||||
or_(
|
||||
WorkflowTrigger.authorization_account_id.in_(subject_ids),
|
||||
WorkflowTrigger.authorization_membership_id.in_(subject_ids),
|
||||
),
|
||||
)
|
||||
.order_by(WorkflowTrigger.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
matches = [
|
||||
_Match("workflow_trigger", row, "workflow_automation_authority")
|
||||
for row in triggers
|
||||
]
|
||||
instances = (
|
||||
session.query(WorkflowInstance)
|
||||
.filter(WorkflowInstance.tenant_id == tenant_id)
|
||||
.order_by(WorkflowInstance.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if len(instances) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Workflow Engine DSAR instance scan limit exceeded; use an exact instance reference."
|
||||
)
|
||||
for row in instances:
|
||||
authorization = dict(row.authorization_ or {})
|
||||
if any(
|
||||
str(authorization.get(key) or "") == value
|
||||
for key, value in (
|
||||
("account_id", selectors.account_id),
|
||||
("identity_id", selectors.identity_id),
|
||||
("membership_id", selectors.membership_id),
|
||||
)
|
||||
if value
|
||||
):
|
||||
matches.append(
|
||||
_Match(
|
||||
"workflow_instance",
|
||||
row,
|
||||
"workflow_subject_authorization",
|
||||
)
|
||||
)
|
||||
steps = (
|
||||
session.query(WorkflowInstanceStep)
|
||||
.filter(
|
||||
WorkflowInstanceStep.tenant_id == tenant_id,
|
||||
WorkflowInstanceStep.work_assignment_id.in_(subject_ids),
|
||||
)
|
||||
.order_by(WorkflowInstanceStep.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
matches.extend(
|
||||
_Match("workflow_instance_step", row, "workflow_subject_work_assignment")
|
||||
for row in steps
|
||||
)
|
||||
if len(matches) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
|
||||
)
|
||||
specs = (
|
||||
(
|
||||
WorkflowDefinition,
|
||||
or_(
|
||||
WorkflowDefinition.created_by.in_(subject_ids),
|
||||
WorkflowDefinition.updated_by.in_(subject_ids),
|
||||
),
|
||||
"workflow_definition",
|
||||
),
|
||||
(
|
||||
WorkflowDefinitionRevision,
|
||||
WorkflowDefinitionRevision.created_by.in_(subject_ids),
|
||||
"workflow_definition_revision",
|
||||
),
|
||||
(
|
||||
WorkflowInstance,
|
||||
WorkflowInstance.created_by.in_(subject_ids),
|
||||
"workflow_instance",
|
||||
),
|
||||
(
|
||||
WorkflowInstanceStep,
|
||||
WorkflowInstanceStep.completed_by.in_(subject_ids),
|
||||
"workflow_instance_step",
|
||||
),
|
||||
(
|
||||
WorkflowInstanceEvent,
|
||||
WorkflowInstanceEvent.actor_id.in_(subject_ids),
|
||||
"workflow_instance_event",
|
||||
),
|
||||
(
|
||||
WorkflowTrigger,
|
||||
or_(
|
||||
WorkflowTrigger.created_by.in_(subject_ids),
|
||||
WorkflowTrigger.updated_by.in_(subject_ids),
|
||||
),
|
||||
"workflow_trigger",
|
||||
),
|
||||
)
|
||||
for model, condition, resource_type in specs:
|
||||
rows = (
|
||||
session.query(model)
|
||||
.filter(model.tenant_id == tenant_id, condition)
|
||||
.order_by(model.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
matches.extend(
|
||||
_Match(resource_type, row, "workflow_operator_attribution") for row in rows
|
||||
)
|
||||
if len(matches) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
def _one(session: Session, model: Any, tenant_id: str, row_id: str) -> Any | None:
|
||||
return (
|
||||
session.query(model)
|
||||
.filter(model.tenant_id == tenant_id, model.id == row_id)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def _direct_category(resource_type: str, row: Any) -> str:
|
||||
if resource_type == "workflow_trigger_delivery":
|
||||
return (
|
||||
"terminal_workflow_delivery"
|
||||
if row.status in {"succeeded", "failed", "skipped", "cancelled"}
|
||||
else "active_workflow_delivery"
|
||||
)
|
||||
return "workflow_governed_state"
|
||||
|
||||
|
||||
def _correlates(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
match: _Match,
|
||||
subject_ids: tuple[str, ...],
|
||||
) -> bool:
|
||||
row = match.row
|
||||
if any(
|
||||
str(getattr(row, field, "") or "") in subject_ids
|
||||
for field in (
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"completed_by",
|
||||
"actor_id",
|
||||
"work_assignment_id",
|
||||
"authorization_account_id",
|
||||
"authorization_membership_id",
|
||||
)
|
||||
):
|
||||
return True
|
||||
if match.resource_type == "workflow_instance":
|
||||
authorization = dict(row.authorization_ or {})
|
||||
if any(
|
||||
str(authorization.get(key) or "") in subject_ids
|
||||
for key in ("account_id", "identity_id", "membership_id")
|
||||
):
|
||||
return True
|
||||
instance_id = getattr(row, "instance_id", None)
|
||||
if instance_id:
|
||||
instance = session.get(WorkflowInstance, instance_id)
|
||||
if instance and instance.tenant_id == tenant_id:
|
||||
return _correlates(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
match=_Match("workflow_instance", instance, "related"),
|
||||
subject_ids=subject_ids,
|
||||
)
|
||||
trigger_id = getattr(row, "trigger_id", None)
|
||||
if trigger_id:
|
||||
trigger = session.get(WorkflowTrigger, trigger_id)
|
||||
if trigger and trigger.tenant_id == tenant_id:
|
||||
return _correlates(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
match=_Match("workflow_trigger", trigger, "related"),
|
||||
subject_ids=subject_ids,
|
||||
)
|
||||
definition_id = getattr(row, "definition_id", None)
|
||||
if definition_id:
|
||||
definition = session.get(WorkflowDefinition, definition_id)
|
||||
return bool(
|
||||
definition
|
||||
and definition.tenant_id == tenant_id
|
||||
and (
|
||||
definition.created_by in subject_ids
|
||||
or definition.updated_by in subject_ids
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _directly_targets(selectors: _Selectors, match: _Match) -> bool:
|
||||
selector = {
|
||||
"workflow_definition": "definition_id",
|
||||
"workflow_definition_revision": "revision_id",
|
||||
"workflow_instance": "instance_id",
|
||||
"workflow_instance_step": "step_id",
|
||||
"workflow_instance_event": "event_id",
|
||||
"workflow_trigger": "trigger_id",
|
||||
"workflow_trigger_delivery": "delivery_id",
|
||||
"workflow_wait_state": "wait_id",
|
||||
}[match.resource_type]
|
||||
return _strip_prefix(selectors.direct.get(selector, "")) == str(match.row.id)
|
||||
|
||||
|
||||
def _record(match: _Match) -> DsarRecordRef:
|
||||
row = match.row
|
||||
immutable = match.category == "workflow_operator_attribution"
|
||||
return DsarRecordRef(
|
||||
provider_id="workflow_engine",
|
||||
module_id="workflow_engine",
|
||||
resource_type=match.resource_type,
|
||||
resource_id=str(row.id),
|
||||
category=match.category,
|
||||
title=match.resource_type.removeprefix("workflow_").replace("_", " ").title(),
|
||||
data={
|
||||
key: value
|
||||
for key, value in _record_data(match.resource_type, row).items()
|
||||
if value is not None
|
||||
},
|
||||
observed_at=_observed_at(row),
|
||||
immutable_evidence=immutable,
|
||||
retention_reason=(
|
||||
"Institutional Workflow authorship, assignments, transitions, and "
|
||||
"decision events remain attributable for governance and audit."
|
||||
if immutable
|
||||
else None
|
||||
),
|
||||
source_path="/workflow",
|
||||
)
|
||||
|
||||
|
||||
def _record_data(resource_type: str, row: Any) -> dict[str, object]:
|
||||
if resource_type == "workflow_definition":
|
||||
return {
|
||||
"scope_type": row.scope_type,
|
||||
"definition_kind": row.definition_kind,
|
||||
"status": row.status,
|
||||
"current_revision": row.current_revision,
|
||||
"active_revision": row.active_revision,
|
||||
"allow_start": row.allow_start,
|
||||
"allow_reuse": row.allow_reuse,
|
||||
"allow_automation": row.allow_automation,
|
||||
"deleted_at": _iso(row.deleted_at),
|
||||
}
|
||||
if resource_type == "workflow_definition_revision":
|
||||
return {
|
||||
"revision": row.revision,
|
||||
"schema_version": row.schema_version,
|
||||
"execution_mode": row.execution_mode,
|
||||
"bpmn_runtime_kind": row.bpmn_runtime_kind,
|
||||
"bpmn_executable": row.bpmn_executable,
|
||||
"created_at": _iso(row.created_at),
|
||||
}
|
||||
if resource_type == "workflow_instance":
|
||||
return {
|
||||
"status": row.status,
|
||||
"start_origin": row.start_origin,
|
||||
"started_at": _iso(row.started_at),
|
||||
"finished_at": _iso(row.finished_at),
|
||||
"cancellation_requested_at": _iso(row.cancellation_requested_at),
|
||||
}
|
||||
if resource_type == "workflow_instance_step":
|
||||
return {
|
||||
"sequence": row.sequence,
|
||||
"node_type": row.node_type,
|
||||
"status": row.status,
|
||||
"attempt": row.attempt,
|
||||
"work_assignment_kind": row.work_assignment_kind,
|
||||
"work_due_at": _iso(row.work_due_at),
|
||||
"started_at": _iso(row.started_at),
|
||||
"finished_at": _iso(row.finished_at),
|
||||
}
|
||||
if resource_type == "workflow_instance_event":
|
||||
return {
|
||||
"sequence": row.sequence,
|
||||
"kind": row.kind,
|
||||
"created_at": _iso(row.created_at),
|
||||
}
|
||||
if resource_type == "workflow_trigger":
|
||||
return {
|
||||
"kind": row.kind,
|
||||
"status": row.status,
|
||||
"event_type": row.event_type,
|
||||
"authorization_subject_kind": row.authorization_subject_kind,
|
||||
"next_fire_at": _iso(row.next_fire_at),
|
||||
"last_fire_at": _iso(row.last_fire_at),
|
||||
}
|
||||
if resource_type == "workflow_trigger_delivery":
|
||||
return {
|
||||
"invocation_kind": row.invocation_kind,
|
||||
"status": row.status,
|
||||
"attempts": row.attempts,
|
||||
"scheduled_for": _iso(row.scheduled_for),
|
||||
"created_at": _iso(row.created_at),
|
||||
}
|
||||
return {
|
||||
"mode": row.mode,
|
||||
"status": row.status,
|
||||
"due_at": _iso(row.due_at),
|
||||
"event_type": row.event_type,
|
||||
"revision": row.revision,
|
||||
"created_at": _iso(row.created_at),
|
||||
"updated_at": _iso(row.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def _planned_kind(record: DsarRecordRef) -> str:
|
||||
if record.category == "terminal_workflow_delivery":
|
||||
return "anonymize"
|
||||
if record.category == "workflow_automation_authority":
|
||||
return "revoke"
|
||||
if record.category == "workflow_operator_attribution":
|
||||
return "retain"
|
||||
return "manual_review"
|
||||
|
||||
|
||||
def _rationale(record: DsarRecordRef, *, kind: str) -> str:
|
||||
if kind == "anonymize":
|
||||
return (
|
||||
"Clear terminal trigger event and error detail while preserving the "
|
||||
"replay identity and minimal delivery evidence."
|
||||
)
|
||||
if kind == "revoke":
|
||||
return (
|
||||
"Disable automation and remove subject-linked delegated authority "
|
||||
"without deleting historical workflow evidence."
|
||||
)
|
||||
if kind == "retain":
|
||||
return record.retention_reason or "Retain institutional attribution evidence."
|
||||
return (
|
||||
"A Workflow owner must review live work, assignments, third parties, "
|
||||
"decision evidence, source authority, and retention."
|
||||
)
|
||||
|
||||
|
||||
def _execute_action(
|
||||
session: Session,
|
||||
*,
|
||||
resource_type: str,
|
||||
row: Any,
|
||||
kind: str,
|
||||
) -> tuple[str, str]:
|
||||
if resource_type == "workflow_trigger_delivery" and kind == "anonymize":
|
||||
if row.status not in {"succeeded", "failed", "skipped", "cancelled"}:
|
||||
raise ValueError("Active Workflow deliveries require manual review.")
|
||||
changed = _replace_fields(row, {"event_": None, "error": None})
|
||||
elif resource_type == "workflow_trigger" and kind == "revoke":
|
||||
changed = _replace_fields(
|
||||
row,
|
||||
{
|
||||
"status": "disabled",
|
||||
"config_": {},
|
||||
"last_error": None,
|
||||
"authorization_account_id": None,
|
||||
"authorization_membership_id": None,
|
||||
"authorization_service_account_id": None,
|
||||
"authorization_ref": f"revoked:{row.id}",
|
||||
"grant_scopes": [],
|
||||
},
|
||||
)
|
||||
else:
|
||||
raise ValueError("Workflow Engine DSAR executable action is unsupported.")
|
||||
if changed:
|
||||
session.flush()
|
||||
return "executed", "Personal Workflow detail minimized."
|
||||
return "unchanged", "Personal Workflow detail was already minimized."
|
||||
|
||||
|
||||
def _replace_fields(row: Any, values: dict[str, object]) -> bool:
|
||||
changed = False
|
||||
for field, value in values.items():
|
||||
if getattr(row, field) != value:
|
||||
setattr(row, field, value)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _already_revoked(resource_type: str, row: Any) -> bool:
|
||||
return bool(
|
||||
resource_type == "workflow_trigger"
|
||||
and row.status == "disabled"
|
||||
and row.authorization_account_id is None
|
||||
and row.authorization_membership_id is None
|
||||
and row.authorization_service_account_id is None
|
||||
and row.authorization_ref == f"revoked:{row.id}"
|
||||
)
|
||||
|
||||
|
||||
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
|
||||
refs = subject.external_references
|
||||
canonical = (
|
||||
_coalesce(
|
||||
subject.account_id,
|
||||
refs.get("workflow_engine.account"),
|
||||
refs.get("access.account"),
|
||||
),
|
||||
_coalesce(
|
||||
subject.identity_id,
|
||||
refs.get("workflow_engine.identity"),
|
||||
refs.get("identity.id"),
|
||||
),
|
||||
_coalesce(
|
||||
subject.membership_id,
|
||||
refs.get("workflow_engine.membership"),
|
||||
refs.get("tenancy.membership"),
|
||||
),
|
||||
)
|
||||
if any(value is _CONFLICT for value in canonical):
|
||||
return None
|
||||
direct: dict[str, str] = {}
|
||||
for selector, aliases in _DIRECT_ALIASES.items():
|
||||
value = _coalesce(*(refs.get(alias) for alias in aliases))
|
||||
if value is _CONFLICT:
|
||||
return None
|
||||
if value:
|
||||
direct[selector] = str(value)
|
||||
return _Selectors(
|
||||
account_id=_optional(canonical[0]),
|
||||
identity_id=_optional(canonical[1]),
|
||||
membership_id=_optional(canonical[2]),
|
||||
direct=direct,
|
||||
)
|
||||
|
||||
|
||||
def _coalesce(*values: str | None) -> str | None | object:
|
||||
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||
if len(normalized) > 1:
|
||||
return _CONFLICT
|
||||
return next(iter(normalized), None)
|
||||
|
||||
|
||||
def _optional(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _strip_prefix(value: str) -> str:
|
||||
return value.partition(":")[2] if ":" in value else value
|
||||
|
||||
|
||||
def _observed_at(row: Any) -> datetime | None:
|
||||
for field in ("finished_at", "created_at", "updated_at", "started_at"):
|
||||
value = getattr(row, field, None)
|
||||
if isinstance(value, datetime):
|
||||
return _aware(value)
|
||||
return None
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
aware = _aware(value)
|
||||
return aware.isoformat() if aware else None
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Workflow Engine DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "workflow_engine" or record.module_id != "workflow_engine":
|
||||
raise ValueError("Workflow Engine DSAR cannot plan a foreign provider record.")
|
||||
if record.resource_type not in _RESOURCE_MODELS or not record.resource_id:
|
||||
raise ValueError("Workflow Engine DSAR record identity is invalid.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "workflow_engine" or action.module_id != "workflow_engine":
|
||||
raise ValueError(
|
||||
"Workflow Engine DSAR cannot execute a foreign provider action."
|
||||
)
|
||||
if action.resource_type not in _RESOURCE_MODELS or not action.action_id.startswith(
|
||||
"workflow_engine:"
|
||||
):
|
||||
raise ValueError("Workflow Engine DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["WORKFLOW_ENGINE_DSAR_CAPABILITY", "WorkflowEngineDsarProvider"]
|
||||
@@ -43,6 +43,10 @@ from govoplan_core.core.workflows import (
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow_engine.backend.db import models as workflow_models
|
||||
from govoplan_workflow_engine.backend.dsar_provider import (
|
||||
WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||
WorkflowEngineDsarProvider,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.configuration_provider import (
|
||||
WORKFLOW_CONFIGURATION_CAPABILITY,
|
||||
)
|
||||
@@ -189,6 +193,11 @@ def _service_launcher(context: ModuleContext) -> WorkflowServiceLauncher:
|
||||
return WorkflowServiceLauncher(registry=context.registry)
|
||||
|
||||
|
||||
def _dsar_provider(context: ModuleContext) -> WorkflowEngineDsarProvider:
|
||||
del context
|
||||
return WorkflowEngineDsarProvider()
|
||||
|
||||
|
||||
def _work_items(context: ModuleContext):
|
||||
from govoplan_workflow_engine.backend.work_items import WorkflowWorkItemProvider
|
||||
|
||||
@@ -254,6 +263,10 @@ manifest = ModuleManifest(
|
||||
name="workflow.bpmn_execution_adapters",
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name=WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||
version="0.1.0",
|
||||
),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -305,6 +318,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_WORKFLOW_ORCHESTRATION: _orchestration_provider,
|
||||
WORKFLOW_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: _service_launcher,
|
||||
WORKFLOW_ENGINE_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: CapabilityDocumentation(
|
||||
@@ -312,6 +326,13 @@ manifest = ModuleManifest(
|
||||
summary="Starts an authorized active Workflow from an exact available Service revision.",
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
WORKFLOW_ENGINE_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Workflow Engine data-subject request provider",
|
||||
summary="Finds subject-linked workflow state while preserving live work and decision evidence.",
|
||||
contract_version="0.1.0",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("privacy_officer", "workflow_operator", "user"),
|
||||
),
|
||||
},
|
||||
work_item_providers=(
|
||||
WorkItemProviderRegistration(
|
||||
@@ -355,6 +376,27 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="workflow.data-subject-requests",
|
||||
title="Workflow data-subject requests",
|
||||
summary="Review structured subject links without exposing or guessing arbitrary process payload content.",
|
||||
body=(
|
||||
"Workflow Engine matches exact tenant-scoped definition, revision, instance, step, event, trigger, delivery, and wait identifiers. It also matches structured instance authorization, work assignment, automation authority, and minimized staff attribution for account, identity, and membership selectors. DSAR results never copy graphs or BPMN, inputs, context, outputs, handoffs, event/configuration payloads, authorization snapshots, errors, replay keys, external references, hashes, or credentials. Arbitrary runtime payloads are not scanned for identifiers because the owning service, case, form, or other source module remains authoritative. "
|
||||
"Subject-linked automation can be disabled and revoked, and explicitly selected terminal trigger delivery detail can be minimized idempotently while its replay key remains. Definitions, instances, live work, assignments, immutable transition/decision events, waits, and operator attribution require workflow-owner, retention, third-party, and source-authority review."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("core", "cases", "forms_runtime", "services", "tasks"),
|
||||
order=75,
|
||||
metadata={
|
||||
"help_contexts": [
|
||||
"workflow.data-subject-requests",
|
||||
"workflow.instances",
|
||||
"workflow.triggers",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="workflow.definition-graphs",
|
||||
title="Workflow definition graphs",
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarProvider,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
create_data_subject_request,
|
||||
search_data_subject_request,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceEvent,
|
||||
WorkflowInstanceStep,
|
||||
WorkflowTrigger,
|
||||
WorkflowTriggerDelivery,
|
||||
WorkflowWaitState,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.dsar_provider import (
|
||||
WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||
WorkflowEngineDsarProvider,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.manifest import manifest
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 21, 21, 0, tzinfo=UTC)
|
||||
SECRET = "private-workflow-detail-do-not-export"
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(
|
||||
self,
|
||||
provider: WorkflowEngineDsarProvider,
|
||||
*,
|
||||
active: bool = True,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.active = active
|
||||
|
||||
def capability_names(self):
|
||||
return (WORKFLOW_ENGINE_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
self._assert_capability(name)
|
||||
return "workflow_engine"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
active = self.active
|
||||
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type(
|
||||
"State",
|
||||
(),
|
||||
{"effective_modules": ("workflow_engine",) if active else ()},
|
||||
)()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
self._assert_capability(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "workflow_engine"})(),)
|
||||
|
||||
@staticmethod
|
||||
def _assert_capability(name: str) -> None:
|
||||
if name != WORKFLOW_ENGINE_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
class WorkflowEngineDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = WorkflowEngineDsarProvider()
|
||||
self.assertIsInstance(self.provider, DsarProvider)
|
||||
self._seed()
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def _seed(self) -> None:
|
||||
definition = WorkflowDefinition(
|
||||
id="definition-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
scope_key="tenant:tenant-1",
|
||||
definition_kind="flow",
|
||||
definition_key="resident-permit",
|
||||
name=SECRET,
|
||||
description=SECRET,
|
||||
status="active",
|
||||
current_revision=1,
|
||||
active_revision=1,
|
||||
metadata_={"secret": SECRET},
|
||||
derivation_provenance={"secret": SECRET},
|
||||
created_by="account-1",
|
||||
updated_by="account-1",
|
||||
)
|
||||
other = WorkflowDefinition(
|
||||
id="definition-other",
|
||||
tenant_id="tenant-2",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-2",
|
||||
scope_key="tenant:tenant-2",
|
||||
definition_kind="flow",
|
||||
definition_key="other",
|
||||
name=SECRET,
|
||||
status="active",
|
||||
current_revision=1,
|
||||
created_by="account-1",
|
||||
updated_by="account-1",
|
||||
)
|
||||
self.session.add_all((definition, other))
|
||||
self.session.flush()
|
||||
revision = WorkflowDefinitionRevision(
|
||||
id="revision-1",
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
revision=1,
|
||||
schema_version=1,
|
||||
graph={"secret": SECRET},
|
||||
content_hash="a" * 64,
|
||||
library_id="bpmn",
|
||||
library_version="1.0.0",
|
||||
execution_mode="hybrid",
|
||||
bpmn_xml=SECRET,
|
||||
bpmn_hash="b" * 64,
|
||||
bpmn_runtime_kind="native_graph",
|
||||
bpmn_executable=True,
|
||||
contribution_metadata={"secret": SECRET},
|
||||
created_by="account-1",
|
||||
)
|
||||
self.session.add(revision)
|
||||
self.session.flush()
|
||||
instance = WorkflowInstance(
|
||||
id="instance-1",
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
definition_revision_id=revision.id,
|
||||
status="completed",
|
||||
start_origin="service",
|
||||
idempotency_key=SECRET,
|
||||
correlation_id=SECRET,
|
||||
input_={"secret": SECRET},
|
||||
context_={"secret": SECRET},
|
||||
output_={"secret": SECRET},
|
||||
authorization_={
|
||||
"account_id": "account-1",
|
||||
"identity_id": "identity-1",
|
||||
"membership_id": "membership-1",
|
||||
"secret": SECRET,
|
||||
},
|
||||
started_at=NOW,
|
||||
finished_at=NOW,
|
||||
error=SECRET,
|
||||
created_by="account-1",
|
||||
)
|
||||
self.session.add(instance)
|
||||
self.session.flush()
|
||||
step = WorkflowInstanceStep(
|
||||
id="step-1",
|
||||
tenant_id="tenant-1",
|
||||
instance_id=instance.id,
|
||||
sequence=1,
|
||||
node_id="review",
|
||||
node_type="human_task",
|
||||
status="completed",
|
||||
attempt=1,
|
||||
idempotency_key=SECRET,
|
||||
input_={"secret": SECRET},
|
||||
output_={"secret": SECRET},
|
||||
handoff={"secret": SECRET},
|
||||
external_ref=SECRET,
|
||||
started_at=NOW,
|
||||
finished_at=NOW,
|
||||
error=SECRET,
|
||||
completed_by="account-1",
|
||||
work_assignment_kind="membership",
|
||||
work_assignment_id="membership-1",
|
||||
work_assignment_label=SECRET,
|
||||
work_due_at=NOW,
|
||||
)
|
||||
trigger = WorkflowTrigger(
|
||||
id="trigger-1",
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
definition_revision_id=revision.id,
|
||||
node_id="start-event",
|
||||
kind="event",
|
||||
status="active",
|
||||
config_={"secret": SECRET},
|
||||
event_type="permit.submitted",
|
||||
next_fire_at=NOW + timedelta(hours=1),
|
||||
last_error=SECRET,
|
||||
authorization_subject_kind="delegated_user",
|
||||
authorization_account_id="account-1",
|
||||
authorization_membership_id="membership-1",
|
||||
authorization_ref=SECRET,
|
||||
grant_scopes=[SECRET],
|
||||
created_by="account-1",
|
||||
updated_by="account-1",
|
||||
)
|
||||
self.session.add_all((step, trigger))
|
||||
self.session.flush()
|
||||
self.session.add_all(
|
||||
(
|
||||
WorkflowInstanceEvent(
|
||||
id="event-1",
|
||||
tenant_id="tenant-1",
|
||||
instance_id=instance.id,
|
||||
step_id=step.id,
|
||||
sequence=1,
|
||||
kind="step.completed",
|
||||
actor_id="account-1",
|
||||
payload={"secret": SECRET},
|
||||
created_at=NOW,
|
||||
),
|
||||
WorkflowTriggerDelivery(
|
||||
id="delivery-1",
|
||||
tenant_id="tenant-1",
|
||||
trigger_id=trigger.id,
|
||||
definition_id=definition.id,
|
||||
definition_revision_id=revision.id,
|
||||
source_key=SECRET,
|
||||
invocation_kind="event",
|
||||
status="succeeded",
|
||||
scheduled_for=NOW,
|
||||
event_={"secret": SECRET},
|
||||
instance_id=instance.id,
|
||||
attempts=1,
|
||||
error=SECRET,
|
||||
),
|
||||
WorkflowWaitState(
|
||||
id="wait-1",
|
||||
tenant_id="tenant-1",
|
||||
instance_id=instance.id,
|
||||
step_id=step.id,
|
||||
mode="event",
|
||||
status="completed",
|
||||
due_at=NOW,
|
||||
event_type="permit.approved",
|
||||
config_={"secret": SECRET},
|
||||
source_event_id=SECRET,
|
||||
event_={"secret": SECRET},
|
||||
error=SECRET,
|
||||
revision=1,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def test_canonical_selector_uses_structured_links_and_minimized_output(
|
||||
self,
|
||||
) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
membership_id="membership-1",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(6, len(records))
|
||||
categories = {record.resource_type: record.category for record in records}
|
||||
self.assertEqual(
|
||||
"workflow_automation_authority",
|
||||
categories["workflow_trigger"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"workflow_subject_authorization",
|
||||
categories["workflow_instance"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"workflow_subject_work_assignment",
|
||||
categories["workflow_instance_step"],
|
||||
)
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
self.assertNotIn(SECRET, exported)
|
||||
self.assertNotIn("account-1", exported)
|
||||
self.assertNotIn("identity-1", exported)
|
||||
self.assertNotIn("membership-1", exported)
|
||||
self.assertNotIn("definition-other", exported)
|
||||
|
||||
def test_exact_instance_and_definition_packages_are_review_only(self) -> None:
|
||||
instance_records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"workflow.instance": "instance:instance-1"}
|
||||
),
|
||||
)
|
||||
definition_records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"workflow.definition": "definition-1"}
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(4, len(instance_records))
|
||||
self.assertEqual(8, len(definition_records))
|
||||
self.assertNotIn(
|
||||
SECRET,
|
||||
json.dumps([record.to_dict() for record in definition_records]),
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"workflow.definition": "definition-1"}
|
||||
),
|
||||
records=definition_records,
|
||||
)
|
||||
self.assertEqual({"manual_review"}, {action.kind for action in actions})
|
||||
|
||||
def test_every_exact_reference_and_conflicts_fail_closed(self) -> None:
|
||||
references = {
|
||||
"workflow_engine.definition_revision": "revision-1",
|
||||
"workflow_engine.step": "step-1",
|
||||
"workflow_engine.event": "event-1",
|
||||
"workflow_engine.trigger": "trigger-1",
|
||||
"workflow_engine.trigger_delivery": "delivery-1",
|
||||
"workflow_engine.wait_state": "wait-1",
|
||||
}
|
||||
for key, value in references.items():
|
||||
with self.subTest(key=key):
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(external_references={key: value}),
|
||||
)
|
||||
self.assertEqual(1, len(records))
|
||||
|
||||
mismatch = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-2",
|
||||
external_references={"workflow.instance": "instance-1"},
|
||||
),
|
||||
)
|
||||
wrong_tenant = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-2",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"workflow.instance": "instance-1"}
|
||||
),
|
||||
)
|
||||
conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={
|
||||
"workflow_engine.revision": "revision-1",
|
||||
"workflow_engine.definition_revision": "different",
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual((), mismatch)
|
||||
self.assertEqual((), wrong_tenant)
|
||||
self.assertEqual((), conflict)
|
||||
|
||||
def test_terminal_delivery_minimization_preserves_replay_identity(self) -> None:
|
||||
subject = DsarSubjectRef(
|
||||
external_references={"workflow_engine.delivery": "delivery-1"}
|
||||
)
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=records,
|
||||
)
|
||||
self.assertEqual("anonymize", actions[0].kind)
|
||||
first = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=actions,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
second = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=actions,
|
||||
request_id="dsar-1-retry",
|
||||
)
|
||||
self.assertEqual("executed", first[0].status)
|
||||
self.assertEqual("unchanged", second[0].status)
|
||||
delivery = self.session.get(WorkflowTriggerDelivery, "delivery-1")
|
||||
self.assertIsNone(delivery.event_)
|
||||
self.assertIsNone(delivery.error)
|
||||
self.assertEqual(SECRET, delivery.source_key)
|
||||
|
||||
def test_automation_authority_revocation_is_idempotent(self) -> None:
|
||||
subject = DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
)
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=records,
|
||||
)
|
||||
trigger_action = next(
|
||||
action for action in actions if action.resource_type == "workflow_trigger"
|
||||
)
|
||||
self.assertEqual("revoke", trigger_action.kind)
|
||||
first = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=(trigger_action,),
|
||||
request_id="dsar-2",
|
||||
)
|
||||
second = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=(trigger_action,),
|
||||
request_id="dsar-2-retry",
|
||||
)
|
||||
self.assertEqual("executed", first[0].status)
|
||||
self.assertEqual("unchanged", second[0].status)
|
||||
trigger = self.session.get(WorkflowTrigger, "trigger-1")
|
||||
self.assertEqual("disabled", trigger.status)
|
||||
self.assertIsNone(trigger.authorization_account_id)
|
||||
self.assertIsNone(trigger.authorization_membership_id)
|
||||
self.assertEqual([], trigger.grant_scopes)
|
||||
self.assertEqual({}, trigger.config_)
|
||||
|
||||
def test_foreign_records_and_actions_are_rejected(self) -> None:
|
||||
subject = DsarSubjectRef(account_id="account-1")
|
||||
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||
self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=(
|
||||
DsarRecordRef(
|
||||
provider_id="cases",
|
||||
module_id="cases",
|
||||
resource_type="case",
|
||||
resource_id="case-1",
|
||||
category="case",
|
||||
title="Case",
|
||||
),
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||
self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=(
|
||||
DsarErasureActionRef(
|
||||
action_id="cases:delete:case:case-1",
|
||||
provider_id="cases",
|
||||
module_id="cases",
|
||||
kind="delete",
|
||||
resource_type="case",
|
||||
resource_id="case-1",
|
||||
title="Delete case",
|
||||
rationale="Foreign",
|
||||
executable=True,
|
||||
),
|
||||
),
|
||||
request_id="dsar-3",
|
||||
)
|
||||
|
||||
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
|
||||
row = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-WORKFLOW-1",
|
||||
request_kind="access_and_erasure",
|
||||
subject=DsarSubjectRef(account_id="account-1"),
|
||||
purpose="Respond to a verified request.",
|
||||
legal_basis="Article 15 and 17 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
self.session.commit()
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=row,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(
|
||||
[WORKFLOW_ENGINE_DSAR_CAPABILITY],
|
||||
row.coverage["provider_capabilities"],
|
||||
)
|
||||
self.assertEqual(6, row.search_result["record_count"])
|
||||
|
||||
inactive = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-WORKFLOW-2",
|
||||
request_kind="access",
|
||||
subject=DsarSubjectRef(account_id="account-1"),
|
||||
purpose="Respond to a verified request.",
|
||||
legal_basis="Article 15 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
self.session.commit()
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider, active=False),
|
||||
row=inactive,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||
self.assertEqual(
|
||||
[WORKFLOW_ENGINE_DSAR_CAPABILITY],
|
||||
inactive.coverage["inactive_provider_capabilities"],
|
||||
)
|
||||
self.assertEqual(0, inactive.search_result["record_count"])
|
||||
|
||||
def test_manifest_registers_and_documents_capability(self) -> None:
|
||||
self.assertIn(
|
||||
WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||
manifest.capability_factories,
|
||||
)
|
||||
self.assertIn(
|
||||
WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||
manifest.capability_documentation,
|
||||
)
|
||||
self.assertIn(
|
||||
WORKFLOW_ENGINE_DSAR_CAPABILITY,
|
||||
{item.name for item in manifest.provides_interfaces},
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
topic.id == "workflow.data-subject-requests"
|
||||
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||
for topic in manifest.documentation
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user