feat: add confidential ballot reference provider

This commit is contained in:
2026-08-02 03:41:01 +02:00
parent c176c5cfcb
commit 0a4e06077d
10 changed files with 1505 additions and 44 deletions
+169 -35
View File
@@ -12,8 +12,11 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.voting import (
ExternalVotingCastRequest,
ExternalVotingFinalizationRequest,
ExternalVotingPreparationRequest,
ExternalVotingProvider,
InteractiveExternalVotingProvider,
VOTING_ASSURANCE_RECORDED,
VotingBallotCreateCommand,
VotingBallotRef,
@@ -250,19 +253,81 @@ class SqlVotingBallots:
payload = dict(current.payload)
_validate_draft(payload)
profile = str(payload["assurance_profile"])
provider: object | None = None
if profile != VOTING_ASSURANCE_RECORDED:
provider_id = str(payload.get("provider_id") or "").strip()
if (
not provider_id
or _capability(self._registry, voting_provider_capability(provider_id))
is None
):
provider = (
_capability(self._registry, voting_provider_capability(provider_id))
if provider_id
else None
)
if not isinstance(provider, ExternalVotingProvider):
raise VotingStoreError(
"The selected Voting assurance profile requires an available external provider."
)
definition_hash, electorate_hash = _frozen_hashes(payload)
payload["definition_sha256"] = definition_hash
payload["electorate_sha256"] = electorate_hash
provider_evidence: tuple[Mapping[str, object], ...] = ()
if isinstance(provider, InteractiveExternalVotingProvider):
provider_id = str(payload["provider_id"])
try:
prepared = provider.prepare_ballot(
typed_session,
principal,
request=ExternalVotingPreparationRequest(
tenant_id=current.tenant_id,
ballot_id=current.ballot_id,
requested_provider_ballot_ref=_optional_text(
payload.get("provider_ballot_ref")
),
definition_sha256=definition_hash,
electorate_sha256=electorate_hash,
assurance_profile=profile, # type: ignore[arg-type]
method=str(payload["method"]),
options=tuple(
_option_from_mapping(item) for item in payload["options"]
),
electorate=tuple(
_elector_from_mapping(item)
for item in payload["electorate"]
),
allow_replacement=bool(payload.get("allow_replacement")),
quorum_weight=int(payload.get("quorum_weight") or 0),
threshold_numerator=int(
payload.get("threshold_numerator") or 1
),
threshold_denominator=int(
payload.get("threshold_denominator") or 2
),
opens_at=_parse_datetime(payload.get("opens_at")),
closes_at=_parse_datetime(payload.get("closes_at")),
requested_at=_now(),
idempotency_key=f"open:{_idempotency(idempotency_key)}",
),
)
except ValueError as exc:
raise VotingStoreError(
"Voting provider preparation was rejected."
) from exc
if (
prepared.provider_id != provider_id
or prepared.definition_sha256 != definition_hash
or prepared.electorate_sha256 != electorate_hash
or prepared.state not in {"prepared", "open"}
):
raise VotingStoreError(
"Voting provider preparation did not match the frozen ballot."
)
payload["provider_ballot_ref"] = prepared.provider_ballot_ref
provider_evidence = tuple(prepared.evidence)
elif (
profile != VOTING_ASSURANCE_RECORDED
and not str(payload.get("provider_ballot_ref") or "").strip()
):
raise VotingStoreError(
"This external Voting provider requires a ballot reference before opening."
)
revised = _revise(
typed_session,
current=current,
@@ -276,6 +341,9 @@ class SqlVotingBallots:
"revision": current.revision + 1,
"definition_sha256": definition_hash,
"electorate_sha256": electorate_hash,
"provider_id": payload.get("provider_id"),
"provider_ballot_ref": payload.get("provider_ballot_ref"),
"provider_evidence": [dict(item) for item in provider_evidence],
},
)
result = _ref(revised)
@@ -301,10 +369,6 @@ class SqlVotingBallots:
current = _current(typed_session, principal, ballot_id=ballot_id, lock=True)
if current.state != "open":
raise VotingStoreError("Only an open Voting ballot accepts votes.")
if current.assurance_profile != VOTING_ASSURANCE_RECORDED:
raise VotingStoreError(
"Votes for this assurance profile must be cast through its external provider."
)
_validate_window(current.payload)
actor_id = _principal_actor(principal)
elector_id = str(command.elector_id or actor_id or "").strip()
@@ -328,6 +392,63 @@ class SqlVotingBallots:
)
_validate_selections(current.payload, selections)
idempotency_key = _idempotency(command.idempotency_key)
if current.assurance_profile != VOTING_ASSURANCE_RECORDED:
provider_id = str(current.payload.get("provider_id") or "").strip()
provider_ref = str(current.payload.get("provider_ballot_ref") or "").strip()
provider = _capability(
self._registry,
voting_provider_capability(provider_id),
)
if not isinstance(provider, InteractiveExternalVotingProvider):
raise VotingStoreError(
"This Voting provider does not expose an interactive cast capability."
)
try:
receipt = provider.cast_ballot(
typed_session,
principal,
request=ExternalVotingCastRequest(
tenant_id=current.tenant_id,
ballot_id=current.ballot_id,
provider_ballot_ref=provider_ref,
definition_sha256=str(current.definition_sha256),
electorate_sha256=str(current.electorate_sha256),
elector_id=elector_id,
selections=selections,
allow_replacement=bool(
current.payload.get("allow_replacement")
),
requested_at=_now(),
idempotency_key=idempotency_key,
),
)
except ValueError as exc:
raise VotingStoreError("Voting provider cast was rejected.") from exc
if receipt.ballot_id != current.ballot_id:
raise VotingStoreError(
"Voting provider returned a receipt for another ballot."
)
normalized = VotingReceipt(
ballot_id=current.ballot_id,
revision=current.revision,
receipt_sha256=receipt.receipt_sha256,
cast_at=receipt.cast_at,
replaced_previous=receipt.replaced_previous,
replayed=receipt.replayed,
)
if not normalized.replayed:
_record_event(
typed_session,
row=current,
event_type="ballot.vote_cast",
principal=principal,
payload={
"receipt_sha256": normalized.receipt_sha256,
"provider_id": provider_id,
"replaced_previous": normalized.replaced_previous,
},
)
return normalized
replay = (
typed_session.query(VotingCastRecord)
.filter(
@@ -743,26 +864,32 @@ class SqlVotingBallots:
if not isinstance(provider, ExternalVotingProvider):
raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.")
electorate = list(current.payload["electorate"])
result = provider.finalize_ballot(
session,
principal,
request=ExternalVotingFinalizationRequest(
tenant_id=current.tenant_id,
ballot_id=current.ballot_id,
provider_ballot_ref=provider_ref,
definition_sha256=str(current.definition_sha256),
electorate_sha256=str(current.electorate_sha256),
options=tuple(
_option_from_mapping(item) for item in current.payload["options"]
try:
result = provider.finalize_ballot(
session,
principal,
request=ExternalVotingFinalizationRequest(
tenant_id=current.tenant_id,
ballot_id=current.ballot_id,
provider_ballot_ref=provider_ref,
definition_sha256=str(current.definition_sha256),
electorate_sha256=str(current.electorate_sha256),
options=tuple(
_option_from_mapping(item)
for item in current.payload["options"]
),
eligible_count=len(electorate),
eligible_weight=sum(
int(item.get("weight") or 1) for item in electorate
),
requested_at=_now(),
idempotency_key=_idempotency(idempotency_key),
),
eligible_count=len(electorate),
eligible_weight=sum(
int(item.get("weight") or 1) for item in electorate
),
requested_at=_now(),
idempotency_key=_idempotency(idempotency_key),
),
)
)
except ValueError as exc:
raise VotingStoreError(
"Voting provider finalization was rejected."
) from exc
option_keys = {str(item["key"]) for item in current.payload["options"]}
if result.ballot_id != current.ballot_id:
raise VotingStoreError(
@@ -871,13 +998,8 @@ def _validate_draft(payload: Mapping[str, Any]) -> None:
if opens_at and closes_at and closes_at <= opens_at:
raise VotingStoreError("Voting close time must be after its open time.")
if assurance != VOTING_ASSURANCE_RECORDED:
if (
not str(payload.get("provider_id") or "").strip()
or not str(payload.get("provider_ballot_ref") or "").strip()
):
raise VotingStoreError(
"External Voting assurance requires provider id and ballot reference."
)
if not str(payload.get("provider_id") or "").strip():
raise VotingStoreError("External Voting assurance requires a provider id.")
def _validate_selections(
@@ -919,6 +1041,7 @@ def _frozen_hashes(payload: Mapping[str, Any]) -> tuple[str, str]:
"definition_sha256",
"result",
"certification",
"provider_ballot_ref",
}
}
return _sha256(definition), _sha256(electorate)
@@ -1169,6 +1292,17 @@ def _option_from_mapping(value: Mapping[str, Any]):
)
def _elector_from_mapping(value: Mapping[str, Any]):
from govoplan_core.core.voting import VotingElector
return VotingElector(
subject_id=str(value["subject_id"]),
label=_optional_text(value.get("label")),
weight=int(value.get("weight") or 1),
provenance=dict(value.get("provenance") or {}),
)
def _sha256(value: object) -> str:
encoded = json.dumps(
value,