feat: add confidential ballot reference provider
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user