feat(poll): add governed DSAR coverage

This commit is contained in:
2026-08-21 12:15:56 +02:00
parent 1891996f13
commit 02ec5423b9
4 changed files with 853 additions and 2 deletions
+506
View File
@@ -0,0 +1,506 @@
from __future__ import annotations
import json
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollParticipationSubmission,
PollResponse,
)
POLL_DSAR_CAPABILITY = dsar_capability_name("poll")
_MAX_RECORDS = 5_000
_MAX_ANSWERS = 1_000
_MAX_ANSWER_BYTES = 256 * 1024
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
respondent_ids: tuple[str, ...]
actor_ids: tuple[str, ...]
email: str | None
poll_id: str | None
invitation_id: str | None
response_id: str | None
class PollDsarProvider:
provider_id = "poll"
module_id = "poll"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
invitation_conditions = []
if selectors.respondent_ids:
invitation_conditions.append(
PollInvitation.respondent_id.in_(selectors.respondent_ids)
)
if selectors.email:
invitation_conditions.append(
func.lower(PollInvitation.email) == selectors.email
)
invitations = db.query(PollInvitation).filter(
PollInvitation.tenant_id == tenant_id,
or_(*invitation_conditions),
)
if selectors.poll_id:
invitations = invitations.filter(PollInvitation.poll_id == selectors.poll_id)
if selectors.invitation_id:
invitations = invitations.filter(PollInvitation.id == selectors.invitation_id)
invitation_rows = _limited(
invitations,
PollInvitation.created_at,
PollInvitation.id,
label="invitation",
)
records.extend(_invitation_record(row) for row in invitation_rows)
linked_response_ids: tuple[str, ...] = ()
if invitation_rows:
invitation_ids = [row.id for row in invitation_rows]
linked_rows = (
db.query(PollParticipationSubmission.response_id)
.filter(
PollParticipationSubmission.tenant_id == tenant_id,
PollParticipationSubmission.invitation_id.in_(invitation_ids),
)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(linked_rows) > _MAX_RECORDS:
raise ValueError(
"Poll DSAR participation-link limit exceeded; narrow selectors."
)
linked_response_ids = tuple(
dict.fromkeys(str(response_id) for (response_id,) in linked_rows)
)
response_conditions = []
if selectors.invitation_id:
if linked_response_ids:
response_conditions.append(PollResponse.id.in_(linked_response_ids))
else:
if selectors.respondent_ids:
response_conditions.append(
PollResponse.respondent_id.in_(selectors.respondent_ids)
)
if linked_response_ids:
response_conditions.append(PollResponse.id.in_(linked_response_ids))
if response_conditions:
responses = db.query(PollResponse).filter(
PollResponse.tenant_id == tenant_id,
or_(*response_conditions),
)
if selectors.poll_id:
responses = responses.filter(PollResponse.poll_id == selectors.poll_id)
if selectors.response_id:
responses = responses.filter(PollResponse.id == selectors.response_id)
records.extend(
_response_record(row)
for row in _limited(
responses,
PollResponse.submitted_at,
PollResponse.id,
label="response",
)
)
if selectors.actor_ids:
polls = db.query(Poll).filter(
Poll.tenant_id == tenant_id,
Poll.created_by_user_id.in_(selectors.actor_ids),
)
transitions = db.query(PollLifecycleTransition).filter(
PollLifecycleTransition.tenant_id == tenant_id,
PollLifecycleTransition.actor_user_id.in_(selectors.actor_ids),
)
if selectors.poll_id:
polls = polls.filter(Poll.id == selectors.poll_id)
transitions = transitions.filter(
PollLifecycleTransition.poll_id == selectors.poll_id
)
records.extend(
_creator_attribution(row)
for row in _limited(
polls,
Poll.created_at,
Poll.id,
label="creator attribution",
)
)
records.extend(
_transition_attribution(row)
for row in _limited(
transitions,
PollLifecycleTransition.created_at,
PollLifecycleTransition.id,
label="lifecycle attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Poll DSAR combined result limit exceeded; narrow the selectors."
)
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Poll DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
participation = record.resource_type in {
"poll_response",
"poll_invitation",
}
actions.append(
DsarErasureActionRef(
action_id=(
f"poll:{'manual_review' if participation else 'retain'}:"
f"{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review" if participation else "retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=("Review " if participation else "Retain ") + record.title,
rationale=(
"Removing or anonymizing participation may change published "
"results, response-update behavior, or retained invitation "
"evidence and therefore requires the Poll owner and retention "
"authority to review the effect."
if participation
else record.retention_reason
or "Poll actor attribution remains governance evidence."
),
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Poll DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind not in {"manual_review", "retain"}:
raise ValueError("Poll DSAR publishes non-executable actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Poll participation remains unchanged pending result and "
"retention-impact review."
if action.kind == "manual_review"
else "Poll lifecycle attribution remains governance evidence."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("poll.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("poll.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("poll.identity"),
references.get("identity.id"),
),
"respondent_id": _coalesce(
references.get("poll.respondent"),
references.get("poll.respondent_id"),
),
"email": _coalesce_email(subject.email, references.get("poll.email")),
"poll_id": _coalesce(
references.get("poll.poll"), references.get("poll.poll_id")
),
"invitation_id": _coalesce(
references.get("poll.invitation"),
references.get("poll.invitation_id"),
),
"response_id": _coalesce(
references.get("poll.response"),
references.get("poll.response_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
base_ids = tuple(
dict.fromkeys(
value
for value in (
_optional_string(values["account_id"]),
_prefixed("account", values["account_id"]),
_optional_string(values["membership_id"]),
_prefixed("membership", values["membership_id"]),
_optional_string(values["identity_id"]),
_prefixed("identity", values["identity_id"]),
)
if value
)
)
direct_respondent = _optional_string(values["respondent_id"])
if direct_respondent and base_ids and direct_respondent not in base_ids:
return None
respondent_ids = base_ids or ((direct_respondent,) if direct_respondent else ())
email = _optional_string(values["email"])
if not respondent_ids and not email:
return None
return _SubjectSelectors(
respondent_ids=respondent_ids,
actor_ids=base_ids,
email=email,
poll_id=_optional_string(values["poll_id"]),
invitation_id=_optional_string(values["invitation_id"]),
response_id=_optional_string(values["response_id"]),
)
def _response_record(row: PollResponse) -> DsarRecordRef:
answers = _answers(row.answers)
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_response",
resource_id=row.id,
category="identified_poll_participation",
title=f"Poll response: {row.poll.title[:500]}",
data={
"poll_id": row.poll_id,
"poll_title": row.poll.title[:500],
"poll_kind": row.poll.kind,
"respondent_id": row.respondent_id,
"respondent_label": (row.respondent_label or "")[:500] or None,
"answers": answers,
"submitted_at": _iso(row.submitted_at),
"retired_at": _iso(row.deleted_at),
},
observed_at=_aware(row.updated_at),
retention_reason=(
"Response erasure or anonymization requires Poll result and retention review."
),
)
def _invitation_record(row: PollInvitation) -> DsarRecordRef:
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_invitation",
resource_id=row.id,
category="poll_invitation_and_contact",
title=f"Poll invitation: {row.poll.title[:500]}",
data={
"poll_id": row.poll_id,
"poll_title": row.poll.title[:500],
"respondent_id": row.respondent_id,
"respondent_label": (row.respondent_label or "")[:500] or None,
"email": row.email,
"expires_at": _iso(row.expires_at),
"revoked_at": _iso(row.revoked_at),
"last_used_at": _iso(row.last_used_at),
"created_at": _iso(row.created_at),
},
observed_at=_aware(row.updated_at),
retention_reason=(
"Invitation erasure requires participation and response-link review."
),
)
def _creator_attribution(row: Poll) -> DsarRecordRef:
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_creator_attribution",
resource_id=row.id,
category="poll_governance_attribution",
title="Poll creator attribution",
data={
"poll_id": row.id,
"kind": row.kind,
"status": row.status,
"visibility": row.visibility,
"opens_at": _iso(row.opens_at),
"closes_at": _iso(row.closes_at),
"created_at": _iso(row.created_at),
"activity": "created_poll",
},
observed_at=_aware(row.created_at),
immutable_evidence=True,
retention_reason="Poll creator attribution is governance evidence.",
)
def _transition_attribution(row: PollLifecycleTransition) -> DsarRecordRef:
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_lifecycle_actor_attribution",
resource_id=row.id,
category="poll_governance_attribution",
title="Poll lifecycle actor attribution",
data={
"poll_id": row.poll_id,
"action": row.action,
"from_status": row.from_status,
"to_status": row.to_status,
"created_at": _iso(row.created_at),
},
observed_at=_aware(row.created_at),
immutable_evidence=True,
retention_reason="Poll lifecycle attribution is governance evidence.",
)
def _answers(value: object) -> list[object]:
if not isinstance(value, list) or len(value) > _MAX_ANSWERS:
raise ValueError("Poll response answers exceed the DSAR bound.")
try:
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
except (TypeError, ValueError) as exc:
raise ValueError("Poll response answers are not JSON serializable.") from exc
if len(encoded) > _MAX_ANSWER_BYTES:
raise ValueError("Poll response answer payload exceeds the DSAR byte bound.")
return json.loads(encoded.decode("utf-8"))
def _limited(query, first, second, *, label: str):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Poll DSAR {label} limit exceeded; narrow selectors.")
return rows
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 _coalesce_email(*values: str | None) -> str | None | object:
normalized = {
str(value).strip().casefold()
for value in values
if str(value or "").strip()
}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _prefixed(prefix: str, value: object) -> str | None:
normalized = _optional_string(value)
return f"{prefix}:{normalized}" if normalized else 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("Poll DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"poll_response",
"poll_invitation",
"poll_creator_attribution",
"poll_lifecycle_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "poll" or record.module_id != "poll":
raise ValueError("Poll DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Poll DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "poll" or action.module_id != "poll":
raise ValueError("Poll DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("poll:"):
raise ValueError("Poll DSAR action identity is invalid.")
__all__ = ["POLL_DSAR_CAPABILITY", "PollDsarProvider"]
+62 -1
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationTopic,
MigrationSpec,
ModuleContext,
@@ -18,6 +19,7 @@ from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.poll_participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
from govoplan_core.db.base import Base
from govoplan_poll.backend.db import models as poll_models # noqa: F401 - populate Poll ORM metadata
from govoplan_poll.backend.dsar_provider import POLL_DSAR_CAPABILITY, PollDsarProvider
MODULE_ID = "poll"
MODULE_NAME = "Poll"
@@ -144,6 +146,11 @@ def _poll_participation_gateway_provider(context: ModuleContext) -> object:
return _poll_scheduling_provider(context)
def _dsar_provider(context: ModuleContext) -> PollDsarProvider:
del context
return PollDsarProvider()
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
@@ -159,6 +166,7 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="poll.workflow_context", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.signed_participation", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.governed_participation", version=MODULE_VERSION),
ModuleInterfaceProvider(name=POLL_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -168,6 +176,17 @@ manifest = ModuleManifest(
capability_factories={
CAPABILITY_POLL_SCHEDULING: _poll_scheduling_provider,
CAPABILITY_POLL_PARTICIPATION_GATEWAY: _poll_participation_gateway_provider,
POLL_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
POLL_DSAR_CAPABILITY: CapabilityDocumentation(
label="Poll data-subject request provider",
summary=(
"Exports identified responses, invitation contact data, and minimized "
"operator attribution without token or gateway secrets."
),
contract_version="0.1.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -196,7 +215,49 @@ manifest = ModuleManifest(
label="Poll",
),
),
documentation=DOCUMENTATION,
documentation=(
DocumentationTopic(
id="poll.data-subject-requests",
title="Poll data-subject requests",
summary=(
"Export identified responses and invitations while preserving result "
"integrity and the boundary around anonymous participation."
),
body=(
"Poll correlates exact respondent identifiers and normalized email "
"addresses inside the active tenant. A matching invitation can resolve "
"its explicitly linked participation submission and response without "
"exposing the signed token. Subject-owned responses include bounded "
"answers, respondent labels, Poll context, and retirement state. "
"Invitations include contact and lifecycle state but never token hashes, "
"gateway configuration, participation policy, metadata, fingerprints, "
"or idempotency values. Creator and lifecycle activity is exported only "
"as minimized attribution. Truly anonymous responses have no stable "
"subject selector and cannot be correlated. Participation erasure "
"requires manual result and retention review; no automatic action "
"silently changes a Poll outcome."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "participant", "organizer", "auditor"),
related_modules=("core", "scheduling", "notifications", "mail"),
metadata={
"help_contexts": ["privacy.data-subject-requests"],
"consequence_classes": {
"export_identified_response": (
"Returns bounded subject-owned answers and Poll context."
),
"anonymous_limitation": (
"Cannot correlate a response that deliberately has no subject identifier."
),
"review_participation_erasure": (
"Requires result-integrity and retention review."
),
},
},
),
*DOCUMENTATION,
),
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",