feat(poll): enforce governed participation ownership

This commit is contained in:
2026-07-21 21:17:04 +02:00
parent f839545605
commit 156486fcee
13 changed files with 3515 additions and 53 deletions
@@ -0,0 +1,730 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping
from datetime import datetime
from typing import Any
from pydantic import ValidationError
from sqlalchemy.orm import Session
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollParticipationSubmission,
PollResponse,
)
from govoplan_poll.backend.participation import (
ANONYMOUS_PASSWORD_REQUIREMENT,
PollGovernedResponseCommand,
PollParticipationPolicy,
PollResponseGatewayRef,
)
from govoplan_poll.backend.schemas import (
PollAnswerInput,
PollParticipationPolicyInput,
PollResponseGatewayInput,
PollSubmitResponseRequest,
)
from govoplan_poll.backend.service import (
PollError,
_assert_governed_invitation_gateway_allowed,
_assert_poll_accepts_responses,
_existing_response,
_lock_poll_for_response,
_now,
assert_no_sensitive_participation_metadata,
get_poll_invitation_by_token,
normalize_response_answers,
response_datetime,
)
GENERIC_PARTICIPATION_ERROR = "Poll invitation not found"
MAX_COMMENT_LENGTH = 4_000
def response_gateway_payload(gateway: PollResponseGatewayRef) -> dict[str, str]:
try:
return PollResponseGatewayInput(
module_id=gateway.module_id,
resource_type=gateway.resource_type,
resource_id=gateway.resource_id,
).model_dump(mode="json")
except ValidationError as exc:
raise PollError("Invalid participation response gateway") from exc
def participation_policy_payload(policy: PollParticipationPolicy) -> dict[str, Any]:
try:
return PollParticipationPolicyInput(
version=policy.version,
single_choice=policy.single_choice,
allow_maybe=policy.allow_maybe,
max_participants_per_option=policy.max_participants_per_option,
allow_comments=policy.allow_comments,
participant_email_required=policy.participant_email_required,
anonymous_password_required=policy.anonymous_password_required,
).model_dump(mode="json")
except ValidationError as exc:
raise PollError("Invalid participation policy") from exc
def response_gateway_ref(value: Mapping[str, Any] | None) -> PollResponseGatewayRef:
try:
parsed = PollResponseGatewayInput.model_validate(value)
except ValidationError as exc:
raise PollError(GENERIC_PARTICIPATION_ERROR) from exc
return PollResponseGatewayRef(
module_id=parsed.module_id,
resource_type=parsed.resource_type,
resource_id=parsed.resource_id,
)
def participation_policy_ref(value: Mapping[str, Any] | None) -> PollParticipationPolicy:
try:
parsed = PollParticipationPolicyInput.model_validate(value)
except ValidationError as exc:
raise PollError(GENERIC_PARTICIPATION_ERROR) from exc
return PollParticipationPolicy(
version=parsed.version,
single_choice=parsed.single_choice,
allow_maybe=parsed.allow_maybe,
max_participants_per_option=parsed.max_participants_per_option,
allow_comments=parsed.allow_comments,
participant_email_required=parsed.participant_email_required,
anonymous_password_required=parsed.anonymous_password_required,
)
def governed_invitation(
session: Session,
*,
token: str,
gateway: PollResponseGatewayRef,
lock: bool = False,
) -> PollInvitation:
"""Resolve a gateway-bound token without revealing which check failed."""
try:
invitation = get_poll_invitation_by_token(session, token=token)
_assert_governed_invitation(invitation, gateway=gateway)
if lock:
invitation = (
session.query(PollInvitation)
.filter(PollInvitation.id == invitation.id)
.populate_existing()
.with_for_update()
.one_or_none()
)
_assert_governed_invitation(invitation, gateway=gateway)
except (PollError, ValidationError) as exc:
raise PollError(GENERIC_PARTICIPATION_ERROR) from exc
if invitation is None:
raise PollError(GENERIC_PARTICIPATION_ERROR)
return invitation
def _assert_governed_invitation(
invitation: PollInvitation | None,
*,
gateway: PollResponseGatewayRef,
authenticated_respondent_id: str | None = None,
) -> None:
if invitation is None or invitation.poll.deleted_at is not None:
raise PollError(GENERIC_PARTICIPATION_ERROR)
if invitation.revoked_at is not None:
raise PollError(GENERIC_PARTICIPATION_ERROR)
if (
invitation.expires_at is not None
and response_datetime(invitation.expires_at) <= _now()
):
raise PollError(GENERIC_PARTICIPATION_ERROR)
_assert_governed_invitation_gateway_allowed(
poll=invitation.poll,
gateway=response_gateway_payload(gateway),
)
if response_gateway_ref(invitation.response_gateway_) != gateway:
raise PollError(GENERIC_PARTICIPATION_ERROR)
participation_policy_ref(invitation.participation_policy_)
if authenticated_respondent_id is not None and (
invitation.respondent_id is not None
and invitation.respondent_id != authenticated_respondent_id
):
raise PollError(GENERIC_PARTICIPATION_ERROR)
def governed_invitation_by_id(
session: Session,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
respondent_id: str,
lock: bool = False,
) -> PollInvitation:
"""Resolve an active governed invitation for one authenticated identity."""
if not respondent_id:
raise PollError(GENERIC_PARTICIPATION_ERROR)
try:
query = session.query(PollInvitation).filter(
PollInvitation.tenant_id == tenant_id,
PollInvitation.poll_id == poll_id,
PollInvitation.id == invitation_id,
)
if lock:
query = query.populate_existing().with_for_update()
invitation = query.one_or_none()
_assert_governed_invitation(
invitation,
gateway=gateway,
authenticated_respondent_id=respondent_id,
)
except PollError as exc:
raise PollError(GENERIC_PARTICIPATION_ERROR) from exc
if invitation is None:
raise PollError(GENERIC_PARTICIPATION_ERROR)
return invitation
def update_governed_invitation_expiry(
session: Session,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
expires_at: datetime | None,
) -> tuple[PollInvitation, bool]:
"""Update expiry without rotating the bearer token or reviving revocation."""
poll = _lock_poll_for_response(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
invitation = (
session.query(PollInvitation)
.filter(
PollInvitation.tenant_id == tenant_id,
PollInvitation.poll_id == poll.id,
PollInvitation.id == invitation_id,
)
.populate_existing()
.with_for_update()
.one_or_none()
)
try:
if invitation is None or invitation.revoked_at is not None:
raise PollError(GENERIC_PARTICIPATION_ERROR)
_assert_governed_invitation_gateway_allowed(
poll=poll,
gateway=response_gateway_payload(gateway),
)
if response_gateway_ref(invitation.response_gateway_) != gateway:
raise PollError(GENERIC_PARTICIPATION_ERROR)
participation_policy_ref(invitation.participation_policy_)
except (PollError, ValidationError) as exc:
raise PollError(GENERIC_PARTICIPATION_ERROR) from exc
normalized_expiry = response_datetime(expires_at)
replayed = response_datetime(invitation.expires_at) == normalized_expiry
if not replayed:
invitation.expires_at = normalized_expiry
session.flush()
return invitation, replayed
def _normalize_email(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().casefold()
if not normalized:
return None
if (
normalized.count("@") != 1
or any(character.isspace() for character in normalized)
or not all(normalized.split("@", 1))
):
raise PollError("Participant email is invalid")
return normalized
def _canonical_respondent_id(
invitation: PollInvitation,
*,
respondent_id: str | None,
participant_email: str | None,
participant_is_authenticated: bool,
) -> str:
if invitation.respondent_id:
return invitation.respondent_id
if participant_is_authenticated and respondent_id:
return respondent_id
if participant_email and invitation.email is None:
email_fingerprint = hashlib.sha256(participant_email.encode("utf-8")).hexdigest()
return f"invitation:{invitation.id}:email:{email_fingerprint}"
return f"invitation:{invitation.id}"
def _normalize_comment(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
if len(normalized) > MAX_COMMENT_LENGTH:
raise PollError(f"Participation comments cannot exceed {MAX_COMMENT_LENGTH} characters")
return normalized or None
def _answer_reserves_capacity(poll: Poll, answer: Mapping[str, Any]) -> bool:
if poll.kind == "availability":
return answer.get("value") == "available"
return True
def _validate_gateway_rules(
*,
invitation: PollInvitation,
policy: PollParticipationPolicy,
command: PollGovernedResponseCommand,
) -> tuple[str | None, str | None, list[PollAnswerInput]]:
normalized_email = _normalize_email(command.participant_email or invitation.email)
invitation_email = _normalize_email(invitation.email)
if invitation_email is not None and normalized_email != invitation_email:
raise PollError("Participant email does not match this invitation")
if (
not command.participant_is_authenticated
and policy.participant_email_required
and normalized_email is None
):
raise PollError("Participant email is required")
if (
not command.participant_is_authenticated
and policy.anonymous_password_required
and ANONYMOUS_PASSWORD_REQUIREMENT not in command.verified_requirements
):
raise PollError("Anonymous password verification is required")
comment = _normalize_comment(command.comment)
if comment is not None and not policy.allow_comments:
raise PollError("Comments are not enabled for this participation link")
answers = [
PollAnswerInput(
option_id=answer.option_id,
option_key=answer.option_key,
value=answer.value,
rank=answer.rank,
)
for answer in command.answers
]
return normalized_email, comment, answers
def _validate_normalized_gateway_answers(
*,
poll: Poll,
policy: PollParticipationPolicy,
normalized_answers: list[dict[str, Any]],
) -> None:
"""Enforce policy against Poll-resolved option identities and values."""
if not policy.allow_maybe and any(
answer.get("value") == "maybe" or answer.get("option_key") == "maybe"
for answer in normalized_answers
):
raise PollError("Maybe responses are not enabled for this participation link")
selected_count = sum(
(
answer.get("value") != "unavailable"
if poll.kind == "availability"
else True
)
for answer in normalized_answers
)
if policy.single_choice and selected_count > 1:
raise PollError("Only one poll option may be selected")
def _selected_capacity_option_ids(
poll: Poll,
normalized_answers: list[dict[str, Any]],
) -> set[str]:
return {
str(answer["option_id"])
for answer in normalized_answers
if answer.get("option_id") and _answer_reserves_capacity(poll, answer)
}
def _enforce_capacity(
session: Session,
*,
poll: Poll,
existing: PollResponse | None,
normalized_answers: list[dict[str, Any]],
limit: int | None,
) -> None:
if limit is None:
return
selected = _selected_capacity_option_ids(poll, normalized_answers)
if not selected:
return
counts = dict.fromkeys(selected, 0)
responses = (
session.query(PollResponse)
.filter(
PollResponse.tenant_id == poll.tenant_id,
PollResponse.poll_id == poll.id,
PollResponse.deleted_at.is_(None),
)
.order_by(PollResponse.id.asc())
.all()
)
for response in responses:
if existing is not None and response.id == existing.id:
continue
for answer in response.answers or []:
if (
isinstance(answer, Mapping)
and answer.get("option_id") in counts
and _answer_reserves_capacity(poll, answer)
):
counts[str(answer["option_id"])] += 1
full = {option_id for option_id, count in counts.items() if count >= limit}
if full:
raise PollError("Participant limit reached for one or more poll options")
def _normalize_idempotency_key(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
if not normalized:
raise PollError("Idempotency key cannot be empty")
if len(normalized) > 255:
raise PollError("Idempotency key cannot be longer than 255 characters")
return normalized
def _submission_fingerprint(
*,
gateway: PollResponseGatewayRef,
command: PollGovernedResponseCommand,
normalized_email: str | None,
comment: str | None,
) -> str:
value = {
"gateway": response_gateway_payload(gateway),
"respondent_id": command.respondent_id,
"respondent_label": command.respondent_label,
"participant_email": normalized_email,
"participant_is_authenticated": command.participant_is_authenticated,
"answers": [
{
"option_id": answer.option_id,
"option_key": answer.option_key,
"value": answer.value,
"rank": answer.rank,
}
for answer in command.answers
],
"comment": comment,
"verified_requirements": sorted(command.verified_requirements),
"metadata": dict(command.metadata),
}
encoded = json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _idempotent_submission(
session: Session,
*,
invitation_id: str,
idempotency_key: str | None,
request_fingerprint: str,
) -> PollResponse | None:
if idempotency_key is None:
return None
submission = (
session.query(PollParticipationSubmission)
.filter(
PollParticipationSubmission.invitation_id == invitation_id,
PollParticipationSubmission.idempotency_key == idempotency_key,
)
.one_or_none()
)
if submission is None:
return None
if submission.request_fingerprint != request_fingerprint:
raise PollError("Idempotency key was already used for a different response")
response = (
session.query(PollResponse)
.filter(PollResponse.id == submission.response_id)
.one_or_none()
)
if response is None:
raise PollError("Idempotent response record is no longer available")
return response
def submit_governed_poll_response(
session: Session,
*,
token: str,
gateway: PollResponseGatewayRef,
command: PollGovernedResponseCommand,
) -> tuple[PollInvitation, PollResponse, bool]:
"""Submit through a bound gateway and enforce its snapshot atomically."""
initial = governed_invitation(session, token=token, gateway=gateway)
poll = _lock_poll_for_response(
session,
tenant_id=initial.tenant_id,
poll_id=initial.poll_id,
)
invitation = governed_invitation(session, token=token, gateway=gateway, lock=True)
if invitation.poll_id != poll.id:
raise PollError(GENERIC_PARTICIPATION_ERROR)
return _submit_locked_governed_response(
session,
poll=poll,
invitation=invitation,
gateway=gateway,
command=command,
)
def submit_authenticated_poll_response(
session: Session,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
respondent_id: str,
command: PollGovernedResponseCommand,
) -> tuple[PollInvitation, PollResponse, bool]:
"""Submit by governed invitation id for one authenticated respondent."""
if (
not command.participant_is_authenticated
or not respondent_id
or command.respondent_id != respondent_id
):
raise PollError(GENERIC_PARTICIPATION_ERROR)
initial = governed_invitation_by_id(
session,
tenant_id=tenant_id,
poll_id=poll_id,
invitation_id=invitation_id,
gateway=gateway,
respondent_id=respondent_id,
)
poll = _lock_poll_for_response(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
invitation = governed_invitation_by_id(
session,
tenant_id=tenant_id,
poll_id=poll_id,
invitation_id=invitation_id,
gateway=gateway,
respondent_id=respondent_id,
lock=True,
)
if initial.id != invitation.id or invitation.poll_id != poll.id:
raise PollError(GENERIC_PARTICIPATION_ERROR)
return _submit_locked_governed_response(
session,
poll=poll,
invitation=invitation,
gateway=gateway,
command=command,
)
def _submit_locked_governed_response(
session: Session,
*,
poll: Poll,
invitation: PollInvitation,
gateway: PollResponseGatewayRef,
command: PollGovernedResponseCommand,
) -> tuple[PollInvitation, PollResponse, bool]:
policy = participation_policy_ref(invitation.participation_policy_)
assert_no_sensitive_participation_metadata(command.metadata)
normalized_email, comment, answer_inputs = _validate_gateway_rules(
invitation=invitation,
policy=policy,
command=command,
)
respondent_id = _canonical_respondent_id(
invitation,
respondent_id=command.respondent_id,
participant_email=normalized_email,
participant_is_authenticated=command.participant_is_authenticated,
)
idempotency_key = _normalize_idempotency_key(command.idempotency_key)
request_fingerprint = _submission_fingerprint(
gateway=gateway,
command=command,
normalized_email=normalized_email,
comment=comment,
)
replay = _idempotent_submission(
session,
invitation_id=invitation.id,
idempotency_key=idempotency_key,
request_fingerprint=request_fingerprint,
)
if replay is not None:
return invitation, replay, True
_assert_poll_accepts_responses(poll)
payload = PollSubmitResponseRequest(
respondent_id=respondent_id,
respondent_label=(
command.respondent_label
or invitation.respondent_label
or invitation.email
or normalized_email
),
answers=answer_inputs,
metadata={
key: value
for key, value in command.metadata.items()
if key not in {"invitation_id", "participant_email", "comment", "response_gateway"}
},
)
normalized_answers = normalize_response_answers(poll, payload)
_validate_normalized_gateway_answers(
poll=poll,
policy=policy,
normalized_answers=normalized_answers,
)
existing = _existing_response(session, poll=poll, respondent_id=respondent_id)
_enforce_capacity(
session,
poll=poll,
existing=existing,
normalized_answers=normalized_answers,
limit=policy.max_participants_per_option,
)
trusted_metadata = dict(payload.metadata)
trusted_metadata["invitation_id"] = invitation.id
trusted_metadata["response_gateway"] = response_gateway_payload(gateway)
if normalized_email is not None:
trusted_metadata["participant_email"] = normalized_email
if comment is not None:
trusted_metadata["comment"] = comment
now = _now()
if existing is not None:
if not poll.allow_response_update:
raise PollError("Response updates are not allowed for this poll")
existing.answers = normalized_answers
existing.respondent_label = payload.respondent_label
existing.submitted_at = now
existing.metadata_ = trusted_metadata
response = existing
else:
response = PollResponse(
tenant_id=poll.tenant_id,
poll_id=poll.id,
respondent_id=respondent_id,
respondent_label=payload.respondent_label,
answers=normalized_answers,
submitted_at=now,
metadata_=trusted_metadata,
)
session.add(response)
session.flush()
if idempotency_key is not None:
session.add(
PollParticipationSubmission(
tenant_id=poll.tenant_id,
poll_id=poll.id,
invitation_id=invitation.id,
response_id=response.id,
idempotency_key=idempotency_key,
request_fingerprint=request_fingerprint,
)
)
invitation.last_used_at = response.submitted_at
session.flush()
return invitation, response, False
def response_metadata(response: PollResponse) -> tuple[str | None, str | None]:
metadata = response.metadata_ or {}
email = metadata.get("participant_email")
comment = metadata.get("comment")
return (
email if isinstance(email, str) else None,
comment if isinstance(comment, str) else None,
)
def response_for_invitation(
session: Session,
*,
invitation: PollInvitation,
respondent_id: str | None = None,
participant_email: str | None = None,
) -> PollResponse | None:
normalized_email = _normalize_email(participant_email)
if invitation.respondent_id:
response_respondent_id = invitation.respondent_id
elif respondent_id:
response_respondent_id = respondent_id
elif invitation.email:
response_respondent_id = f"invitation:{invitation.id}"
elif normalized_email:
response_respondent_id = _canonical_respondent_id(
invitation,
respondent_id=None,
participant_email=normalized_email,
participant_is_authenticated=False,
)
else:
return None
return (
session.query(PollResponse)
.filter(
PollResponse.tenant_id == invitation.tenant_id,
PollResponse.poll_id == invitation.poll_id,
PollResponse.respondent_id == response_respondent_id,
PollResponse.deleted_at.is_(None),
)
.order_by(PollResponse.submitted_at.desc(), PollResponse.id.desc())
.first()
)
__all__ = [
"GENERIC_PARTICIPATION_ERROR",
"MAX_COMMENT_LENGTH",
"governed_invitation",
"governed_invitation_by_id",
"participation_policy_payload",
"participation_policy_ref",
"response_for_invitation",
"response_gateway_payload",
"response_gateway_ref",
"response_metadata",
"submit_authenticated_poll_response",
"submit_governed_poll_response",
"update_governed_invitation_expiry",
]