feat: add confidential ballot reference provider
This commit is contained in:
@@ -8,8 +8,11 @@ The native `recorded` assurance profile is auditable but not secret: active
|
|||||||
ballots are stored so that a result can be reconstructed. Confidential,
|
ballots are stored so that a result can be reconstructed. Confidential,
|
||||||
secret, and externally certified profiles fail closed unless an explicit
|
secret, and externally certified profiles fail closed unless an explicit
|
||||||
`voting.provider.<provider>` capability is installed. Providers return only
|
`voting.provider.<provider>` capability is installed. Providers return only
|
||||||
aggregate results and evidence; raw secret ballots and provider credentials
|
aggregate results and evidence through the public Voting boundary. The bundled
|
||||||
do not enter the Voting database.
|
`local_confidential` provider stores cast selections only as Encryption-owned
|
||||||
|
server-envelope ciphertext in provider-specific shared SQL tables. It is
|
||||||
|
confidential at rest, but it is not anonymous, secret, coercion-resistant, or
|
||||||
|
externally certified.
|
||||||
|
|
||||||
Committee and other modules retain their deliberation or business context and
|
Committee and other modules retain their deliberation or business context and
|
||||||
refer to Voting ballots through the `voting.ballots` capability. Informal
|
refer to Voting ballots through the `voting.ballots` capability. Informal
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ credentials inside its own assurance boundary and returns aggregate counts,
|
|||||||
weighted counts, a result hash, and evidence. GovOPlaN does not claim that a
|
weighted counts, a result hash, and evidence. GovOPlaN does not claim that a
|
||||||
provider or deployment satisfies legal or certification requirements merely
|
provider or deployment satisfies legal or certification requirements merely
|
||||||
because the adapter contract is implemented.
|
because the adapter contract is implemented.
|
||||||
|
Core bounds provider evidence to JSON, 64 items and 64 KiB and rejects fields
|
||||||
|
that can carry credentials, private key material, plaintext, or raw
|
||||||
|
selections before Voting or Committee can persist the projection.
|
||||||
|
|
||||||
|
### Bundled local confidential provider
|
||||||
|
|
||||||
|
`local_confidential` is an interactive reference provider for the
|
||||||
|
`confidential` profile. Opening a ballot creates a ballot-scoped Encryption
|
||||||
|
vault using the configured `local_aesgcm` provider. Casting stores the elector,
|
||||||
|
weight, receipt, and replacement generation as provider metadata while the
|
||||||
|
selection payload is stored only as authenticated ciphertext. Finalization
|
||||||
|
opens active cast envelopes inside the provider boundary and exports only the
|
||||||
|
aggregate and sanitized evidence.
|
||||||
|
|
||||||
|
Encryption records a context-bound keyed commitment rather than an enumerable
|
||||||
|
plaintext digest, so low-entropy choices are not exposed to an offline database
|
||||||
|
reader through idempotency metadata.
|
||||||
|
|
||||||
|
This profile is server-readable. It does not provide voter anonymity,
|
||||||
|
unlinkability, coercion resistance, client-held keys, an HSM/KMS claim, or
|
||||||
|
external certification. It therefore cannot be selected for `secret` or
|
||||||
|
`external_certified` ballots. A provider ballot reference is generated when
|
||||||
|
the ballot opens; externally hosted providers may continue to require a
|
||||||
|
pre-existing reference.
|
||||||
|
|
||||||
## Lifecycle and concurrency
|
## Lifecycle and concurrency
|
||||||
|
|
||||||
@@ -55,6 +79,8 @@ Back up these tables as one consistency unit:
|
|||||||
- `voting_cast_records`
|
- `voting_cast_records`
|
||||||
- `voting_lifecycle_events`
|
- `voting_lifecycle_events`
|
||||||
- `voting_command_replays`
|
- `voting_command_replays`
|
||||||
|
- `voting_confidential_ballots`
|
||||||
|
- `voting_confidential_casts`
|
||||||
|
|
||||||
A restore is valid only when current revisions are unique per tenant and
|
A restore is valid only when current revisions are unique per tenant and
|
||||||
ballot, cast generations are unique per elector, every active cast references
|
ballot, cast generations are unique per elector, every active cast references
|
||||||
@@ -62,6 +88,10 @@ the current frozen definition hash, result hashes can be recomputed, and event
|
|||||||
sequences are contiguous. Provider-backed restores also require the external
|
sequences are contiguous. Provider-backed restores also require the external
|
||||||
provider evidence and ballot reference to remain resolvable. Do not reopen a
|
provider evidence and ballot reference to remain resolvable. Do not reopen a
|
||||||
restored external ballot merely to compensate for missing provider state.
|
restored external ballot merely to compensate for missing provider state.
|
||||||
|
For `local_confidential`, restore the Voting and Encryption tables from the
|
||||||
|
same consistency point and restore the exact deployment `MASTER_KEY_B64` from
|
||||||
|
the approved secret store. A database-only restore or a changed/lost master
|
||||||
|
key must fail closed; it cannot reconstruct confidential casts.
|
||||||
|
|
||||||
Before destructive retirement, stop new casts, retain a verified database
|
Before destructive retirement, stop new casts, retain a verified database
|
||||||
snapshot, export result/certification evidence under the applicable retention
|
snapshot, export result/certification evidence under the applicable retention
|
||||||
@@ -74,6 +104,8 @@ node-local filesystem.
|
|||||||
- tenant predicates apply to every ballot, cast, event, and replay lookup
|
- tenant predicates apply to every ballot, cast, event, and replay lookup
|
||||||
- the authenticated account must match the frozen elector subject
|
- the authenticated account must match the frozen elector subject
|
||||||
- native casting fails closed for non-recorded assurance profiles
|
- native casting fails closed for non-recorded assurance profiles
|
||||||
|
- interactive provider casting validates the frozen tenant, electorate,
|
||||||
|
definition, options, replacement policy, and time window again
|
||||||
- raw selections are never returned by list, detail, result, or history APIs
|
- raw selections are never returned by list, detail, result, or history APIs
|
||||||
- provider result keys must exactly match frozen options
|
- provider result keys must exactly match frozen options
|
||||||
- external results require evidence and cannot exceed the frozen electorate
|
- external results require evidence and cannot exceed the frozen electorate
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ from typing import Any
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
JSON,
|
JSON,
|
||||||
|
LargeBinary,
|
||||||
String,
|
String,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
)
|
)
|
||||||
@@ -145,9 +147,126 @@ class VotingCommandReplay(Base, TimestampMixin):
|
|||||||
response: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
response: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class VotingConfidentialBallot(Base, TimestampMixin):
|
||||||
|
__tablename__ = "voting_confidential_ballots"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"provider_ballot_ref",
|
||||||
|
name="uq_voting_confidential_provider_ref",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"ballot_id",
|
||||||
|
name="uq_voting_confidential_ballot",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_voting_confidential_ballot_state",
|
||||||
|
"tenant_id",
|
||||||
|
"state",
|
||||||
|
"prepared_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
provider_ballot_ref: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True
|
||||||
|
)
|
||||||
|
ballot_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
electorate_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
assurance_profile: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
method: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
options: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False)
|
||||||
|
electorate: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False)
|
||||||
|
allow_replacement: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
|
quorum_weight: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
threshold_numerator: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
threshold_denominator: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
opens_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
closes_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
vault_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
preparation_idempotency_key: Mapped[str] = mapped_column(
|
||||||
|
String(160), nullable=False
|
||||||
|
)
|
||||||
|
preparation_request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
prepared_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False
|
||||||
|
)
|
||||||
|
finalization_idempotency_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(160), nullable=True
|
||||||
|
)
|
||||||
|
finalized_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
result: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class VotingConfidentialCast(Base, TimestampMixin):
|
||||||
|
__tablename__ = "voting_confidential_casts"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"provider_ballot_id",
|
||||||
|
"elector_id",
|
||||||
|
"generation",
|
||||||
|
name="uq_voting_confidential_cast_generation",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"provider_ballot_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_voting_confidential_cast_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_voting_confidential_cast_active",
|
||||||
|
"tenant_id",
|
||||||
|
"provider_ballot_id",
|
||||||
|
"elector_id",
|
||||||
|
"superseded_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
provider_ballot_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("voting_confidential_ballots.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
elector_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
generation: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||||
|
encryption_envelope_id: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True
|
||||||
|
)
|
||||||
|
encryption_resource_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), nullable=False, index=True
|
||||||
|
)
|
||||||
|
weight: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
cast_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
|
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
receipt_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"VotingBallotRevision",
|
"VotingBallotRevision",
|
||||||
"VotingCastRecord",
|
"VotingCastRecord",
|
||||||
"VotingCommandReplay",
|
"VotingCommandReplay",
|
||||||
|
"VotingConfidentialBallot",
|
||||||
|
"VotingConfidentialCast",
|
||||||
"VotingLifecycleEvent",
|
"VotingLifecycleEvent",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,761 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.encryption import (
|
||||||
|
ContentProtectionRequest,
|
||||||
|
ContentUnprotectionRequest,
|
||||||
|
KeyVaultCreateRequest,
|
||||||
|
encryption_content_cipher,
|
||||||
|
encryption_key_vault,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.voting import (
|
||||||
|
ExternalVotingBallotRef,
|
||||||
|
ExternalVotingCastRequest,
|
||||||
|
ExternalVotingFinalizationRequest,
|
||||||
|
ExternalVotingPreparationRequest,
|
||||||
|
VotingReceipt,
|
||||||
|
VotingResult,
|
||||||
|
)
|
||||||
|
from govoplan_voting.backend.db.models import (
|
||||||
|
VotingConfidentialBallot,
|
||||||
|
VotingConfidentialCast,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_CONFIDENTIAL_PROVIDER_ID = "local_confidential"
|
||||||
|
LOCAL_KEY_PROVIDER_ID = "local_aesgcm"
|
||||||
|
|
||||||
|
|
||||||
|
class LocalConfidentialVotingError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LocalConfidentialVotingProvider:
|
||||||
|
"""Reference confidential provider backed by encrypted shared SQL state.
|
||||||
|
|
||||||
|
The provider conceals selections at rest from ordinary Voting storage. It is
|
||||||
|
server-decryptable and intentionally makes no secret-ballot, coercion-
|
||||||
|
resistance, anonymity, HSM, or external-certification claim.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, registry: object | None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def prepare_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ExternalVotingPreparationRequest,
|
||||||
|
) -> ExternalVotingBallotRef:
|
||||||
|
db = _session(session)
|
||||||
|
_require_tenant(principal, request.tenant_id)
|
||||||
|
if request.assurance_profile != "confidential":
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The local confidential provider supports only the confidential assurance profile."
|
||||||
|
)
|
||||||
|
provider_ref = (
|
||||||
|
str(request.requested_provider_ballot_ref or "").strip()
|
||||||
|
or f"local-confidential:{request.ballot_id}"
|
||||||
|
)
|
||||||
|
payload = _preparation_payload(request, provider_ref=provider_ref)
|
||||||
|
digest = _digest(payload)
|
||||||
|
existing = (
|
||||||
|
db.query(VotingConfidentialBallot)
|
||||||
|
.filter(
|
||||||
|
VotingConfidentialBallot.tenant_id == request.tenant_id,
|
||||||
|
VotingConfidentialBallot.provider_ballot_ref == provider_ref,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.preparation_request_sha256 != digest:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider ballot reference was reused for another definition."
|
||||||
|
)
|
||||||
|
return self._ballot_ref(existing, replayed=True)
|
||||||
|
|
||||||
|
cipher = encryption_content_cipher(self._registry)
|
||||||
|
key_vault = encryption_key_vault(self._registry)
|
||||||
|
if cipher is None or key_vault is None:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The local confidential provider requires the Encryption key-vault and content-cipher capabilities."
|
||||||
|
)
|
||||||
|
vault_id = f"voting-{request.ballot_id}"
|
||||||
|
vault = key_vault.get_vault(
|
||||||
|
db,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
vault_id=vault_id,
|
||||||
|
)
|
||||||
|
if vault is None:
|
||||||
|
vault = key_vault.create_vault(
|
||||||
|
db,
|
||||||
|
principal,
|
||||||
|
request=KeyVaultCreateRequest(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
vault_id=vault_id,
|
||||||
|
name=f"Confidential ballot {request.ballot_id}",
|
||||||
|
provider_id=LOCAL_KEY_PROVIDER_ID,
|
||||||
|
purpose="voting.confidential.ballot",
|
||||||
|
algorithm_suite="AES-256-GCM",
|
||||||
|
scope_type="voting_ballot",
|
||||||
|
scope_id=request.ballot_id,
|
||||||
|
policy_ref="voting:local-confidential:v1",
|
||||||
|
idempotency_key=f"voting-vault:{request.ballot_id}",
|
||||||
|
recovery_quorum=2,
|
||||||
|
profile_kind="server_envelope",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if vault.state != "active" or vault.current_key is None:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential ballot Encryption vault is not active."
|
||||||
|
)
|
||||||
|
row = VotingConfidentialBallot(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
provider_ballot_ref=provider_ref,
|
||||||
|
ballot_id=request.ballot_id,
|
||||||
|
definition_sha256=request.definition_sha256,
|
||||||
|
electorate_sha256=request.electorate_sha256,
|
||||||
|
assurance_profile=request.assurance_profile,
|
||||||
|
method=request.method,
|
||||||
|
state="open",
|
||||||
|
options=[asdict(item) for item in request.options],
|
||||||
|
electorate=[
|
||||||
|
{
|
||||||
|
"subject_id": item.subject_id,
|
||||||
|
"label": item.label,
|
||||||
|
"weight": item.weight,
|
||||||
|
"provenance": dict(item.provenance),
|
||||||
|
}
|
||||||
|
for item in request.electorate
|
||||||
|
],
|
||||||
|
allow_replacement=request.allow_replacement,
|
||||||
|
quorum_weight=request.quorum_weight,
|
||||||
|
threshold_numerator=request.threshold_numerator,
|
||||||
|
threshold_denominator=request.threshold_denominator,
|
||||||
|
opens_at=request.opens_at,
|
||||||
|
closes_at=request.closes_at,
|
||||||
|
vault_id=vault_id,
|
||||||
|
preparation_idempotency_key=request.idempotency_key,
|
||||||
|
preparation_request_sha256=digest,
|
||||||
|
prepared_at=request.requested_at,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
return self._ballot_ref(row)
|
||||||
|
|
||||||
|
def cast_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ExternalVotingCastRequest,
|
||||||
|
) -> VotingReceipt:
|
||||||
|
db = _session(session)
|
||||||
|
_require_tenant(principal, request.tenant_id)
|
||||||
|
actor_id = _principal_actor(principal)
|
||||||
|
if not actor_id or actor_id != request.elector_id:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"A confidential ballot principal cannot cast for another elector."
|
||||||
|
)
|
||||||
|
ballot = self._ballot(
|
||||||
|
db,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
provider_ballot_ref=request.provider_ballot_ref,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
self._validate_binding(
|
||||||
|
ballot,
|
||||||
|
ballot_id=request.ballot_id,
|
||||||
|
definition_sha256=request.definition_sha256,
|
||||||
|
electorate_sha256=request.electorate_sha256,
|
||||||
|
)
|
||||||
|
if ballot.state != "open":
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider ballot is not open."
|
||||||
|
)
|
||||||
|
_validate_window(ballot)
|
||||||
|
electorate = {str(item["subject_id"]): item for item in ballot.electorate}
|
||||||
|
elector = electorate.get(request.elector_id)
|
||||||
|
if elector is None:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The current principal is not in the confidential provider electorate."
|
||||||
|
)
|
||||||
|
selections = tuple(str(item).strip() for item in request.selections)
|
||||||
|
_validate_selections(ballot, selections)
|
||||||
|
if request.allow_replacement != ballot.allow_replacement:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider replacement policy does not match the frozen ballot."
|
||||||
|
)
|
||||||
|
cipher = encryption_content_cipher(self._registry)
|
||||||
|
if cipher is None:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider cannot cast because Encryption is unavailable."
|
||||||
|
)
|
||||||
|
replay = (
|
||||||
|
db.query(VotingConfidentialCast)
|
||||||
|
.filter(
|
||||||
|
VotingConfidentialCast.tenant_id == request.tenant_id,
|
||||||
|
VotingConfidentialCast.provider_ballot_id == ballot.id,
|
||||||
|
VotingConfidentialCast.idempotency_key == request.idempotency_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if replay is not None:
|
||||||
|
opened = cipher.unprotect_content(
|
||||||
|
db,
|
||||||
|
request=ContentUnprotectionRequest(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
owner_module="voting",
|
||||||
|
resource_type="voting_confidential_cast",
|
||||||
|
resource_id=replay.encryption_resource_id,
|
||||||
|
envelope_id=replay.encryption_envelope_id,
|
||||||
|
ciphertext=replay.ciphertext,
|
||||||
|
actor_id=actor_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
stored_selections = _validate_decrypted_cast(
|
||||||
|
replay,
|
||||||
|
ballot,
|
||||||
|
json.loads(opened.decode("utf-8")),
|
||||||
|
)
|
||||||
|
request_digest = _confidential_cast_request_digest(
|
||||||
|
request,
|
||||||
|
generation=replay.generation,
|
||||||
|
ciphertext=replay.ciphertext,
|
||||||
|
)
|
||||||
|
if replay.request_sha256 != request_digest:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential vote idempotency key was reused for another vote."
|
||||||
|
)
|
||||||
|
if stored_selections != selections:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential vote idempotency key was reused for another vote."
|
||||||
|
)
|
||||||
|
return VotingReceipt(
|
||||||
|
ballot_id=request.ballot_id,
|
||||||
|
revision=0,
|
||||||
|
receipt_sha256=replay.receipt_sha256,
|
||||||
|
cast_at=_aware(replay.cast_at),
|
||||||
|
replaced_previous=replay.generation > 1,
|
||||||
|
replayed=True,
|
||||||
|
)
|
||||||
|
previous = (
|
||||||
|
db.query(VotingConfidentialCast)
|
||||||
|
.filter(
|
||||||
|
VotingConfidentialCast.tenant_id == request.tenant_id,
|
||||||
|
VotingConfidentialCast.provider_ballot_id == ballot.id,
|
||||||
|
VotingConfidentialCast.elector_id == request.elector_id,
|
||||||
|
VotingConfidentialCast.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if previous is not None and not ballot.allow_replacement:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"This confidential ballot does not allow vote replacement."
|
||||||
|
)
|
||||||
|
generation = previous.generation + 1 if previous is not None else 1
|
||||||
|
cast_at = request.requested_at
|
||||||
|
cast_id = str(uuid.uuid4())
|
||||||
|
plaintext = _confidential_cast_plaintext(
|
||||||
|
request,
|
||||||
|
generation=generation,
|
||||||
|
selections=selections,
|
||||||
|
)
|
||||||
|
protected = cipher.protect_content(
|
||||||
|
db,
|
||||||
|
request=ContentProtectionRequest(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
owner_module="voting",
|
||||||
|
resource_type="voting_confidential_cast",
|
||||||
|
resource_id=cast_id,
|
||||||
|
profile_id="voting-local-confidential-v1",
|
||||||
|
vault_id=ballot.vault_id,
|
||||||
|
ciphertext_ref=f"voting-db://confidential-casts/{cast_id}",
|
||||||
|
plaintext=plaintext,
|
||||||
|
policy_decision_ref="voting:local-confidential:v1",
|
||||||
|
idempotency_key=f"voting-confidential-cast:{cast_id}",
|
||||||
|
actor_id=actor_id,
|
||||||
|
metadata={"ballot_id": request.ballot_id, "generation": generation},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
request_digest = _confidential_cast_request_digest(
|
||||||
|
request,
|
||||||
|
generation=generation,
|
||||||
|
ciphertext=protected.ciphertext,
|
||||||
|
)
|
||||||
|
receipt_hash = _digest(
|
||||||
|
{
|
||||||
|
"tenant_id": request.tenant_id,
|
||||||
|
"ballot_id": request.ballot_id,
|
||||||
|
"definition_sha256": request.definition_sha256,
|
||||||
|
"elector_id": request.elector_id,
|
||||||
|
"generation": generation,
|
||||||
|
"ciphertext_digest": protected.envelope.ciphertext_digest,
|
||||||
|
"cast_at": cast_at.isoformat(),
|
||||||
|
"idempotency_key": request.idempotency_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if previous is not None:
|
||||||
|
previous.superseded_at = cast_at
|
||||||
|
db.add(
|
||||||
|
VotingConfidentialCast(
|
||||||
|
id=cast_id,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
provider_ballot_id=ballot.id,
|
||||||
|
elector_id=request.elector_id,
|
||||||
|
generation=generation,
|
||||||
|
definition_sha256=request.definition_sha256,
|
||||||
|
ciphertext=protected.ciphertext,
|
||||||
|
encryption_envelope_id=protected.envelope.envelope_id,
|
||||||
|
encryption_resource_id=cast_id,
|
||||||
|
weight=int(elector.get("weight") or 1),
|
||||||
|
cast_at=cast_at,
|
||||||
|
idempotency_key=request.idempotency_key,
|
||||||
|
request_sha256=request_digest,
|
||||||
|
receipt_sha256=receipt_hash,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
return VotingReceipt(
|
||||||
|
ballot_id=request.ballot_id,
|
||||||
|
revision=0,
|
||||||
|
receipt_sha256=receipt_hash,
|
||||||
|
cast_at=cast_at,
|
||||||
|
replaced_previous=previous is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def finalize_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ExternalVotingFinalizationRequest,
|
||||||
|
) -> VotingResult:
|
||||||
|
db = _session(session)
|
||||||
|
_require_tenant(principal, request.tenant_id)
|
||||||
|
ballot = self._ballot(
|
||||||
|
db,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
provider_ballot_ref=request.provider_ballot_ref,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
self._validate_binding(
|
||||||
|
ballot,
|
||||||
|
ballot_id=request.ballot_id,
|
||||||
|
definition_sha256=request.definition_sha256,
|
||||||
|
electorate_sha256=request.electorate_sha256,
|
||||||
|
)
|
||||||
|
if ballot.result is not None:
|
||||||
|
return _result_from_mapping(ballot.result)
|
||||||
|
if ballot.state != "open":
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider ballot cannot be finalized from its current state."
|
||||||
|
)
|
||||||
|
option_keys = tuple(str(item["key"]) for item in ballot.options)
|
||||||
|
if option_keys != tuple(item.key for item in request.options):
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider options do not match the frozen ballot."
|
||||||
|
)
|
||||||
|
if request.eligible_count != len(
|
||||||
|
ballot.electorate
|
||||||
|
) or request.eligible_weight != sum(
|
||||||
|
int(item.get("weight") or 1) for item in ballot.electorate
|
||||||
|
):
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider electorate totals do not match the frozen ballot."
|
||||||
|
)
|
||||||
|
cipher = encryption_content_cipher(self._registry)
|
||||||
|
if cipher is None:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider cannot tally because Encryption is unavailable."
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
db.query(VotingConfidentialCast)
|
||||||
|
.filter(
|
||||||
|
VotingConfidentialCast.tenant_id == request.tenant_id,
|
||||||
|
VotingConfidentialCast.provider_ballot_id == ballot.id,
|
||||||
|
VotingConfidentialCast.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(VotingConfidentialCast.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
counts = {key: 0 for key in option_keys}
|
||||||
|
weighted = {key: 0 for key in option_keys}
|
||||||
|
for row in rows:
|
||||||
|
opened = cipher.unprotect_content(
|
||||||
|
db,
|
||||||
|
request=ContentUnprotectionRequest(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
owner_module="voting",
|
||||||
|
resource_type="voting_confidential_cast",
|
||||||
|
resource_id=row.encryption_resource_id,
|
||||||
|
envelope_id=row.encryption_envelope_id,
|
||||||
|
ciphertext=row.ciphertext,
|
||||||
|
actor_id=_principal_actor(principal),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
item = json.loads(opened.decode("utf-8"))
|
||||||
|
selections = _validate_decrypted_cast(row, ballot, item)
|
||||||
|
for selection in selections:
|
||||||
|
counts[selection] += 1
|
||||||
|
weighted[selection] += row.weight
|
||||||
|
cast_weight = sum(row.weight for row in rows)
|
||||||
|
eligible_weight = sum(
|
||||||
|
int(item.get("weight") or 1) for item in ballot.electorate
|
||||||
|
)
|
||||||
|
quorum_met = cast_weight >= ballot.quorum_weight
|
||||||
|
max_weight = max(weighted.values(), default=0)
|
||||||
|
winners = tuple(
|
||||||
|
key for key in option_keys if max_weight > 0 and weighted[key] == max_weight
|
||||||
|
)
|
||||||
|
threshold_met = bool(
|
||||||
|
quorum_met
|
||||||
|
and winners
|
||||||
|
and max_weight * ballot.threshold_denominator
|
||||||
|
>= max(cast_weight, 1) * ballot.threshold_numerator
|
||||||
|
)
|
||||||
|
result_body = {
|
||||||
|
"ballot_id": ballot.ballot_id,
|
||||||
|
"counts": counts,
|
||||||
|
"weighted_counts": weighted,
|
||||||
|
"cast_count": len(rows),
|
||||||
|
"cast_weight": cast_weight,
|
||||||
|
"eligible_count": len(ballot.electorate),
|
||||||
|
"eligible_weight": eligible_weight,
|
||||||
|
"quorum_met": quorum_met,
|
||||||
|
"threshold_met": threshold_met,
|
||||||
|
"winning_options": winners,
|
||||||
|
"definition_sha256": ballot.definition_sha256,
|
||||||
|
"electorate_sha256": ballot.electorate_sha256,
|
||||||
|
}
|
||||||
|
result_sha256 = _digest(result_body)
|
||||||
|
evidence = (
|
||||||
|
{
|
||||||
|
"kind": "reference_provider_result",
|
||||||
|
"provider_id": LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||||
|
"provider_ballot_ref": ballot.provider_ballot_ref,
|
||||||
|
"assurance_profile": "confidential",
|
||||||
|
"protection_profile": "server_envelope",
|
||||||
|
"definition_sha256": ballot.definition_sha256,
|
||||||
|
"electorate_sha256": ballot.electorate_sha256,
|
||||||
|
"active_receipts_sha256": _digest(
|
||||||
|
sorted(row.receipt_sha256 for row in rows)
|
||||||
|
),
|
||||||
|
"certified": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = VotingResult(
|
||||||
|
ballot_id=ballot.ballot_id,
|
||||||
|
revision=0,
|
||||||
|
counts=counts,
|
||||||
|
weighted_counts=weighted,
|
||||||
|
cast_count=len(rows),
|
||||||
|
cast_weight=cast_weight,
|
||||||
|
eligible_count=len(ballot.electorate),
|
||||||
|
eligible_weight=eligible_weight,
|
||||||
|
quorum_met=quorum_met,
|
||||||
|
threshold_met=threshold_met,
|
||||||
|
winning_options=winners,
|
||||||
|
result_sha256=result_sha256,
|
||||||
|
evidence=evidence,
|
||||||
|
)
|
||||||
|
ballot.state = "closed"
|
||||||
|
ballot.finalization_idempotency_key = request.idempotency_key
|
||||||
|
ballot.finalized_at = request.requested_at
|
||||||
|
ballot.result = _result_mapping(result)
|
||||||
|
db.flush()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def ballot_status(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
provider_ballot_ref: str,
|
||||||
|
) -> ExternalVotingBallotRef | None:
|
||||||
|
db = _session(session)
|
||||||
|
_require_tenant(principal, tenant_id)
|
||||||
|
row = (
|
||||||
|
db.query(VotingConfidentialBallot)
|
||||||
|
.filter(
|
||||||
|
VotingConfidentialBallot.tenant_id == tenant_id,
|
||||||
|
VotingConfidentialBallot.provider_ballot_ref == provider_ballot_ref,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
return self._ballot_ref(row) if row is not None else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_binding(
|
||||||
|
ballot: VotingConfidentialBallot,
|
||||||
|
*,
|
||||||
|
ballot_id: str,
|
||||||
|
definition_sha256: str,
|
||||||
|
electorate_sha256: str,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
ballot.ballot_id != ballot_id
|
||||||
|
or ballot.definition_sha256 != definition_sha256
|
||||||
|
or ballot.electorate_sha256 != electorate_sha256
|
||||||
|
):
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider request does not match the frozen ballot."
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ballot_ref(
|
||||||
|
row: VotingConfidentialBallot,
|
||||||
|
*,
|
||||||
|
replayed: bool = False,
|
||||||
|
) -> ExternalVotingBallotRef:
|
||||||
|
return ExternalVotingBallotRef(
|
||||||
|
provider_id=LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||||
|
provider_ballot_ref=row.provider_ballot_ref,
|
||||||
|
state="closed" if row.state == "closed" else "open",
|
||||||
|
definition_sha256=row.definition_sha256,
|
||||||
|
electorate_sha256=row.electorate_sha256,
|
||||||
|
evidence=(
|
||||||
|
{
|
||||||
|
"kind": "reference_provider_preparation",
|
||||||
|
"assurance_profile": "confidential",
|
||||||
|
"protection_profile": "server_envelope",
|
||||||
|
"replayed": replayed,
|
||||||
|
"certified": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ballot(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
provider_ballot_ref: str,
|
||||||
|
lock: bool,
|
||||||
|
) -> VotingConfidentialBallot:
|
||||||
|
query = session.query(VotingConfidentialBallot).filter(
|
||||||
|
VotingConfidentialBallot.tenant_id == tenant_id,
|
||||||
|
VotingConfidentialBallot.provider_ballot_ref == provider_ballot_ref,
|
||||||
|
)
|
||||||
|
if lock:
|
||||||
|
query = query.with_for_update()
|
||||||
|
row = query.one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider ballot was not found."
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _preparation_payload(
|
||||||
|
request: ExternalVotingPreparationRequest,
|
||||||
|
*,
|
||||||
|
provider_ref: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"tenant_id": request.tenant_id,
|
||||||
|
"ballot_id": request.ballot_id,
|
||||||
|
"provider_ballot_ref": provider_ref,
|
||||||
|
"definition_sha256": request.definition_sha256,
|
||||||
|
"electorate_sha256": request.electorate_sha256,
|
||||||
|
"assurance_profile": request.assurance_profile,
|
||||||
|
"method": request.method,
|
||||||
|
"options": [asdict(item) for item in request.options],
|
||||||
|
"electorate": [asdict(item) for item in request.electorate],
|
||||||
|
"allow_replacement": request.allow_replacement,
|
||||||
|
"quorum_weight": request.quorum_weight,
|
||||||
|
"threshold_numerator": request.threshold_numerator,
|
||||||
|
"threshold_denominator": request.threshold_denominator,
|
||||||
|
"opens_at": request.opens_at,
|
||||||
|
"closes_at": request.closes_at,
|
||||||
|
"idempotency_key": request.idempotency_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_selections(
|
||||||
|
ballot: VotingConfidentialBallot,
|
||||||
|
selections: tuple[str, ...],
|
||||||
|
) -> None:
|
||||||
|
option_keys = {str(item["key"]) for item in ballot.options}
|
||||||
|
if (
|
||||||
|
not selections
|
||||||
|
or len(selections) != len(set(selections))
|
||||||
|
or not set(selections) <= option_keys
|
||||||
|
):
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"Confidential selections must be unique configured option keys."
|
||||||
|
)
|
||||||
|
if ballot.method in {"single_choice", "yes_no_abstain"} and len(selections) != 1:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"This confidential Voting method requires exactly one selection."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_decrypted_cast(
|
||||||
|
row: VotingConfidentialCast,
|
||||||
|
ballot: VotingConfidentialBallot,
|
||||||
|
value: object,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"A confidential cast payload has an invalid structure."
|
||||||
|
)
|
||||||
|
selections = tuple(str(item) for item in value.get("selections", ()))
|
||||||
|
if (
|
||||||
|
value.get("ballot_id") != ballot.ballot_id
|
||||||
|
or value.get("definition_sha256") != ballot.definition_sha256
|
||||||
|
or value.get("elector_id") != row.elector_id
|
||||||
|
or value.get("generation") != row.generation
|
||||||
|
):
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"A confidential cast payload does not match its authenticated record."
|
||||||
|
)
|
||||||
|
_validate_selections(ballot, selections)
|
||||||
|
return selections
|
||||||
|
|
||||||
|
|
||||||
|
def _confidential_cast_plaintext(
|
||||||
|
request: ExternalVotingCastRequest,
|
||||||
|
*,
|
||||||
|
generation: int,
|
||||||
|
selections: tuple[str, ...],
|
||||||
|
) -> bytes:
|
||||||
|
return _canonical_json(
|
||||||
|
{
|
||||||
|
"ballot_id": request.ballot_id,
|
||||||
|
"definition_sha256": request.definition_sha256,
|
||||||
|
"elector_id": request.elector_id,
|
||||||
|
"generation": generation,
|
||||||
|
"selections": selections,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _confidential_cast_request_digest(
|
||||||
|
request: ExternalVotingCastRequest,
|
||||||
|
*,
|
||||||
|
generation: int,
|
||||||
|
ciphertext: bytes,
|
||||||
|
) -> str:
|
||||||
|
return _digest(
|
||||||
|
{
|
||||||
|
"tenant_id": request.tenant_id,
|
||||||
|
"ballot_id": request.ballot_id,
|
||||||
|
"provider_ballot_ref": request.provider_ballot_ref,
|
||||||
|
"definition_sha256": request.definition_sha256,
|
||||||
|
"electorate_sha256": request.electorate_sha256,
|
||||||
|
"elector_id": request.elector_id,
|
||||||
|
"allow_replacement": request.allow_replacement,
|
||||||
|
"generation": generation,
|
||||||
|
"ciphertext_sha256": hashlib.sha256(ciphertext).hexdigest(),
|
||||||
|
"idempotency_key": request.idempotency_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_window(ballot: VotingConfidentialBallot) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if ballot.opens_at is not None and now < _aware(ballot.opens_at):
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider ballot has not opened yet."
|
||||||
|
)
|
||||||
|
if ballot.closes_at is not None and now >= _aware(ballot.closes_at):
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider ballot has reached its close time."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result_mapping(result: VotingResult) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ballot_id": result.ballot_id,
|
||||||
|
"revision": result.revision,
|
||||||
|
"counts": dict(result.counts),
|
||||||
|
"weighted_counts": dict(result.weighted_counts),
|
||||||
|
"cast_count": result.cast_count,
|
||||||
|
"cast_weight": result.cast_weight,
|
||||||
|
"eligible_count": result.eligible_count,
|
||||||
|
"eligible_weight": result.eligible_weight,
|
||||||
|
"quorum_met": result.quorum_met,
|
||||||
|
"threshold_met": result.threshold_met,
|
||||||
|
"winning_options": list(result.winning_options),
|
||||||
|
"result_sha256": result.result_sha256,
|
||||||
|
"evidence": [dict(item) for item in result.evidence],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _result_from_mapping(value: dict[str, Any]) -> VotingResult:
|
||||||
|
return VotingResult(
|
||||||
|
ballot_id=str(value["ballot_id"]),
|
||||||
|
revision=int(value.get("revision") or 0),
|
||||||
|
counts={str(key): int(item) for key, item in value["counts"].items()},
|
||||||
|
weighted_counts={
|
||||||
|
str(key): int(item) for key, item in value["weighted_counts"].items()
|
||||||
|
},
|
||||||
|
cast_count=int(value["cast_count"]),
|
||||||
|
cast_weight=int(value["cast_weight"]),
|
||||||
|
eligible_count=int(value["eligible_count"]),
|
||||||
|
eligible_weight=int(value["eligible_weight"]),
|
||||||
|
quorum_met=bool(value["quorum_met"]),
|
||||||
|
threshold_met=bool(value["threshold_met"]),
|
||||||
|
winning_options=tuple(str(item) for item in value["winning_options"]),
|
||||||
|
result_sha256=str(value["result_sha256"]),
|
||||||
|
evidence=tuple(dict(item) for item in value.get("evidence", ())),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_tenant(principal: object, tenant_id: str) -> None:
|
||||||
|
if str(getattr(principal, "tenant_id", "") or "").strip() != tenant_id:
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider principal belongs to another tenant."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_actor(principal: object) -> str | None:
|
||||||
|
for name in ("account_id", "user_id", "identity_id", "membership_id", "subject"):
|
||||||
|
value = str(getattr(principal, name, "") or "").strip()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not hasattr(value, "query"):
|
||||||
|
raise LocalConfidentialVotingError(
|
||||||
|
"The confidential provider requires a database session."
|
||||||
|
)
|
||||||
|
return value # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime) -> datetime:
|
||||||
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(value: object) -> str:
|
||||||
|
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LOCAL_CONFIDENTIAL_PROVIDER_ID",
|
||||||
|
"LocalConfidentialVotingError",
|
||||||
|
"LocalConfidentialVotingProvider",
|
||||||
|
]
|
||||||
@@ -6,6 +6,10 @@ from govoplan_core.core.access import (
|
|||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.encryption import (
|
||||||
|
CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
|
||||||
|
CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_guards import (
|
from govoplan_core.core.module_guards import (
|
||||||
drop_table_retirement_provider,
|
drop_table_retirement_provider,
|
||||||
persistent_table_uninstall_guard,
|
persistent_table_uninstall_guard,
|
||||||
@@ -19,6 +23,7 @@ from govoplan_core.core.modules import (
|
|||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
ModuleInterfaceProvider,
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
@@ -26,10 +31,17 @@ from govoplan_core.core.modules import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.core.voting import CAPABILITY_VOTING_BALLOTS
|
from govoplan_core.core.voting import (
|
||||||
|
CAPABILITY_VOTING_BALLOTS,
|
||||||
|
voting_provider_capability,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_voting.backend.db import models as voting_models
|
from govoplan_voting.backend.db import models as voting_models
|
||||||
from govoplan_voting.backend.service import SqlVotingBallots
|
from govoplan_voting.backend.service import SqlVotingBallots
|
||||||
|
from govoplan_voting.backend.local_confidential_provider import (
|
||||||
|
LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||||
|
LocalConfidentialVotingProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MODULE_ID = "voting"
|
MODULE_ID = "voting"
|
||||||
@@ -67,6 +79,12 @@ def _ballots(context: ModuleContext) -> SqlVotingBallots:
|
|||||||
return SqlVotingBallots(context.registry)
|
return SqlVotingBallots(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_confidential_provider(
|
||||||
|
context: ModuleContext,
|
||||||
|
) -> LocalConfidentialVotingProvider:
|
||||||
|
return LocalConfidentialVotingProvider(context.registry)
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
current = session.query(voting_models.VotingBallotRevision).filter(
|
current = session.query(voting_models.VotingBallotRevision).filter(
|
||||||
voting_models.VotingBallotRevision.tenant_id == tenant_id,
|
voting_models.VotingBallotRevision.tenant_id == tenant_id,
|
||||||
@@ -101,6 +119,24 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_VOTING_BALLOTS, version="0.1.0"),
|
ModuleInterfaceProvider(name=CAPABILITY_VOTING_BALLOTS, version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID),
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
permissions=(
|
permissions=(
|
||||||
_permission(
|
_permission(
|
||||||
@@ -210,7 +246,12 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
capability_factories={CAPABILITY_VOTING_BALLOTS: _ballots},
|
capability_factories={
|
||||||
|
CAPABILITY_VOTING_BALLOTS: _ballots,
|
||||||
|
voting_provider_capability(
|
||||||
|
LOCAL_CONFIDENTIAL_PROVIDER_ID
|
||||||
|
): _local_confidential_provider,
|
||||||
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation(
|
CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation(
|
||||||
label="Governed ballots",
|
label="Governed ballots",
|
||||||
@@ -226,6 +267,8 @@ manifest = ModuleManifest(
|
|||||||
retirement_provider=drop_table_retirement_provider(
|
retirement_provider=drop_table_retirement_provider(
|
||||||
voting_models.VotingCommandReplay,
|
voting_models.VotingCommandReplay,
|
||||||
voting_models.VotingLifecycleEvent,
|
voting_models.VotingLifecycleEvent,
|
||||||
|
voting_models.VotingConfidentialCast,
|
||||||
|
voting_models.VotingConfidentialBallot,
|
||||||
voting_models.VotingCastRecord,
|
voting_models.VotingCastRecord,
|
||||||
voting_models.VotingBallotRevision,
|
voting_models.VotingBallotRevision,
|
||||||
label=MODULE_NAME,
|
label=MODULE_NAME,
|
||||||
@@ -238,6 +281,8 @@ manifest = ModuleManifest(
|
|||||||
voting_models.VotingCastRecord,
|
voting_models.VotingCastRecord,
|
||||||
voting_models.VotingLifecycleEvent,
|
voting_models.VotingLifecycleEvent,
|
||||||
voting_models.VotingCommandReplay,
|
voting_models.VotingCommandReplay,
|
||||||
|
voting_models.VotingConfidentialBallot,
|
||||||
|
voting_models.VotingConfidentialCast,
|
||||||
label=MODULE_NAME,
|
label=MODULE_NAME,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -250,6 +295,7 @@ manifest = ModuleManifest(
|
|||||||
body=(
|
body=(
|
||||||
"Opening a ballot freezes its definition and electorate hashes. Native recorded ballots retain active vote records for reconstruction; they are not secret. "
|
"Opening a ballot freezes its definition and electorate hashes. Native recorded ballots retain active vote records for reconstruction; they are not secret. "
|
||||||
"Confidential, secret, and externally certified profiles require an installed provider and retain only aggregate results, receipts, hashes, and evidence. "
|
"Confidential, secret, and externally certified profiles require an installed provider and retain only aggregate results, receipts, hashes, and evidence. "
|
||||||
|
"The bundled local confidential provider encrypts raw selections through Encryption and supports interactive casting, but remains server-readable and uncertified. "
|
||||||
"Closure, certification, challenge, and annulment remain separate auditable transitions."
|
"Closure, certification, challenge, and annulment remain separate auditable transitions."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
@@ -273,6 +319,7 @@ manifest = ModuleManifest(
|
|||||||
known_limits=(
|
known_limits=(
|
||||||
"The native profile is recorded and reconstructable, not cryptographically secret.",
|
"The native profile is recorded and reconstructable, not cryptographically secret.",
|
||||||
"Confidential, secret, and externally certified profiles require an installed provider capability and fail closed otherwise.",
|
"Confidential, secret, and externally certified profiles require an installed provider capability and fail closed otherwise.",
|
||||||
|
"The bundled local confidential provider is server-decryptable and is neither anonymous, coercion-resistant, secret, nor externally certified.",
|
||||||
"Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.",
|
"Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.",
|
||||||
),
|
),
|
||||||
supported_authority_modes=("native_authoritative", "external_authoritative"),
|
supported_authority_modes=("native_authoritative", "external_authoritative"),
|
||||||
|
|||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
"""v0.1.15 local confidential Voting provider.
|
||||||
|
|
||||||
|
Revision ID: 8b9c0d1e2f3a
|
||||||
|
Revises: 7a8b9c0d1e2f
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "8b9c0d1e2f3a"
|
||||||
|
down_revision = "7a8b9c0d1e2f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"voting_confidential_ballots",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("provider_ballot_ref", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("ballot_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("definition_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("electorate_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("assurance_profile", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("method", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("state", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("options", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("electorate", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allow_replacement", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("quorum_weight", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("threshold_numerator", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("threshold_denominator", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("opens_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("closes_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("vault_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("preparation_idempotency_key", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("preparation_request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("prepared_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("finalization_idempotency_key", sa.String(length=160), nullable=True),
|
||||||
|
sa.Column("finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("result", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_confidential_ballots")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"provider_ballot_ref",
|
||||||
|
name="uq_voting_confidential_provider_ref",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"ballot_id",
|
||||||
|
name="uq_voting_confidential_ballot",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in ("tenant_id", "provider_ballot_ref", "ballot_id", "state"):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_voting_confidential_ballots_{column}"),
|
||||||
|
"voting_confidential_ballots",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_voting_confidential_ballot_state",
|
||||||
|
"voting_confidential_ballots",
|
||||||
|
["tenant_id", "state", "prepared_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"voting_confidential_casts",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("provider_ballot_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("elector_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("generation", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("definition_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("ciphertext", sa.LargeBinary(), nullable=False),
|
||||||
|
sa.Column("encryption_envelope_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("encryption_resource_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("weight", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("cast_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("receipt_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["provider_ballot_id"],
|
||||||
|
["voting_confidential_ballots.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_voting_confidential_casts_provider_ballot_id_voting_confidential_ballots"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_voting_confidential_casts")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"provider_ballot_id",
|
||||||
|
"elector_id",
|
||||||
|
"generation",
|
||||||
|
name="uq_voting_confidential_cast_generation",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"provider_ballot_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_voting_confidential_cast_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"provider_ballot_id",
|
||||||
|
"elector_id",
|
||||||
|
"encryption_envelope_id",
|
||||||
|
"encryption_resource_id",
|
||||||
|
"cast_at",
|
||||||
|
"superseded_at",
|
||||||
|
"receipt_sha256",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_voting_confidential_casts_{column}"),
|
||||||
|
"voting_confidential_casts",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_voting_confidential_cast_active",
|
||||||
|
"voting_confidential_casts",
|
||||||
|
["tenant_id", "provider_ballot_id", "elector_id", "superseded_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("voting_confidential_casts")
|
||||||
|
op.drop_table("voting_confidential_ballots")
|
||||||
@@ -12,8 +12,11 @@ from sqlalchemy import func
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.voting import (
|
from govoplan_core.core.voting import (
|
||||||
|
ExternalVotingCastRequest,
|
||||||
ExternalVotingFinalizationRequest,
|
ExternalVotingFinalizationRequest,
|
||||||
|
ExternalVotingPreparationRequest,
|
||||||
ExternalVotingProvider,
|
ExternalVotingProvider,
|
||||||
|
InteractiveExternalVotingProvider,
|
||||||
VOTING_ASSURANCE_RECORDED,
|
VOTING_ASSURANCE_RECORDED,
|
||||||
VotingBallotCreateCommand,
|
VotingBallotCreateCommand,
|
||||||
VotingBallotRef,
|
VotingBallotRef,
|
||||||
@@ -250,19 +253,81 @@ class SqlVotingBallots:
|
|||||||
payload = dict(current.payload)
|
payload = dict(current.payload)
|
||||||
_validate_draft(payload)
|
_validate_draft(payload)
|
||||||
profile = str(payload["assurance_profile"])
|
profile = str(payload["assurance_profile"])
|
||||||
|
provider: object | None = None
|
||||||
if profile != VOTING_ASSURANCE_RECORDED:
|
if profile != VOTING_ASSURANCE_RECORDED:
|
||||||
provider_id = str(payload.get("provider_id") or "").strip()
|
provider_id = str(payload.get("provider_id") or "").strip()
|
||||||
if (
|
provider = (
|
||||||
not provider_id
|
_capability(self._registry, voting_provider_capability(provider_id))
|
||||||
or _capability(self._registry, voting_provider_capability(provider_id))
|
if provider_id
|
||||||
is None
|
else None
|
||||||
):
|
)
|
||||||
|
if not isinstance(provider, ExternalVotingProvider):
|
||||||
raise VotingStoreError(
|
raise VotingStoreError(
|
||||||
"The selected Voting assurance profile requires an available external provider."
|
"The selected Voting assurance profile requires an available external provider."
|
||||||
)
|
)
|
||||||
definition_hash, electorate_hash = _frozen_hashes(payload)
|
definition_hash, electorate_hash = _frozen_hashes(payload)
|
||||||
payload["definition_sha256"] = definition_hash
|
payload["definition_sha256"] = definition_hash
|
||||||
payload["electorate_sha256"] = electorate_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(
|
revised = _revise(
|
||||||
typed_session,
|
typed_session,
|
||||||
current=current,
|
current=current,
|
||||||
@@ -276,6 +341,9 @@ class SqlVotingBallots:
|
|||||||
"revision": current.revision + 1,
|
"revision": current.revision + 1,
|
||||||
"definition_sha256": definition_hash,
|
"definition_sha256": definition_hash,
|
||||||
"electorate_sha256": electorate_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)
|
result = _ref(revised)
|
||||||
@@ -301,10 +369,6 @@ class SqlVotingBallots:
|
|||||||
current = _current(typed_session, principal, ballot_id=ballot_id, lock=True)
|
current = _current(typed_session, principal, ballot_id=ballot_id, lock=True)
|
||||||
if current.state != "open":
|
if current.state != "open":
|
||||||
raise VotingStoreError("Only an open Voting ballot accepts votes.")
|
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)
|
_validate_window(current.payload)
|
||||||
actor_id = _principal_actor(principal)
|
actor_id = _principal_actor(principal)
|
||||||
elector_id = str(command.elector_id or actor_id or "").strip()
|
elector_id = str(command.elector_id or actor_id or "").strip()
|
||||||
@@ -328,6 +392,63 @@ class SqlVotingBallots:
|
|||||||
)
|
)
|
||||||
_validate_selections(current.payload, selections)
|
_validate_selections(current.payload, selections)
|
||||||
idempotency_key = _idempotency(command.idempotency_key)
|
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 = (
|
replay = (
|
||||||
typed_session.query(VotingCastRecord)
|
typed_session.query(VotingCastRecord)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -743,6 +864,7 @@ class SqlVotingBallots:
|
|||||||
if not isinstance(provider, ExternalVotingProvider):
|
if not isinstance(provider, ExternalVotingProvider):
|
||||||
raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.")
|
raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.")
|
||||||
electorate = list(current.payload["electorate"])
|
electorate = list(current.payload["electorate"])
|
||||||
|
try:
|
||||||
result = provider.finalize_ballot(
|
result = provider.finalize_ballot(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
@@ -753,7 +875,8 @@ class SqlVotingBallots:
|
|||||||
definition_sha256=str(current.definition_sha256),
|
definition_sha256=str(current.definition_sha256),
|
||||||
electorate_sha256=str(current.electorate_sha256),
|
electorate_sha256=str(current.electorate_sha256),
|
||||||
options=tuple(
|
options=tuple(
|
||||||
_option_from_mapping(item) for item in current.payload["options"]
|
_option_from_mapping(item)
|
||||||
|
for item in current.payload["options"]
|
||||||
),
|
),
|
||||||
eligible_count=len(electorate),
|
eligible_count=len(electorate),
|
||||||
eligible_weight=sum(
|
eligible_weight=sum(
|
||||||
@@ -763,6 +886,10 @@ class SqlVotingBallots:
|
|||||||
idempotency_key=_idempotency(idempotency_key),
|
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"]}
|
option_keys = {str(item["key"]) for item in current.payload["options"]}
|
||||||
if result.ballot_id != current.ballot_id:
|
if result.ballot_id != current.ballot_id:
|
||||||
raise VotingStoreError(
|
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:
|
if opens_at and closes_at and closes_at <= opens_at:
|
||||||
raise VotingStoreError("Voting close time must be after its open time.")
|
raise VotingStoreError("Voting close time must be after its open time.")
|
||||||
if assurance != VOTING_ASSURANCE_RECORDED:
|
if assurance != VOTING_ASSURANCE_RECORDED:
|
||||||
if (
|
if not str(payload.get("provider_id") or "").strip():
|
||||||
not str(payload.get("provider_id") or "").strip()
|
raise VotingStoreError("External Voting assurance requires a provider id.")
|
||||||
or not str(payload.get("provider_ballot_ref") or "").strip()
|
|
||||||
):
|
|
||||||
raise VotingStoreError(
|
|
||||||
"External Voting assurance requires provider id and ballot reference."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_selections(
|
def _validate_selections(
|
||||||
@@ -919,6 +1041,7 @@ def _frozen_hashes(payload: Mapping[str, Any]) -> tuple[str, str]:
|
|||||||
"definition_sha256",
|
"definition_sha256",
|
||||||
"result",
|
"result",
|
||||||
"certification",
|
"certification",
|
||||||
|
"provider_ballot_ref",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return _sha256(definition), _sha256(electorate)
|
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:
|
def _sha256(value: object) -> str:
|
||||||
encoded = json.dumps(
|
encoded = json.dumps(
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ class VotingMigrationTests(unittest.TestCase):
|
|||||||
"voting_cast_records",
|
"voting_cast_records",
|
||||||
"voting_lifecycle_events",
|
"voting_lifecycle_events",
|
||||||
"voting_command_replays",
|
"voting_command_replays",
|
||||||
|
"voting_confidential_ballots",
|
||||||
|
"voting_confidential_casts",
|
||||||
}.issubset(tables)
|
}.issubset(tables)
|
||||||
)
|
)
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"7a8b9c0d1e2f",
|
"8b9c0d1e2f3a",
|
||||||
set(MigrationContext.configure(connection).get_current_heads()),
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+192
-1
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -11,9 +14,23 @@ from govoplan_core.core.voting import (
|
|||||||
VotingCastCommand,
|
VotingCastCommand,
|
||||||
VotingElector,
|
VotingElector,
|
||||||
VotingOption,
|
VotingOption,
|
||||||
|
voting_provider_capability,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.encryption import (
|
||||||
|
CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
|
||||||
|
CAPABILITY_ENCRYPTION_KEY_VAULT,
|
||||||
|
ContentProtectionEnvelope,
|
||||||
|
ProtectedContent,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_voting.backend.db.models import VotingCastRecord
|
from govoplan_voting.backend.db.models import (
|
||||||
|
VotingCastRecord,
|
||||||
|
VotingConfidentialCast,
|
||||||
|
)
|
||||||
|
from govoplan_voting.backend.local_confidential_provider import (
|
||||||
|
LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||||
|
LocalConfidentialVotingProvider,
|
||||||
|
)
|
||||||
from govoplan_voting.backend.service import SqlVotingBallots, VotingStoreError
|
from govoplan_voting.backend.service import SqlVotingBallots, VotingStoreError
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +40,87 @@ class Principal:
|
|||||||
account_id: str
|
account_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.capabilities: dict[str, object] = {}
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name in self.capabilities
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object | None:
|
||||||
|
return self.capabilities.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeKeyVault:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.vaults: dict[tuple[str, str], object] = {}
|
||||||
|
|
||||||
|
def create_vault(self, session, principal, *, request):
|
||||||
|
del session, principal
|
||||||
|
vault = SimpleNamespace(state="active", current_key=SimpleNamespace(version=1))
|
||||||
|
self.vaults[(request.tenant_id, request.vault_id)] = vault
|
||||||
|
return vault
|
||||||
|
|
||||||
|
def get_vault(self, session, *, tenant_id, vault_id):
|
||||||
|
del session
|
||||||
|
return self.vaults.get((tenant_id, vault_id))
|
||||||
|
|
||||||
|
def rotate_key(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def revoke_key(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def schedule_destruction(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def reconcile_vault(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class FakeContentCipher:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.plaintexts: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
def protect_content(self, session, *, request):
|
||||||
|
del session
|
||||||
|
envelope_id = f"envelope-{request.resource_id}"
|
||||||
|
ciphertext = b"ciphertext:" + hashlib.sha256(request.plaintext).digest()
|
||||||
|
self.plaintexts[envelope_id] = request.plaintext
|
||||||
|
return ProtectedContent(
|
||||||
|
envelope=ContentProtectionEnvelope(
|
||||||
|
envelope_id=envelope_id,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
owner_module=request.owner_module,
|
||||||
|
resource_type=request.resource_type,
|
||||||
|
resource_id=request.resource_id,
|
||||||
|
profile_kind="server_envelope",
|
||||||
|
profile_id=request.profile_id,
|
||||||
|
provider_id="fake",
|
||||||
|
vault_id=request.vault_id,
|
||||||
|
key_version=1,
|
||||||
|
algorithm_suite="AES-256-GCM",
|
||||||
|
ciphertext_ref=request.ciphertext_ref,
|
||||||
|
ciphertext_digest=hashlib.sha256(ciphertext).hexdigest(),
|
||||||
|
authenticated_context_digest="a" * 64,
|
||||||
|
state="active",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
wrapped_key_refs=(f"wrapped-{request.resource_id}",),
|
||||||
|
),
|
||||||
|
ciphertext=ciphertext,
|
||||||
|
)
|
||||||
|
|
||||||
|
def unprotect_content(self, session, *, request):
|
||||||
|
del session
|
||||||
|
return self.plaintexts[request.envelope_id]
|
||||||
|
|
||||||
|
def execute_rewrap(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def prepare_reencryption(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
def command(*, assurance: str = "recorded", provider_id: str | None = None):
|
def command(*, assurance: str = "recorded", provider_id: str | None = None):
|
||||||
return VotingBallotCreateCommand(
|
return VotingBallotCreateCommand(
|
||||||
title="Budget choice",
|
title="Budget choice",
|
||||||
@@ -192,6 +290,99 @@ class VotingTests(unittest.TestCase):
|
|||||||
idempotency_key="open-secret",
|
idempotency_key="open-secret",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_local_confidential_provider_encrypts_casts_and_returns_aggregates(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
registry = FakeRegistry()
|
||||||
|
provider = LocalConfidentialVotingProvider(registry)
|
||||||
|
registry.capabilities.update(
|
||||||
|
{
|
||||||
|
voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID): provider,
|
||||||
|
CAPABILITY_ENCRYPTION_KEY_VAULT: FakeKeyVault(),
|
||||||
|
CAPABILITY_ENCRYPTION_CONTENT_CIPHER: FakeContentCipher(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service = SqlVotingBallots(registry)
|
||||||
|
draft = replace(
|
||||||
|
command(
|
||||||
|
assurance="confidential",
|
||||||
|
provider_id=LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||||
|
),
|
||||||
|
provider_ballot_ref=None,
|
||||||
|
)
|
||||||
|
with self.Session() as session:
|
||||||
|
created = service.create_ballot(
|
||||||
|
session,
|
||||||
|
self.manager,
|
||||||
|
command=draft,
|
||||||
|
idempotency_key="create-confidential",
|
||||||
|
)
|
||||||
|
opened = service.open_ballot(
|
||||||
|
session,
|
||||||
|
self.manager,
|
||||||
|
ballot_id=created.id,
|
||||||
|
expected_revision=created.revision,
|
||||||
|
idempotency_key="open-confidential",
|
||||||
|
)
|
||||||
|
detail = service.get_ballot(
|
||||||
|
session,
|
||||||
|
self.manager,
|
||||||
|
ballot_id=opened.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
f"local-confidential:{opened.id}",
|
||||||
|
detail["provider_ballot_ref"],
|
||||||
|
)
|
||||||
|
first = service.cast_ballot(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "alice"),
|
||||||
|
ballot_id=opened.id,
|
||||||
|
command=VotingCastCommand(("a",), "cast-confidential-alice"),
|
||||||
|
)
|
||||||
|
replay = service.cast_ballot(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "alice"),
|
||||||
|
ballot_id=opened.id,
|
||||||
|
command=VotingCastCommand(("a",), "cast-confidential-alice"),
|
||||||
|
)
|
||||||
|
self.assertEqual(first.receipt_sha256, replay.receipt_sha256)
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
VotingStoreError,
|
||||||
|
"provider cast was rejected",
|
||||||
|
):
|
||||||
|
service.cast_ballot(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "alice"),
|
||||||
|
ballot_id=opened.id,
|
||||||
|
command=VotingCastCommand(
|
||||||
|
("b",),
|
||||||
|
"cast-confidential-alice",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
service.cast_ballot(
|
||||||
|
session,
|
||||||
|
Principal("tenant-1", "bob"),
|
||||||
|
ballot_id=opened.id,
|
||||||
|
command=VotingCastCommand(("b",), "cast-confidential-bob"),
|
||||||
|
)
|
||||||
|
self.assertEqual(0, session.query(VotingCastRecord).count())
|
||||||
|
encrypted = session.query(VotingConfidentialCast).all()
|
||||||
|
self.assertEqual(2, len(encrypted))
|
||||||
|
self.assertTrue(all(row.ciphertext for row in encrypted))
|
||||||
|
|
||||||
|
result = service.close_ballot(
|
||||||
|
session,
|
||||||
|
self.manager,
|
||||||
|
ballot_id=opened.id,
|
||||||
|
expected_revision=opened.revision,
|
||||||
|
idempotency_key="close-confidential",
|
||||||
|
)
|
||||||
|
self.assertEqual({"a": 1, "b": 1}, dict(result.counts))
|
||||||
|
self.assertEqual({"a": 2, "b": 1}, dict(result.weighted_counts))
|
||||||
|
self.assertEqual("local_confidential", result.evidence[0]["provider_id"])
|
||||||
|
self.assertFalse(result.evidence[0]["certified"])
|
||||||
|
|
||||||
def test_idempotency_key_cannot_change_command(self) -> None:
|
def test_idempotency_key_cannot_change_command(self) -> None:
|
||||||
with self.Session() as session:
|
with self.Session() as session:
|
||||||
self.service.create_ballot(
|
self.service.create_ballot(
|
||||||
|
|||||||
@@ -49,7 +49,16 @@ export default function VotingBallotDialog({
|
|||||||
&& draft.options.every((item) => item.key.trim() && item.label.trim())
|
&& draft.options.every((item) => item.key.trim() && item.label.trim())
|
||||||
&& draft.electorate.length > 0
|
&& draft.electorate.length > 0
|
||||||
&& draft.electorate.every((item) => item.subject_id.trim() && item.weight > 0)
|
&& draft.electorate.every((item) => item.subject_id.trim() && item.weight > 0)
|
||||||
&& (draft.assurance_profile === "recorded" || (draft.provider_id?.trim() && draft.provider_ballot_ref?.trim()))
|
&& (
|
||||||
|
draft.assurance_profile === "recorded"
|
||||||
|
|| (
|
||||||
|
draft.provider_id?.trim()
|
||||||
|
&& (
|
||||||
|
draft.provider_id.trim() === "local_confidential"
|
||||||
|
|| draft.provider_ballot_ref?.trim()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
), [draft]);
|
), [draft]);
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -104,7 +113,21 @@ export default function VotingBallotDialog({
|
|||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Description" className="voting-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="Description" className="voting-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
<FormField label="Assurance profile">
|
<FormField label="Assurance profile">
|
||||||
<select value={draft.assurance_profile} disabled={busy} onChange={(event) => setDraft({ ...draft, assurance_profile: event.target.value as VotingBallotDraft["assurance_profile"] })}>
|
<select value={draft.assurance_profile} disabled={busy} onChange={(event) => {
|
||||||
|
const assurance_profile = event.target.value as VotingBallotDraft["assurance_profile"];
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
assurance_profile,
|
||||||
|
provider_id: assurance_profile === "recorded"
|
||||||
|
? null
|
||||||
|
: assurance_profile === "confidential"
|
||||||
|
? "local_confidential"
|
||||||
|
: draft.provider_id === "local_confidential" ? "" : draft.provider_id,
|
||||||
|
provider_ballot_ref: assurance_profile === "recorded" || assurance_profile === "confidential"
|
||||||
|
? null
|
||||||
|
: draft.provider_ballot_ref
|
||||||
|
});
|
||||||
|
}}>
|
||||||
<option value="recorded">Recorded</option>
|
<option value="recorded">Recorded</option>
|
||||||
<option value="confidential">Confidential provider</option>
|
<option value="confidential">Confidential provider</option>
|
||||||
<option value="secret">Secret provider</option>
|
<option value="secret">Secret provider</option>
|
||||||
@@ -117,7 +140,14 @@ export default function VotingBallotDialog({
|
|||||||
<div className="voting-editor-toggle"><ToggleSwitch label="Allow vote replacement" checked={draft.allow_replacement} disabled={busy} onChange={(allow_replacement) => setDraft({ ...draft, allow_replacement })} /></div>
|
<div className="voting-editor-toggle"><ToggleSwitch label="Allow vote replacement" checked={draft.allow_replacement} disabled={busy} onChange={(allow_replacement) => setDraft({ ...draft, allow_replacement })} /></div>
|
||||||
{draft.assurance_profile !== "recorded" && <>
|
{draft.assurance_profile !== "recorded" && <>
|
||||||
<FormField label="Provider id"><input value={draft.provider_id ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_id: event.target.value })} /></FormField>
|
<FormField label="Provider id"><input value={draft.provider_id ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_id: event.target.value })} /></FormField>
|
||||||
<FormField label="Provider ballot reference"><input value={draft.provider_ballot_ref ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, provider_ballot_ref: event.target.value })} /></FormField>
|
<FormField label="Provider ballot reference">
|
||||||
|
<input
|
||||||
|
value={draft.provider_ballot_ref ?? ""}
|
||||||
|
placeholder={draft.provider_id === "local_confidential" ? "Generated when opened" : undefined}
|
||||||
|
disabled={busy || draft.provider_id === "local_confidential"}
|
||||||
|
onChange={(event) => setDraft({ ...draft, provider_ballot_ref: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user