Compare commits
15
Commits
ee19343697
...
v0.1.20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00650aee81 | ||
|
|
64ff8da814 | ||
|
|
7e03fe62ac | ||
|
|
13bdeccdab | ||
|
|
89cc5f0189 | ||
|
|
dc9fdc9143 | ||
|
|
f0f0286866 | ||
|
|
e3c0db76c9 | ||
|
|
5608022b3f | ||
|
|
d8224e4676 | ||
|
|
bf248d172d | ||
|
|
98a73622ba | ||
|
|
befe8aef82 | ||
|
|
8cfd6bfd48 | ||
|
|
0168c5ecd5 |
@@ -14,6 +14,8 @@ on:
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
@@ -29,7 +31,6 @@ jobs:
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
@@ -43,24 +44,6 @@ jobs:
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
python - "$tag" <<'PY'
|
||||
import fnmatch
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
tag = sys.argv[1]
|
||||
repository = os.environ["GITEA_REPOSITORY"]
|
||||
request = urllib.request.Request(
|
||||
f"{os.environ['GITEA_API_URL']}/repos/{repository}/tag_protections",
|
||||
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
protections = json.load(response)
|
||||
if not any(fnmatch.fnmatchcase(tag, item.get("name_pattern", "")) for item in protections):
|
||||
raise SystemExit(f"Release tag {tag!r} is not covered by repository tag protection")
|
||||
PY
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
@@ -130,7 +113,7 @@ jobs:
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/GovOPlaN/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
@@ -180,6 +163,78 @@ jobs:
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
@@ -189,13 +244,17 @@ jobs:
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )); then
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
@@ -203,7 +262,9 @@ jobs:
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "${webui_packages[0]}" \
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
|
||||
@@ -20,3 +20,9 @@ preference and availability collection remains in `govoplan-poll`.
|
||||
|
||||
See [the domain and assurance boundary](docs/VOTING_DOMAIN.md) for operations,
|
||||
security, recovery, and integration details.
|
||||
|
||||
POLYAS is the first planned external provider; its bounded operator-assisted
|
||||
profile and integration prerequisites are documented in
|
||||
[the POLYAS provider profile](docs/POLYAS_PROVIDER_PROFILE.md). Development of
|
||||
a native certifiable provider follows the staged, independently evaluated
|
||||
[certifiable Voting program](docs/CERTIFIABLE_VOTING_PROGRAM.md).
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Native certifiable Voting program
|
||||
|
||||
## Objective and non-claim
|
||||
|
||||
GovOPlaN may develop a native end-to-end verifiable Voting provider, but the
|
||||
current platform and bundled `local_confidential` provider are not certified
|
||||
voting products. Certification cannot be obtained by adding a label, tests, or
|
||||
general platform security controls. It applies to a precisely bounded Target
|
||||
of Evaluation (TOE), version, evaluated configuration, lifecycle, and evidence
|
||||
set assessed by an independent laboratory and certification authority.
|
||||
|
||||
The native provider must therefore be an isolated assurance component behind
|
||||
`voting.provider.<id>`, not an implicit claim over all of GovOPlaN. Voting owns
|
||||
the governed ballot lifecycle and evidence projection; the TOE owns ballot
|
||||
secrecy, cryptographic casting, verification, tallying, and the evaluated
|
||||
ceremony. Policy, Access, Identity Trust, Encryption, Forms Runtime, Workflow
|
||||
Engine, Committee, Decisions, Audit, Records, and Reporting may support the
|
||||
journey without being silently pulled into the TOE.
|
||||
|
||||
## Program stages
|
||||
|
||||
### 1. Protection profile and legal target
|
||||
|
||||
- identify election classes, jurisdictions, attack potential, voting
|
||||
principles, accessibility duties, and retention obligations;
|
||||
- select the applicable BSI Protection Profile/TR and Common Criteria target;
|
||||
- engage a recognized evaluation facility before fixing the architecture;
|
||||
- write the Security Target, assumptions, threats, organizational policies,
|
||||
security objectives, and evaluated configuration.
|
||||
|
||||
### 2. TOE and trust boundaries
|
||||
|
||||
- specify client, election server, bulletin board, verifier, tally component,
|
||||
key ceremony, build/release chain, time source, and operator boundaries;
|
||||
- define electorate preparation and archival as explicit supporting processes
|
||||
when they are outside the TOE;
|
||||
- prohibit node-local authoritative state and undeclared side channels;
|
||||
- define compromise, suspension, challenge, annulment, recovery, and evidence
|
||||
export before implementation.
|
||||
|
||||
### 3. Protocol and independent review
|
||||
|
||||
- select a published, independently reviewed end-to-end verifiable protocol;
|
||||
- use reviewed cryptographic libraries and parameter suites rather than
|
||||
designing new cryptography;
|
||||
- provide individual and universal verification without exposing vote choice;
|
||||
- define coercion-resistance claims truthfully, including what is not solved;
|
||||
- commission independent cryptographic and privacy review before production.
|
||||
|
||||
### 4. Conformance implementation
|
||||
|
||||
- implement canonical ballot/electorate/result/evidence encodings;
|
||||
- bind every cast and tally artifact to the frozen definition and electorate;
|
||||
- provide deterministic conformance fixtures, malformed-input suites,
|
||||
property tests, fault injection, and cross-implementation verification;
|
||||
- preserve receipt privacy and prevent credentials, raw votes, or private keys
|
||||
from entering GovOPlaN evidence projections;
|
||||
- expose certification state through `VotingProviderAssuranceDeclaration`.
|
||||
|
||||
### 5. Controlled lifecycle
|
||||
|
||||
- reproducible, signed builds and reviewed dependencies;
|
||||
- role-separated source, release, election, key-custody, and audit authority;
|
||||
- vulnerability handling, maintenance impact analysis, SBOM, provenance, and
|
||||
controlled update path for in-progress elections;
|
||||
- production ceremonies, backup/restore, disaster recovery, secure deletion,
|
||||
monitoring, incident response, and independently witnessed evidence.
|
||||
|
||||
### 6. Evaluation and operation
|
||||
|
||||
- laboratory pre-evaluation and gap remediation;
|
||||
- formal Common Criteria evaluation/certification of an exact TOE version;
|
||||
- target-specific deployment acceptance against the evaluated configuration;
|
||||
- certificate and maintenance-report monitoring;
|
||||
- fail-closed retirement or profile downgrade when validity expires or the
|
||||
evaluated configuration changes.
|
||||
|
||||
## Work-product gates
|
||||
|
||||
Native implementation can proceed through fixtures and research profiles, but
|
||||
the `external_certified` runtime profile remains unavailable until all of these
|
||||
are independently evidenced:
|
||||
|
||||
- approved Security Target and TOE boundary;
|
||||
- independent protocol/cryptographic review;
|
||||
- conformance and adverse-condition evidence;
|
||||
- controlled build and release provenance;
|
||||
- operational ceremony and recovery evidence;
|
||||
- valid product/version/configuration-specific certificate.
|
||||
|
||||
Research, evaluation, and certified states are separate. A provider in
|
||||
evaluation may support a bounded test profile, but cannot become certified by
|
||||
configuration or administrator override.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# POLYAS provider profile
|
||||
|
||||
## Current integration position
|
||||
|
||||
POLYAS is the first external provider selected for high-assurance GovOPlaN
|
||||
Voting. This is an integration decision, not a certification claim. Until a
|
||||
contracted machine interface, sandbox, exact product/version binding, and
|
||||
current certificate evidence are available, the integration remains
|
||||
operator-assisted and must not advertise the `external_certified` assurance
|
||||
profile.
|
||||
|
||||
The public POLYAS material documents the Online Voting Manager, spreadsheet
|
||||
electoral-roll import, PDF/Excel result export, an election control portal,
|
||||
verification tools, SecureLink, and an electoral-board interface. It does not
|
||||
document a stable public API that is sufficient for an unattended GovOPlaN
|
||||
provider. The initial integration therefore uses the existing external
|
||||
provider contract as its target and keeps manual handoffs explicit:
|
||||
|
||||
1. GovOPlaN freezes the ballot definition and electorate hashes.
|
||||
2. An authorized election officer creates and seals the corresponding POLYAS
|
||||
election using a reviewed export.
|
||||
3. GovOPlaN records the POLYAS project reference, exact product/profile, and
|
||||
handoff evidence without storing voter credentials.
|
||||
4. Voters enter the provider through its controlled launch or invitation path.
|
||||
5. An authorized officer imports signed result and protocol artifacts.
|
||||
6. GovOPlaN verifies the frozen binding, records aggregate results and evidence,
|
||||
and retains certification, challenge, and annulment as separate actions.
|
||||
|
||||
Operator-assisted imports must be labelled as such. Browser automation or
|
||||
screen scraping is not an acceptable production API.
|
||||
|
||||
## Provider information required
|
||||
|
||||
Before implementing unattended preparation, launch, status, or result
|
||||
acquisition, obtain from POLYAS:
|
||||
|
||||
- the contracted API/protocol specification and versioning policy;
|
||||
- sandbox credentials and representative test-election fixtures;
|
||||
- supported ballot methods, weighting, voter groups, replacement, quorum, and
|
||||
threshold semantics;
|
||||
- idempotency, revision, sealing, cancellation, outcome-unknown, and retry
|
||||
behavior;
|
||||
- invitation and voter-authentication boundaries;
|
||||
- signed result, archive, audit, and verification artifact formats;
|
||||
- retention, deletion, subprocessor, location, incident, and DPA terms;
|
||||
- product/version-specific Security Target, certificate, maintenance reports,
|
||||
validity period, and evaluated configuration;
|
||||
- recovery and continuity evidence for an election in progress.
|
||||
|
||||
Credentials belong in governed credential envelopes. Raw selections, voter
|
||||
credentials, recovery codes, and private provider keys must never cross the
|
||||
Voting provider boundary.
|
||||
|
||||
## Certification gate
|
||||
|
||||
The BSI certificate `BSI-DSZ-CC-0862-V2-2021` for POLYAS CORE 2.5.0, including
|
||||
maintained versions described by its maintenance reports, was valid through
|
||||
2026-06-24. As of 2026-08-04, that validity date has passed. A new election must
|
||||
not be labelled `external_certified` from this historical certificate alone.
|
||||
|
||||
The adapter must expose a `VotingProviderAssuranceDeclaration`. The runtime
|
||||
accepts `external_certified` only when the declaration pins:
|
||||
|
||||
- the exact provider and implementation contract;
|
||||
- supported assurance profile and protocol version;
|
||||
- certification authority and reference;
|
||||
- an independently retrievable evidence reference;
|
||||
- a current validity window.
|
||||
|
||||
The declaration is frozen with the ballot and revalidated before cast and
|
||||
finalization. Expiry, revocation, provider replacement, protocol change, or
|
||||
certificate substitution fails closed and requires explicit reconciliation.
|
||||
|
||||
Authoritative references:
|
||||
|
||||
- [BSI certificate record](https://www.bsi.bund.de/SharedDocs/Zertifikate_CC/CC/Sonstiges/0862_0862V2.html)
|
||||
- [BSI TR-03169](https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03169/BSI-TR-03169.pdf)
|
||||
- [POLYAS security overview](https://support.polyas.com/en/faqs/security/ensure-secure-voting/)
|
||||
- [POLYAS election control portal](https://support.polyas.com/en/online-voting-manager/features/authentication/election-control-portal/)
|
||||
|
||||
@@ -32,6 +32,12 @@ credentials inside its own assurance boundary and returns aggregate counts,
|
||||
weighted counts, a result hash, and evidence. GovOPlaN does not claim that a
|
||||
provider or deployment satisfies legal or certification requirements merely
|
||||
because the adapter contract is implemented.
|
||||
Each provider declares supported assurance profiles, protocol and
|
||||
implementation identity, and certification state. Voting pins that declaration
|
||||
when opening and revalidates it before provider casting and finalization.
|
||||
`external_certified` requires a current authority, certificate reference,
|
||||
evidence reference, and validity window; a changed, expired, or revoked claim
|
||||
fails closed.
|
||||
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.
|
||||
@@ -57,6 +63,14 @@ external certification. It therefore cannot be selected for `secret` or
|
||||
the ballot opens; externally hosted providers may continue to require a
|
||||
pre-existing reference.
|
||||
|
||||
POLYAS is the selected first external provider, initially through an explicit
|
||||
operator-assisted handoff until a contracted API and sandbox are available.
|
||||
The historical POLYAS CORE 2.5 Common Criteria certificate expired on
|
||||
2026-06-24, so its reference alone cannot enable `external_certified`. See
|
||||
[the POLYAS provider profile](POLYAS_PROVIDER_PROFILE.md). Native certifiable
|
||||
development is governed by the separate
|
||||
[certifiable Voting program](CERTIFIABLE_VOTING_PROGRAM.md).
|
||||
|
||||
## Lifecycle and concurrency
|
||||
|
||||
Ballots move through `draft -> open -> closed -> certified`. A closed or
|
||||
@@ -109,4 +123,7 @@ node-local filesystem.
|
||||
- raw selections are never returned by list, detail, result, or history APIs
|
||||
- provider result keys must exactly match frozen options
|
||||
- external results require evidence and cannot exceed the frozen electorate
|
||||
- external providers must match the assurance declaration frozen at opening
|
||||
- externally certified providers must remain currently certified through
|
||||
provider casting and finalization
|
||||
- certification and annulment use separate permissions
|
||||
|
||||
+2
-2
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-voting"
|
||||
version = "0.1.14"
|
||||
version = "0.1.20"
|
||||
description = "Governed voting, ballot assurance, tally, and certification for GovOPlaN."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = ["govoplan-core>=0.1.14", "govoplan-access>=0.1.8"]
|
||||
dependencies = ["govoplan-core>=0.1.18", "govoplan-access>=0.1.18"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN Voting module."""
|
||||
|
||||
__version__ = "0.1.14"
|
||||
__version__ = "0.1.20"
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_voting.backend.db.models import (
|
||||
VotingBallotRevision,
|
||||
VotingCastRecord,
|
||||
VotingConfidentialBallot,
|
||||
VotingConfidentialCast,
|
||||
VotingLifecycleEvent,
|
||||
)
|
||||
|
||||
|
||||
VOTING_DSAR_CAPABILITY = dsar_capability_name("voting")
|
||||
_MAX_RECORDS = 5_000
|
||||
_MAX_SELECTIONS = 1_000
|
||||
_CONFLICT = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SubjectSelectors:
|
||||
elector_ids: tuple[str, ...]
|
||||
actor_ids: tuple[str, ...]
|
||||
ballot_id: str | None
|
||||
|
||||
|
||||
class VotingDsarProvider:
|
||||
provider_id = "voting"
|
||||
module_id = "voting"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
return ()
|
||||
records: list[DsarRecordRef] = []
|
||||
|
||||
personal_casts = db.query(VotingCastRecord).filter(
|
||||
VotingCastRecord.tenant_id == tenant_id,
|
||||
VotingCastRecord.elector_id.in_(selectors.elector_ids),
|
||||
)
|
||||
actor_casts = db.query(VotingCastRecord).filter(
|
||||
VotingCastRecord.tenant_id == tenant_id,
|
||||
VotingCastRecord.actor_id.in_(selectors.actor_ids),
|
||||
~VotingCastRecord.elector_id.in_(selectors.elector_ids),
|
||||
)
|
||||
ballots = db.query(VotingBallotRevision).filter(
|
||||
VotingBallotRevision.tenant_id == tenant_id,
|
||||
VotingBallotRevision.created_by.in_(selectors.actor_ids),
|
||||
)
|
||||
events = db.query(VotingLifecycleEvent).filter(
|
||||
VotingLifecycleEvent.tenant_id == tenant_id,
|
||||
VotingLifecycleEvent.actor_id.in_(selectors.actor_ids),
|
||||
)
|
||||
confidential = (
|
||||
db.query(VotingConfidentialCast, VotingConfidentialBallot)
|
||||
.join(
|
||||
VotingConfidentialBallot,
|
||||
VotingConfidentialCast.provider_ballot_id
|
||||
== VotingConfidentialBallot.id,
|
||||
)
|
||||
.filter(
|
||||
VotingConfidentialCast.tenant_id == tenant_id,
|
||||
VotingConfidentialBallot.tenant_id == tenant_id,
|
||||
VotingConfidentialCast.elector_id.in_(selectors.elector_ids),
|
||||
)
|
||||
)
|
||||
if selectors.ballot_id:
|
||||
personal_casts = personal_casts.filter(
|
||||
VotingCastRecord.ballot_id == selectors.ballot_id
|
||||
)
|
||||
actor_casts = actor_casts.filter(
|
||||
VotingCastRecord.ballot_id == selectors.ballot_id
|
||||
)
|
||||
ballots = ballots.filter(
|
||||
VotingBallotRevision.ballot_id == selectors.ballot_id
|
||||
)
|
||||
events = events.filter(
|
||||
VotingLifecycleEvent.ballot_id == selectors.ballot_id
|
||||
)
|
||||
confidential = confidential.filter(
|
||||
VotingConfidentialBallot.ballot_id == selectors.ballot_id
|
||||
)
|
||||
|
||||
records.extend(
|
||||
_recorded_cast(row)
|
||||
for row in _limited(
|
||||
personal_casts,
|
||||
VotingCastRecord.cast_at,
|
||||
VotingCastRecord.id,
|
||||
label="recorded cast",
|
||||
)
|
||||
)
|
||||
records.extend(
|
||||
_cast_actor_attribution(row)
|
||||
for row in _limited(
|
||||
actor_casts,
|
||||
VotingCastRecord.cast_at,
|
||||
VotingCastRecord.id,
|
||||
label="cast actor attribution",
|
||||
)
|
||||
)
|
||||
confidential_rows = (
|
||||
confidential.order_by(
|
||||
VotingConfidentialCast.cast_at,
|
||||
VotingConfidentialCast.id,
|
||||
)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if len(confidential_rows) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Voting DSAR confidential-cast limit exceeded; narrow selectors."
|
||||
)
|
||||
records.extend(
|
||||
_confidential_cast_receipt(cast, ballot)
|
||||
for cast, ballot in confidential_rows
|
||||
)
|
||||
records.extend(
|
||||
_ballot_actor_attribution(row)
|
||||
for row in _limited(
|
||||
ballots,
|
||||
VotingBallotRevision.recorded_at,
|
||||
VotingBallotRevision.id,
|
||||
label="ballot attribution",
|
||||
)
|
||||
)
|
||||
records.extend(
|
||||
_event_actor_attribution(row)
|
||||
for row in _limited(
|
||||
events,
|
||||
VotingLifecycleEvent.recorded_at,
|
||||
VotingLifecycleEvent.id,
|
||||
label="lifecycle attribution",
|
||||
)
|
||||
)
|
||||
if len(records) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Voting DSAR combined result limit exceeded; narrow the selectors."
|
||||
)
|
||||
return tuple(
|
||||
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
|
||||
)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Voting DSAR subject selectors conflict.")
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=(
|
||||
f"voting:retain:{record.resource_type}:{record.resource_id}"
|
||||
),
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind="retain",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Retain {record.title}",
|
||||
rationale=record.retention_reason
|
||||
or "Ballot evidence must retain integrity.",
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Voting DSAR subject selectors conflict.")
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if action.executable or action.kind != "retain":
|
||||
raise ValueError("Voting DSAR publishes retain-only actions.")
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Ballot participation and lifecycle evidence remains unchanged "
|
||||
"to preserve integrity, certification, and challenge history."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||
references = subject.external_references
|
||||
values = {
|
||||
"account_id": _coalesce(
|
||||
subject.account_id,
|
||||
references.get("voting.account"),
|
||||
references.get("access.account"),
|
||||
),
|
||||
"membership_id": _coalesce(
|
||||
subject.membership_id,
|
||||
references.get("voting.membership"),
|
||||
references.get("tenancy.membership"),
|
||||
),
|
||||
"identity_id": _coalesce(
|
||||
subject.identity_id,
|
||||
references.get("voting.identity"),
|
||||
references.get("identity.id"),
|
||||
),
|
||||
"elector_id": _coalesce(
|
||||
references.get("voting.elector"),
|
||||
references.get("voting.elector_id"),
|
||||
),
|
||||
"actor_id": _coalesce(
|
||||
references.get("voting.actor"),
|
||||
references.get("voting.created_by"),
|
||||
),
|
||||
"ballot_id": _coalesce(
|
||||
references.get("voting.ballot"),
|
||||
references.get("voting.ballot_id"),
|
||||
),
|
||||
}
|
||||
if any(value is _CONFLICT for value in values.values()):
|
||||
return None
|
||||
base_ids = tuple(
|
||||
dict.fromkeys(
|
||||
value
|
||||
for value in (
|
||||
_optional_string(values["account_id"]),
|
||||
_prefixed("account", values["account_id"]),
|
||||
_optional_string(values["membership_id"]),
|
||||
_prefixed("membership", values["membership_id"]),
|
||||
_optional_string(values["identity_id"]),
|
||||
_prefixed("identity", values["identity_id"]),
|
||||
)
|
||||
if value
|
||||
)
|
||||
)
|
||||
direct_elector = _optional_string(values["elector_id"])
|
||||
direct_actor = _optional_string(values["actor_id"])
|
||||
for direct in (direct_elector, direct_actor):
|
||||
if direct and base_ids and direct not in base_ids:
|
||||
return None
|
||||
if not base_ids and direct_elector and direct_actor and direct_elector != direct_actor:
|
||||
return None
|
||||
elector_ids = base_ids or ((direct_elector or direct_actor,) if direct_elector or direct_actor else ())
|
||||
actor_ids = base_ids or ((direct_actor or direct_elector,) if direct_actor or direct_elector else ())
|
||||
if not elector_ids:
|
||||
return None
|
||||
return _SubjectSelectors(
|
||||
elector_ids=elector_ids,
|
||||
actor_ids=actor_ids,
|
||||
ballot_id=_optional_string(values["ballot_id"]),
|
||||
)
|
||||
|
||||
|
||||
def _recorded_cast(row: VotingCastRecord) -> DsarRecordRef:
|
||||
if not isinstance(row.selections, list) or len(row.selections) > _MAX_SELECTIONS:
|
||||
raise ValueError("Voting recorded selections exceed the DSAR bound.")
|
||||
return DsarRecordRef(
|
||||
provider_id="voting",
|
||||
module_id="voting",
|
||||
resource_type="recorded_ballot_cast",
|
||||
resource_id=row.id,
|
||||
category="identified_recorded_vote",
|
||||
title="Recorded ballot participation",
|
||||
data={
|
||||
"ballot_id": row.ballot_id,
|
||||
"generation": row.generation,
|
||||
"selections": [str(item)[:255] for item in row.selections],
|
||||
"weight": row.weight,
|
||||
"cast_at": _iso(row.cast_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"receipt_sha256": row.receipt_sha256,
|
||||
"assurance": "recorded_and_reconstructable",
|
||||
},
|
||||
observed_at=_aware(row.cast_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason=(
|
||||
"Recorded votes are attributable, reconstructable ballot evidence."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _cast_actor_attribution(row: VotingCastRecord) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="voting",
|
||||
module_id="voting",
|
||||
resource_type="recorded_cast_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="ballot_operator_attribution",
|
||||
title="Recorded cast actor attribution",
|
||||
data={
|
||||
"ballot_id": row.ballot_id,
|
||||
"generation": row.generation,
|
||||
"cast_at": _iso(row.cast_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"activity": "recorded_cast_for_elector",
|
||||
},
|
||||
observed_at=_aware(row.cast_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason="Cast actor attribution is immutable ballot evidence.",
|
||||
)
|
||||
|
||||
|
||||
def _confidential_cast_receipt(
|
||||
row: VotingConfidentialCast,
|
||||
ballot: VotingConfidentialBallot,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="voting",
|
||||
module_id="voting",
|
||||
resource_type="confidential_ballot_participation",
|
||||
resource_id=row.id,
|
||||
category="confidential_vote_participation_receipt",
|
||||
title="Confidential ballot participation",
|
||||
data={
|
||||
"ballot_id": ballot.ballot_id,
|
||||
"provider_ballot_ref": ballot.provider_ballot_ref,
|
||||
"assurance_profile": ballot.assurance_profile,
|
||||
"method": ballot.method,
|
||||
"ballot_state": ballot.state,
|
||||
"generation": row.generation,
|
||||
"weight": row.weight,
|
||||
"cast_at": _iso(row.cast_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"receipt_sha256": row.receipt_sha256,
|
||||
"selections_disclosed": False,
|
||||
},
|
||||
observed_at=_aware(row.cast_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason=(
|
||||
"Confidential participation receipts are retained without ciphertext or "
|
||||
"selection disclosure."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _ballot_actor_attribution(row: VotingBallotRevision) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="voting",
|
||||
module_id="voting",
|
||||
resource_type="ballot_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="ballot_governance_attribution",
|
||||
title="Ballot revision actor attribution",
|
||||
data={
|
||||
"ballot_id": row.ballot_id,
|
||||
"revision": row.revision,
|
||||
"state": row.state,
|
||||
"assurance_profile": row.assurance_profile,
|
||||
"method": row.method,
|
||||
"recorded_at": _iso(row.recorded_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"activity": "recorded_ballot_revision",
|
||||
},
|
||||
observed_at=_aware(row.recorded_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason="Ballot revision attribution is governance evidence.",
|
||||
)
|
||||
|
||||
|
||||
def _event_actor_attribution(row: VotingLifecycleEvent) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="voting",
|
||||
module_id="voting",
|
||||
resource_type="voting_lifecycle_actor_attribution",
|
||||
resource_id=row.id,
|
||||
category="ballot_governance_attribution",
|
||||
title="Voting lifecycle actor attribution",
|
||||
data={
|
||||
"ballot_id": row.ballot_id,
|
||||
"sequence": row.sequence,
|
||||
"event_type": row.event_type,
|
||||
"recorded_at": _iso(row.recorded_at),
|
||||
},
|
||||
observed_at=_aware(row.recorded_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason="Voting lifecycle attribution is governance evidence.",
|
||||
)
|
||||
|
||||
|
||||
def _limited(query, first, second, *, label: str):
|
||||
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError(f"Voting DSAR {label} limit exceeded; narrow selectors.")
|
||||
return rows
|
||||
|
||||
|
||||
def _coalesce(*values: str | None) -> str | None | object:
|
||||
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||
if len(normalized) > 1:
|
||||
return _CONFLICT
|
||||
return next(iter(normalized), None)
|
||||
|
||||
|
||||
def _optional_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _prefixed(prefix: str, value: object) -> str | None:
|
||||
normalized = _optional_string(value)
|
||||
return f"{prefix}:{normalized}" if normalized else None
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
aware = _aware(value)
|
||||
return aware.isoformat() if aware else None
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Voting DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
_RESOURCE_TYPES = {
|
||||
"recorded_ballot_cast",
|
||||
"recorded_cast_actor_attribution",
|
||||
"confidential_ballot_participation",
|
||||
"ballot_actor_attribution",
|
||||
"voting_lifecycle_actor_attribution",
|
||||
}
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "voting" or record.module_id != "voting":
|
||||
raise ValueError("Voting DSAR cannot plan a foreign provider record.")
|
||||
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
|
||||
raise ValueError("Voting DSAR record identity is invalid.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "voting" or action.module_id != "voting":
|
||||
raise ValueError("Voting DSAR cannot execute a foreign provider action.")
|
||||
if not action.action_id.startswith("voting:retain:"):
|
||||
raise ValueError("Voting DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["VOTING_DSAR_CAPABILITY", "VotingDsarProvider"]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'voting.assurance': {'privacy_notes': ['Aufgezeichnete Stimmzettel sind rekonstruierbar und '
|
||||
'dürfen niemals als geheim bezeichnet werden.',
|
||||
'Von Anbietern unterstützte Profile zeigen nur das '
|
||||
'Aggregat, den Empfang, den Hash und die Nachweise, die '
|
||||
'der Anbietervertrag erlaubt.',
|
||||
'Der gebündelte lokale vertrauliche Anbieter ist '
|
||||
'serverlesbar und unzertifiziert trotz verschlüsselter '
|
||||
'gespeicherter Auswahl.']},
|
||||
'voting.data-subject-requests': {'consequence_classes': {'export_confidential_receipt': 'Gibt '
|
||||
'Teilnahme-Metadaten '
|
||||
'ohne '
|
||||
'Auswahl '
|
||||
'oder '
|
||||
'Geheimtext '
|
||||
'zurück.',
|
||||
'export_recorded_vote': 'Gibt die '
|
||||
'rekonstruierbaren '
|
||||
'aufgezeichneten '
|
||||
'Auswahlen des '
|
||||
'Subjekts '
|
||||
'zurück.',
|
||||
'retain_ballot_evidence': 'Bewahrt die '
|
||||
'Integrität '
|
||||
'der '
|
||||
'Stimmzettel '
|
||||
'und die '
|
||||
'Geschichte '
|
||||
'der '
|
||||
'Herausforderungen.'}},
|
||||
'voting.reference.fields-and-consequences': {'consequence_classes': {'cast': 'Registriert oder '
|
||||
'ersetzt eine '
|
||||
'autorisierte Stimme '
|
||||
'und gibt eine '
|
||||
'datenschutzbeschränkte '
|
||||
'Quittung zurück.',
|
||||
'certify': 'Fügt '
|
||||
'Zertifizierungsnachweise '
|
||||
'hinzu, ohne das '
|
||||
'eingefrorene '
|
||||
'Ergebnis neu zu '
|
||||
'schreiben.',
|
||||
'challenge_or_annul': 'Hängt '
|
||||
'einen '
|
||||
'begründeten '
|
||||
'Governance-Übergang '
|
||||
'an, '
|
||||
'während '
|
||||
'vorherige '
|
||||
'Nachweise '
|
||||
'beibehalten '
|
||||
'werden.',
|
||||
'close': 'Stoppt das Gießen '
|
||||
'und zeichnet die '
|
||||
'Aggregatzahl auf.',
|
||||
'open': 'Friert die '
|
||||
'Stimmzetteldefinition '
|
||||
'und Wählerschaft '
|
||||
'ein und erlaubt '
|
||||
'autorisiertes '
|
||||
'Casting.'}}}
|
||||
@@ -21,8 +21,10 @@ from govoplan_core.core.voting import (
|
||||
ExternalVotingCastRequest,
|
||||
ExternalVotingFinalizationRequest,
|
||||
ExternalVotingPreparationRequest,
|
||||
VOTING_CERTIFICATION_NOT_CERTIFIED,
|
||||
VotingReceipt,
|
||||
VotingResult,
|
||||
VotingProviderAssuranceDeclaration,
|
||||
)
|
||||
from govoplan_voting.backend.db.models import (
|
||||
VotingConfidentialBallot,
|
||||
@@ -49,6 +51,19 @@ class LocalConfidentialVotingProvider:
|
||||
def __init__(self, registry: object | None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def assurance_declaration(self) -> VotingProviderAssuranceDeclaration:
|
||||
return VotingProviderAssuranceDeclaration(
|
||||
provider_id=LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||
implementation_ref="govoplan-voting/local-confidential@1",
|
||||
supported_assurance_profiles=("confidential",),
|
||||
certification_state=VOTING_CERTIFICATION_NOT_CERTIFIED,
|
||||
protocol_ref="govoplan:voting:local-confidential",
|
||||
protocol_version="1.0",
|
||||
notes=(
|
||||
"Server-readable reference provider; no anonymity, secrecy, coercion-resistance, or certification claim.",
|
||||
),
|
||||
)
|
||||
|
||||
def prepare_ballot(
|
||||
self,
|
||||
session: object,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_voting.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
@@ -16,6 +19,7 @@ from govoplan_core.core.module_guards import (
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -27,6 +31,7 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
@@ -37,6 +42,10 @@ from govoplan_core.core.voting import (
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_voting.backend.db import models as voting_models
|
||||
from govoplan_voting.backend.dsar_provider import (
|
||||
VOTING_DSAR_CAPABILITY,
|
||||
VotingDsarProvider,
|
||||
)
|
||||
from govoplan_voting.backend.service import SqlVotingBallots
|
||||
from govoplan_voting.backend.local_confidential_provider import (
|
||||
LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||
@@ -46,7 +55,7 @@ from govoplan_voting.backend.local_confidential_provider import (
|
||||
|
||||
MODULE_ID = "voting"
|
||||
MODULE_NAME = "Voting"
|
||||
MODULE_VERSION = "0.1.14"
|
||||
MODULE_VERSION = "0.1.20"
|
||||
READ_SCOPE = "voting:ballot:read"
|
||||
MANAGE_SCOPE = "voting:ballot:manage"
|
||||
CAST_SCOPE = "voting:ballot:cast"
|
||||
@@ -95,6 +104,10 @@ def _local_confidential_provider(
|
||||
return LocalConfidentialVotingProvider(context.registry)
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> VotingDsarProvider:
|
||||
return VotingDsarProvider()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
current = session.query(voting_models.VotingBallotRevision).filter(
|
||||
voting_models.VotingBallotRevision.tenant_id == tenant_id,
|
||||
@@ -124,6 +137,7 @@ manifest = ModuleManifest(
|
||||
name=voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID),
|
||||
version="0.1.0",
|
||||
),
|
||||
ModuleInterfaceProvider(name=VOTING_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -222,6 +236,17 @@ manifest = ModuleManifest(
|
||||
order=39,
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="meetings-decisions",
|
||||
module_id=MODULE_ID,
|
||||
label="i18n:govoplan-core.product_area.meetings_decisions",
|
||||
icon="calendar",
|
||||
description="i18n:govoplan-core.product_area.meetings_decisions_description",
|
||||
surface_ids=("voting.nav.voting", "voting.route.voting"),
|
||||
order=50,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="voting.navigation",
|
||||
@@ -252,13 +277,22 @@ manifest = ModuleManifest(
|
||||
voting_provider_capability(
|
||||
LOCAL_CONFIDENTIAL_PROVIDER_ID
|
||||
): _local_confidential_provider,
|
||||
VOTING_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation(
|
||||
label="Governed ballots",
|
||||
summary="Creates frozen electorates, records eligible votes, closes deterministic tallies, and certifies aggregate results.",
|
||||
contract_version="0.1.0",
|
||||
)
|
||||
),
|
||||
VOTING_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Voting data-subject request provider",
|
||||
summary=(
|
||||
"Exports identified recorded votes, confidential participation "
|
||||
"receipts, and minimized actor attribution without weakening secrecy."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
@@ -289,6 +323,67 @@ manifest = ModuleManifest(
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="voting.data-subject-requests",
|
||||
title="Voting data-subject requests",
|
||||
summary=(
|
||||
"Distinguish reconstructable recorded votes from confidential "
|
||||
"participation when exporting a subject's ballot data."
|
||||
),
|
||||
body=(
|
||||
"Voting correlates exact tenant and elector identifiers and can narrow "
|
||||
"an already verified search to one ballot. Recorded ballots are "
|
||||
"explicitly attributable and reconstructable, so a subject receives "
|
||||
"their own bounded selections, weight, generation, timestamps, and "
|
||||
"receipt. Confidential ballots return participation, assurance, "
|
||||
"generation, timing, and receipt metadata only. Ciphertext, encryption "
|
||||
"envelopes, resource-key references, electorate payloads, and choices "
|
||||
"are never disclosed. Acting on another elector's cast produces only "
|
||||
"minimized actor attribution. Ballot identifiers alone reveal no "
|
||||
"personal participation. All voting records are retained to preserve "
|
||||
"integrity, certification, recount, and challenge evidence."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("core", "committee", "identity_trust", "encryption"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Betroffenenanfragen für Abstimmungen",
|
||||
"summary": (
|
||||
"Beim Export von Abstimmungsdaten einer Person rekonstruierbare aufgezeichnete Stimmen von vertraulicher Teilnahme "
|
||||
"unterscheiden."
|
||||
),
|
||||
"body": (
|
||||
"Voting korreliert exakte Mandanten- und Wahlberechtigtenkennungen und kann eine bereits verifizierte Suche auf eine "
|
||||
"Abstimmung begrenzen. Aufgezeichnete Abstimmungen sind ausdrücklich zurechenbar und rekonstruierbar; eine betroffene "
|
||||
"Person erhält daher ihre eigenen begrenzten Auswahlwerte, Gewichtung, Generation, Zeitpunkte und Quittung. Vertrauliche "
|
||||
"Abstimmungen liefern nur Metadaten zu Teilnahme, Zusicherungsprofil, Generation, Zeitpunkt und Quittung. Chiffrat, "
|
||||
"Verschlüsselungshüllen, Ressourcenschlüsselverweise, Wählerschaftsdaten und Auswahlwerte werden niemals offengelegt. "
|
||||
"Das Handeln für die Stimmabgabe einer anderen Person erzeugt nur eine minimierte Akteurszuordnung. Eine "
|
||||
"Abstimmungskennung allein verrät keine persönliche Teilnahme. Alle Abstimmungsdatensätze werden zum Schutz von Integrität, "
|
||||
"Zertifizierung, Nachzählung und Anfechtungsnachweisen aufbewahrt."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"help_contexts": [
|
||||
"voting.ballot",
|
||||
"privacy.data-subject-requests",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"export_recorded_vote": (
|
||||
"Returns the subject's reconstructable recorded selections."
|
||||
),
|
||||
"export_confidential_receipt": (
|
||||
"Returns participation metadata without choices or ciphertext."
|
||||
),
|
||||
"retain_ballot_evidence": (
|
||||
"Preserves ballot integrity and challenge history."
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="voting.assurance",
|
||||
title="Voting assurance and certification",
|
||||
@@ -296,20 +391,62 @@ manifest = ModuleManifest(
|
||||
body=(
|
||||
"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. "
|
||||
"Provider assurance, protocol identity, certificate evidence, and validity are pinned when the ballot opens and revalidated before provider effects. "
|
||||
"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."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner", "auditor"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("voting",),
|
||||
any_scopes=(
|
||||
READ_SCOPE,
|
||||
MANAGE_SCOPE,
|
||||
CAST_SCOPE,
|
||||
CERTIFY_SCOPE,
|
||||
ADMIN_SCOPE,
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Voting domain and assurance boundary",
|
||||
href="govoplan-voting/docs/VOTING_DOMAIN.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="POLYAS provider profile",
|
||||
href="govoplan-voting/docs/POLYAS_PROVIDER_PROFILE.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Native certifiable Voting program",
|
||||
href="govoplan-voting/docs/CERTIFIABLE_VOTING_PROGRAM.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Abstimmungszusicherung und Zertifizierung durchführen",
|
||||
"summary": (
|
||||
"Aufgezeichnete Abstimmungen und providergestützte vertrauliche oder geheime Abstimmungen betreiben, ohne ihre "
|
||||
"Zusicherungsprofile zu vermischen."
|
||||
),
|
||||
"body": (
|
||||
"Das Öffnen einer Abstimmung friert Definition und Wählerschaftshashes ein. Native aufgezeichnete Abstimmungen bewahren "
|
||||
"aktive Stimmdatensätze zur Rekonstruktion; sie sind nicht geheim. Vertrauliche, geheime und extern zertifizierte Profile "
|
||||
"verlangen einen installierten Provider und bewahren nur aggregierte Ergebnisse, Quittungen, Hashes und Nachweise. "
|
||||
"Provider-Zusicherung, Protokollkennung, Zertifikatsnachweis und Gültigkeit werden beim Öffnen festgelegt und vor "
|
||||
"Provider-Wirkungen erneut geprüft. Der mitgelieferte lokale vertrauliche Provider verschlüsselt rohe Auswahlwerte über "
|
||||
"Encryption und unterstützt interaktive Stimmabgabe, bleibt aber serverlesbar und nicht zertifiziert. Schließung, "
|
||||
"Zertifizierung, Anfechtung und Aufhebung bleiben getrennte auditierbare Übergänge."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"voting.navigation",
|
||||
@@ -348,7 +485,27 @@ manifest = ModuleManifest(
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Abstimmungsfelder, Zusicherung und Folgen des Lebenszyklus",
|
||||
"summary": (
|
||||
"Semantik von eingefrorener Wählerschaft, Schwellenwert, Provider, Quittung, Auszählung, Zertifizierung, Anfechtung und "
|
||||
"Aufhebung."
|
||||
),
|
||||
"body": (
|
||||
"Das Öffnen friert exakte Optionen, Methode, Wählerschaft, Gewichtungen, Quorum, Schwellenwert, Ersetzungsregel, "
|
||||
"Zusicherungsprofil und Providerbindung ein. Aufgezeichnete Abstimmungen bleiben zurechenbar und rekonstruierbar. "
|
||||
"Vertrauliche, geheime und extern zertifizierte Profile sind ausschließlich Zusicherungen ihres installierten Providers; "
|
||||
"der lokale vertrauliche Provider ist serverlesbar und nicht zertifiziert. Eine Stimmabgabe zeichnet eine Stimme nur auf "
|
||||
"oder ersetzt sie, wenn die eingefrorene Definition dies erlaubt, und liefert eine Quittung. Schließen verhindert weitere "
|
||||
"Stimmabgaben und zeichnet die Auszählung auf. Zertifizieren ergänzt Nachweise, ohne das Ergebnis umzuschreiben. Anfechtung "
|
||||
"und Aufhebung sind getrennt begründete, auditierbare Übergänge und löschen niemals eingefrorene Definition, Quittungen "
|
||||
"oder frühere Historie."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"seed": True,
|
||||
"help_contexts": [
|
||||
"voting.field.assurance-profile",
|
||||
@@ -382,6 +539,7 @@ manifest = ModuleManifest(
|
||||
"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.",
|
||||
"The bundled local confidential provider is server-decryptable and is neither anonymous, coercion-resistant, secret, nor externally certified.",
|
||||
"POLYAS remains an operator-assisted integration target until a contracted API, sandbox, current certification evidence, and conformance fixtures are available.",
|
||||
"Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.",
|
||||
),
|
||||
supported_authority_modes=("native_authoritative", "external_authoritative"),
|
||||
@@ -402,11 +560,20 @@ manifest = ModuleManifest(
|
||||
reference_packages=("product.service-to-decision",),
|
||||
migration_docs=("docs/VOTING_DOMAIN.md",),
|
||||
recovery_docs=("docs/VOTING_DOMAIN.md",),
|
||||
security_docs=("docs/VOTING_DOMAIN.md",),
|
||||
security_docs=(
|
||||
"docs/VOTING_DOMAIN.md",
|
||||
"docs/POLYAS_PROVIDER_PROFILE.md",
|
||||
"docs/CERTIFIABLE_VOTING_PROGRAM.md",
|
||||
),
|
||||
operations_docs=("docs/VOTING_DOMAIN.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
@@ -21,8 +21,10 @@ from govoplan_core.core.voting import (
|
||||
VotingBallotCreateCommand,
|
||||
VotingBallotRef,
|
||||
VotingCastCommand,
|
||||
VotingCapabilityError,
|
||||
VotingReceipt,
|
||||
VotingResult,
|
||||
require_voting_provider_assurance,
|
||||
voting_provider_capability,
|
||||
)
|
||||
from govoplan_voting.backend.db.models import (
|
||||
@@ -265,6 +267,16 @@ class SqlVotingBallots:
|
||||
raise VotingStoreError(
|
||||
"The selected Voting assurance profile requires an available external provider."
|
||||
)
|
||||
try:
|
||||
declaration = require_voting_provider_assurance(
|
||||
provider,
|
||||
provider_id=provider_id,
|
||||
assurance_profile=profile,
|
||||
at=_now(),
|
||||
)
|
||||
except VotingCapabilityError as exc:
|
||||
raise VotingStoreError(str(exc)) from exc
|
||||
payload["provider_assurance"] = declaration.to_dict()
|
||||
definition_hash, electorate_hash = _frozen_hashes(payload)
|
||||
payload["definition_sha256"] = definition_hash
|
||||
payload["electorate_sha256"] = electorate_hash
|
||||
@@ -343,6 +355,7 @@ class SqlVotingBallots:
|
||||
"electorate_sha256": electorate_hash,
|
||||
"provider_id": payload.get("provider_id"),
|
||||
"provider_ballot_ref": payload.get("provider_ballot_ref"),
|
||||
"provider_assurance": payload.get("provider_assurance"),
|
||||
"provider_evidence": [dict(item) for item in provider_evidence],
|
||||
},
|
||||
)
|
||||
@@ -403,6 +416,7 @@ class SqlVotingBallots:
|
||||
raise VotingStoreError(
|
||||
"This Voting provider does not expose an interactive cast capability."
|
||||
)
|
||||
_require_pinned_provider_assurance(current.payload, provider)
|
||||
try:
|
||||
receipt = provider.cast_ballot(
|
||||
typed_session,
|
||||
@@ -863,6 +877,7 @@ class SqlVotingBallots:
|
||||
provider = _capability(self._registry, voting_provider_capability(provider_id))
|
||||
if not isinstance(provider, ExternalVotingProvider):
|
||||
raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.")
|
||||
_require_pinned_provider_assurance(current.payload, provider)
|
||||
electorate = list(current.payload["electorate"])
|
||||
try:
|
||||
result = provider.finalize_ballot(
|
||||
@@ -944,6 +959,7 @@ def _payload_from_command(command: VotingBallotCreateCommand) -> dict[str, Any]:
|
||||
"closes_at": _datetime_text(command.closes_at),
|
||||
"provider_id": _optional_text(command.provider_id),
|
||||
"provider_ballot_ref": _optional_text(command.provider_ballot_ref),
|
||||
"provider_assurance": None,
|
||||
"metadata": dict(command.metadata),
|
||||
"definition_sha256": None,
|
||||
"electorate_sha256": None,
|
||||
@@ -1029,6 +1045,28 @@ def _validate_window(payload: Mapping[str, Any]) -> None:
|
||||
raise VotingStoreError("Voting ballot has already reached its close time.")
|
||||
|
||||
|
||||
def _require_pinned_provider_assurance(
|
||||
payload: Mapping[str, Any],
|
||||
provider: object,
|
||||
) -> None:
|
||||
provider_id = str(payload.get("provider_id") or "").strip()
|
||||
assurance_profile = str(payload.get("assurance_profile") or "").strip()
|
||||
try:
|
||||
current = require_voting_provider_assurance(
|
||||
provider,
|
||||
provider_id=provider_id,
|
||||
assurance_profile=assurance_profile,
|
||||
at=_now(),
|
||||
)
|
||||
except VotingCapabilityError as exc:
|
||||
raise VotingStoreError(str(exc)) from exc
|
||||
pinned = payload.get("provider_assurance")
|
||||
if not isinstance(pinned, Mapping) or dict(pinned) != current.to_dict():
|
||||
raise VotingStoreError(
|
||||
"Voting provider assurance changed after the ballot was frozen."
|
||||
)
|
||||
|
||||
|
||||
def _frozen_hashes(payload: Mapping[str, Any]) -> tuple[str, str]:
|
||||
electorate = list(payload.get("electorate") or [])
|
||||
definition = {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
create_data_subject_request,
|
||||
search_data_subject_request,
|
||||
)
|
||||
from govoplan_voting.backend.db.models import (
|
||||
VotingBallotRevision,
|
||||
VotingCastRecord,
|
||||
VotingConfidentialBallot,
|
||||
VotingConfidentialCast,
|
||||
VotingLifecycleEvent,
|
||||
)
|
||||
from govoplan_voting.backend.dsar_provider import (
|
||||
VOTING_DSAR_CAPABILITY,
|
||||
VotingDsarProvider,
|
||||
)
|
||||
from govoplan_voting.backend.manifest import manifest
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 21, 15, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: VotingDsarProvider) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def capability_names(self):
|
||||
return (VOTING_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
if name != VOTING_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
return "voting"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type("State", (), {"effective_modules": ("voting",)})()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
if name != VOTING_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "voting"})(),)
|
||||
|
||||
|
||||
class VotingDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = VotingDsarProvider()
|
||||
self.assertIsInstance(self.provider, DsarProvider)
|
||||
self._seed()
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def _seed(self) -> None:
|
||||
self.session.add(
|
||||
VotingBallotRevision(
|
||||
id="ballot-revision-1",
|
||||
tenant_id="tenant-1",
|
||||
ballot_id="ballot-1",
|
||||
revision=1,
|
||||
state="closed",
|
||||
assurance_profile="recorded",
|
||||
method="single_choice",
|
||||
definition_sha256="definition-hash-do-not-export",
|
||||
electorate_sha256="electorate-hash-do-not-export",
|
||||
recorded_at=NOW,
|
||||
payload={
|
||||
"electorate": "ballot-electorate-do-not-export",
|
||||
"options": "ballot-options-do-not-export",
|
||||
},
|
||||
created_by="account-1",
|
||||
)
|
||||
)
|
||||
self.session.add_all(
|
||||
(
|
||||
VotingCastRecord(
|
||||
id="recorded-cast-1",
|
||||
tenant_id="tenant-1",
|
||||
ballot_id="ballot-1",
|
||||
definition_sha256="cast-definition-hash-do-not-export",
|
||||
elector_id="account-1",
|
||||
generation=1,
|
||||
selections=["option-a"],
|
||||
weight=1,
|
||||
cast_at=NOW,
|
||||
idempotency_key="cast-idempotency-do-not-export",
|
||||
receipt_sha256="recorded-receipt-1",
|
||||
actor_id="account-1",
|
||||
),
|
||||
VotingCastRecord(
|
||||
id="recorded-cast-other",
|
||||
tenant_id="tenant-1",
|
||||
ballot_id="ballot-1",
|
||||
definition_sha256="other-definition",
|
||||
elector_id="account-other",
|
||||
generation=1,
|
||||
selections=["private-other-selection-do-not-export"],
|
||||
weight=1,
|
||||
cast_at=NOW,
|
||||
idempotency_key="other-idempotency",
|
||||
receipt_sha256="other-receipt",
|
||||
actor_id="account-other",
|
||||
),
|
||||
VotingCastRecord(
|
||||
id="recorded-cast-proxy",
|
||||
tenant_id="tenant-1",
|
||||
ballot_id="ballot-1",
|
||||
definition_sha256="proxy-definition",
|
||||
elector_id="account-proxy-subject",
|
||||
generation=1,
|
||||
selections=["proxy-selection-do-not-export"],
|
||||
weight=1,
|
||||
cast_at=NOW,
|
||||
idempotency_key="proxy-idempotency-do-not-export",
|
||||
receipt_sha256="proxy-receipt-do-not-export",
|
||||
actor_id="account-1",
|
||||
),
|
||||
)
|
||||
)
|
||||
confidential_ballot = VotingConfidentialBallot(
|
||||
id="confidential-ballot-row",
|
||||
tenant_id="tenant-1",
|
||||
provider_ballot_ref="provider-ballot-1",
|
||||
ballot_id="ballot-confidential",
|
||||
definition_sha256="confidential-definition-do-not-export",
|
||||
electorate_sha256="confidential-electorate-hash-do-not-export",
|
||||
assurance_profile="confidential",
|
||||
method="single_choice",
|
||||
state="closed",
|
||||
options=[{"private": "confidential-options-do-not-export"}],
|
||||
electorate=[{"private": "confidential-electorate-do-not-export"}],
|
||||
allow_replacement=True,
|
||||
quorum_weight=1,
|
||||
threshold_numerator=1,
|
||||
threshold_denominator=2,
|
||||
vault_id="vault-do-not-export",
|
||||
preparation_idempotency_key="preparation-idempotency-do-not-export",
|
||||
preparation_request_sha256="preparation-hash-do-not-export",
|
||||
prepared_at=NOW,
|
||||
result={"private": "confidential-result-do-not-export"},
|
||||
)
|
||||
self.session.add(confidential_ballot)
|
||||
self.session.flush()
|
||||
self.session.add(
|
||||
VotingConfidentialCast(
|
||||
id="confidential-cast-1",
|
||||
tenant_id="tenant-1",
|
||||
provider_ballot_id="confidential-ballot-row",
|
||||
elector_id="account-1",
|
||||
generation=1,
|
||||
definition_sha256="confidential-cast-definition-do-not-export",
|
||||
ciphertext=b"ciphertext-do-not-export",
|
||||
encryption_envelope_id="envelope-do-not-export",
|
||||
encryption_resource_id="resource-key-do-not-export",
|
||||
weight=1,
|
||||
cast_at=NOW,
|
||||
idempotency_key="confidential-idempotency-do-not-export",
|
||||
request_sha256="confidential-request-hash-do-not-export",
|
||||
receipt_sha256="confidential-receipt-1",
|
||||
)
|
||||
)
|
||||
self.session.add(
|
||||
VotingLifecycleEvent(
|
||||
id="lifecycle-1",
|
||||
tenant_id="tenant-1",
|
||||
ballot_id="ballot-1",
|
||||
sequence=1,
|
||||
event_type="ballot.opened",
|
||||
recorded_at=NOW,
|
||||
actor_id="account-1",
|
||||
payload={"secret": "lifecycle-payload-do-not-export"},
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _subject() -> DsarSubjectRef:
|
||||
return DsarSubjectRef(account_id="account-1")
|
||||
|
||||
def test_search_separates_recorded_and_confidential_disclosure(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"recorded_ballot_cast",
|
||||
"recorded_cast_actor_attribution",
|
||||
"confidential_ballot_participation",
|
||||
"ballot_actor_attribution",
|
||||
"voting_lifecycle_actor_attribution",
|
||||
},
|
||||
{record.resource_type for record in records},
|
||||
)
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
self.assertIn("option-a", exported)
|
||||
self.assertIn("recorded-receipt-1", exported)
|
||||
self.assertIn("confidential-receipt-1", exported)
|
||||
self.assertIn('"selections_disclosed": false', exported)
|
||||
for excluded in (
|
||||
"private-other-selection-do-not-export",
|
||||
"proxy-selection-do-not-export",
|
||||
"proxy-receipt-do-not-export",
|
||||
"ciphertext-do-not-export",
|
||||
"envelope-do-not-export",
|
||||
"resource-key-do-not-export",
|
||||
"confidential-options-do-not-export",
|
||||
"confidential-electorate-do-not-export",
|
||||
"confidential-result-do-not-export",
|
||||
"ballot-electorate-do-not-export",
|
||||
"lifecycle-payload-do-not-export",
|
||||
"cast-idempotency-do-not-export",
|
||||
):
|
||||
self.assertNotIn(excluded, exported)
|
||||
|
||||
def test_ballot_narrowing_and_conflicting_elector_fail_closed(self) -> None:
|
||||
narrowed = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"voting.ballot": "ballot-confidential"},
|
||||
),
|
||||
)
|
||||
conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"voting.elector": "account-other"},
|
||||
),
|
||||
)
|
||||
ballot_only = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"voting.ballot": "ballot-1"}
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
["confidential_ballot_participation"],
|
||||
[record.resource_type for record in narrowed],
|
||||
)
|
||||
self.assertEqual((), conflict)
|
||||
self.assertEqual((), ballot_only)
|
||||
|
||||
def test_erasure_is_retain_only(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self._subject(),
|
||||
records=records,
|
||||
)
|
||||
self.assertTrue(actions)
|
||||
self.assertTrue(all(action.kind == "retain" for action in actions))
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self._subject(),
|
||||
actions=actions,
|
||||
request_id="dsar-voting-1",
|
||||
)
|
||||
self.assertTrue(all(result.status == "blocked" for result in results))
|
||||
self.assertEqual(3, self.session.query(VotingCastRecord).count())
|
||||
|
||||
def test_manifest_and_core_workflow_discover_provider(self) -> None:
|
||||
self.assertIn(VOTING_DSAR_CAPABILITY, manifest.capability_factories)
|
||||
row = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-VOTING-1",
|
||||
request_kind="access",
|
||||
subject=self._subject(),
|
||||
purpose="Voting participation access request",
|
||||
legal_basis=None,
|
||||
due_at=None,
|
||||
requested_by_account_id="operator-1",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=row,
|
||||
expected_revision=row.resource_revision,
|
||||
)
|
||||
self.assertEqual("searched", row.status)
|
||||
self.assertEqual(5, row.search_result["record_count"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,14 @@ from govoplan_voting.backend.manifest import manifest
|
||||
|
||||
|
||||
class VotingInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in manifest.documentation:
|
||||
german = (topic.translations or {}).get("de", {})
|
||||
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||
self.assertTrue(
|
||||
all(str(value).strip() for value in german.values()), topic.id
|
||||
)
|
||||
|
||||
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||
frontend = manifest.frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
@@ -21,9 +29,13 @@ class VotingInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
reference = topics["voting.reference.fields-and-consequences"]
|
||||
self.assertIn("voting.ballot", guide.metadata["help_contexts"])
|
||||
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
||||
self.assertIn("voting.field.assurance-profile", reference.metadata["help_contexts"])
|
||||
self.assertEqual("workflow", guide.metadata["kind"])
|
||||
self.assertIn(
|
||||
"voting.field.assurance-profile", reference.metadata["help_contexts"]
|
||||
)
|
||||
self.assertIn("cast", reference.metadata["consequence_classes"])
|
||||
self.assertIn("challenge_or_annul", reference.metadata["consequence_classes"])
|
||||
self.assertEqual("reference", reference.metadata["kind"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+104
-1
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
@@ -10,10 +10,13 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_core.core.voting import (
|
||||
VOTING_CERTIFICATION_CERTIFIED,
|
||||
VOTING_CERTIFICATION_IN_EVALUATION,
|
||||
VotingBallotCreateCommand,
|
||||
VotingCastCommand,
|
||||
VotingElector,
|
||||
VotingOption,
|
||||
VotingProviderAssuranceDeclaration,
|
||||
voting_provider_capability,
|
||||
)
|
||||
from govoplan_core.core.encryption import (
|
||||
@@ -51,6 +54,17 @@ class FakeRegistry:
|
||||
return self.capabilities.get(name)
|
||||
|
||||
|
||||
class FakeExternalVotingProvider:
|
||||
def __init__(self, declaration: VotingProviderAssuranceDeclaration) -> None:
|
||||
self.declaration = declaration
|
||||
|
||||
def assurance_declaration(self) -> VotingProviderAssuranceDeclaration:
|
||||
return self.declaration
|
||||
|
||||
def finalize_ballot(self, session, principal, *, request):
|
||||
raise AssertionError("finalization should not run in assurance gate tests")
|
||||
|
||||
|
||||
class FakeKeyVault:
|
||||
def __init__(self) -> None:
|
||||
self.vaults: dict[tuple[str, str], object] = {}
|
||||
@@ -290,6 +304,95 @@ class VotingTests(unittest.TestCase):
|
||||
idempotency_key="open-secret",
|
||||
)
|
||||
|
||||
def test_external_certified_provider_claim_is_current_and_frozen(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
candidate = FakeExternalVotingProvider(
|
||||
VotingProviderAssuranceDeclaration(
|
||||
provider_id="certified",
|
||||
implementation_ref="vendor/adapter@1",
|
||||
supported_assurance_profiles=("external_certified",),
|
||||
certification_state=VOTING_CERTIFICATION_IN_EVALUATION,
|
||||
protocol_ref="vendor:ballot",
|
||||
protocol_version="3.0",
|
||||
)
|
||||
)
|
||||
registry = FakeRegistry()
|
||||
registry.capabilities[voting_provider_capability("certified")] = candidate
|
||||
service = SqlVotingBallots(registry)
|
||||
|
||||
with self.Session() as session:
|
||||
created = service.create_ballot(
|
||||
session,
|
||||
self.manager,
|
||||
command=command(
|
||||
assurance="external_certified",
|
||||
provider_id="certified",
|
||||
),
|
||||
idempotency_key="create-certified-candidate",
|
||||
)
|
||||
with self.assertRaisesRegex(VotingStoreError, "currently valid"):
|
||||
service.open_ballot(
|
||||
session,
|
||||
self.manager,
|
||||
ballot_id=created.id,
|
||||
expected_revision=created.revision,
|
||||
idempotency_key="open-certified-candidate",
|
||||
)
|
||||
|
||||
candidate.declaration = VotingProviderAssuranceDeclaration(
|
||||
provider_id="certified",
|
||||
implementation_ref="vendor/adapter@1",
|
||||
supported_assurance_profiles=("external_certified",),
|
||||
certification_state=VOTING_CERTIFICATION_CERTIFIED,
|
||||
protocol_ref="vendor:ballot",
|
||||
protocol_version="3.0",
|
||||
certification_authority="Independent authority",
|
||||
certification_reference="certificate-2026-1",
|
||||
certification_evidence_ref="evidence://certificate-2026-1",
|
||||
certification_valid_from=now - timedelta(days=1),
|
||||
certification_valid_until=now + timedelta(days=1),
|
||||
)
|
||||
with self.Session() as session:
|
||||
created = service.create_ballot(
|
||||
session,
|
||||
self.manager,
|
||||
command=command(
|
||||
assurance="external_certified",
|
||||
provider_id="certified",
|
||||
),
|
||||
idempotency_key="create-certified",
|
||||
)
|
||||
opened = service.open_ballot(
|
||||
session,
|
||||
self.manager,
|
||||
ballot_id=created.id,
|
||||
expected_revision=created.revision,
|
||||
idempotency_key="open-certified",
|
||||
)
|
||||
detail = service.get_ballot(
|
||||
session,
|
||||
self.manager,
|
||||
ballot_id=created.id,
|
||||
)
|
||||
self.assertEqual(
|
||||
"certificate-2026-1",
|
||||
detail["provider_assurance"]["certification_reference"],
|
||||
)
|
||||
|
||||
candidate.declaration = replace(
|
||||
candidate.declaration,
|
||||
certification_reference="certificate-2026-2",
|
||||
certification_evidence_ref="evidence://certificate-2026-2",
|
||||
)
|
||||
with self.assertRaisesRegex(VotingStoreError, "changed after"):
|
||||
service.close_ballot(
|
||||
session,
|
||||
self.manager,
|
||||
ballot_id=opened.id,
|
||||
expected_revision=opened.revision,
|
||||
idempotency_key="close-certified",
|
||||
)
|
||||
|
||||
def test_local_confidential_provider_encrypts_casts_and_returns_aggregates(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/voting-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,7 +14,7 @@
|
||||
"./styles/voting.css": "./src/styles/voting.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
import { FormGrid,
|
||||
Button,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
@@ -118,6 +118,7 @@ export default function VotingBallotDialog({
|
||||
onClose={requestClose}
|
||||
closeDisabled={busy}
|
||||
portal
|
||||
size="wide"
|
||||
className="voting-ballot-dialog"
|
||||
footer={
|
||||
<>
|
||||
@@ -128,7 +129,7 @@ export default function VotingBallotDialog({
|
||||
<div className="voting-ballot-editor">
|
||||
<div className="voting-editor-help"><DocumentationHelpLink reference={VOTING_FIELD_DOCUMENTATION} /></div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<div className="voting-editor-grid">
|
||||
<FormGrid columns={2} gap="compact" collapseAt="workspace">
|
||||
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
|
||||
<FormField label="Method">
|
||||
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value as VotingBallotDraft["method"] })}>
|
||||
@@ -175,7 +176,7 @@ export default function VotingBallotDialog({
|
||||
/>
|
||||
</FormField>
|
||||
</>}
|
||||
</div>
|
||||
</FormGrid>
|
||||
|
||||
<EditorHeading title="Options" onAdd={() => setDraft({ ...draft, options: [...draft.options, { key: `option-${draft.options.length + 1}`, label: "", description: "" }] })} disabled={busy} />
|
||||
<div className="voting-editor-list">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CheckCircle2, Pencil, Plus, RefreshCw, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { CheckCircle2, Pencil, Plus, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
import { ActionBlockerHint,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
@@ -10,8 +9,16 @@ import {
|
||||
FormField,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
MetricCard,
|
||||
MetricGrid,
|
||||
PageScrollViewport,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
WorkspaceActionBar,
|
||||
WorkspaceLayout,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
usePlatformLanguage,
|
||||
@@ -144,28 +151,44 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||
|
||||
return (
|
||||
<main className="voting-page">
|
||||
<div className="voting-shell">
|
||||
<aside className="voting-catalogue">
|
||||
<div className="voting-toolbar">
|
||||
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? VOTING_I18N.loading : busy ? VOTING_I18N.busy : undefined} onClick={() => void loadList()} />
|
||||
<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>
|
||||
<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />
|
||||
</div>
|
||||
<WorkspaceLayout
|
||||
variant="split"
|
||||
primarySize="default"
|
||||
primaryScrollable={false}
|
||||
contentScrollable={false}
|
||||
surface="contained"
|
||||
primaryClassName="voting-catalogue"
|
||||
contentClassName="voting-workspace"
|
||||
primaryLabel="Ballots"
|
||||
contentLabel="Ballot details"
|
||||
interfaceId="voting.workspace"
|
||||
helpContextId="voting.workspace"
|
||||
helpModuleId="voting"
|
||||
primary={<>
|
||||
<WorkspaceActionBar
|
||||
scope="collection-pane"
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void loadList(), loading: loading || busy, label: "Refresh ballots" }}
|
||||
className="voting-toolbar"
|
||||
createAction={<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>}
|
||||
helpAction={<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />}
|
||||
/>
|
||||
<PageScrollViewport className="voting-list-viewport">
|
||||
{loading && <LoadingIndicator label="Loading ballots" />}
|
||||
{!loading && items.length === 0 && <div className="voting-empty">No ballots.</div>}
|
||||
<div className="voting-list" role="list">
|
||||
{items.map((item) => <button type="button" role="listitem" className={`voting-list-row${item.id === selectedId ? " is-selected" : ""}`} key={item.id} onClick={() => setSelectedId(item.id)}>
|
||||
<span><strong>{item.title}</strong><small>{humanize(item.assurance_profile)}</small></span>
|
||||
{!loading && items.length === 0 && <StatePanel size="compact" description="No ballots." />}
|
||||
<SelectionList variant="navigation" label="Ballots">
|
||||
{items.map((item) => <SelectionListItem selected={item.id === selectedId} key={item.id} onClick={() => setSelectedId(item.id)}>
|
||||
<SelectionListItemContent title={item.title} description={humanize(item.assurance_profile)} />
|
||||
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
||||
</button>)}
|
||||
</div>
|
||||
</SelectionListItem>)}
|
||||
</SelectionList>
|
||||
</PageScrollViewport>
|
||||
</aside>
|
||||
<section className="voting-workspace">
|
||||
</>}
|
||||
>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||
{!selected && !loading && <div className="voting-empty">Select a ballot.</div>}
|
||||
{!selected && !loading && <StatePanel size="fill" title="Ballots" description="Select a ballot." />}
|
||||
{selected && <PageScrollViewport className="voting-detail-viewport">
|
||||
<div className="voting-detail-heading">
|
||||
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
|
||||
@@ -179,12 +202,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||
{selected.state !== "annulled" && <Button variant="danger" disabled={busy || !canAdmin} disabledReason={busy ? VOTING_I18N.busy : !canAdmin ? VOTING_I18N.adminReason : undefined} onClick={() => setReasonAction("annul")}>Annul</Button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="voting-metrics">
|
||||
<Metric label="Electors" value={selected.electorate.length} />
|
||||
<Metric label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
|
||||
<Metric label="Quorum" value={selected.quorum_weight} />
|
||||
<Metric label="Assurance" value={humanize(selected.assurance_profile)} />
|
||||
</div>
|
||||
<MetricGrid columns={4} spacing="block">
|
||||
<MetricCard density="compact" label="Electors" value={selected.electorate.length} />
|
||||
<MetricCard density="compact" label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
|
||||
<MetricCard density="compact" label="Quorum" value={selected.quorum_weight} />
|
||||
<MetricCard density="compact" label="Assurance" value={humanize(selected.assurance_profile)} />
|
||||
</MetricGrid>
|
||||
{selected.description && <p className="voting-description">{selected.description}</p>}
|
||||
{selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible && <section className="voting-cast-panel">
|
||||
<h3>Cast vote</h3>
|
||||
@@ -215,12 +238,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||
</section>
|
||||
{selected.result && <section className="voting-section">
|
||||
<h3>Result</h3>
|
||||
<div className="voting-metrics">
|
||||
<Metric label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
|
||||
<Metric label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
|
||||
<Metric label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
|
||||
<Metric label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
|
||||
</div>
|
||||
<MetricGrid columns={4} spacing="block">
|
||||
<MetricCard density="compact" label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
|
||||
<MetricCard density="compact" label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
|
||||
<MetricCard density="compact" label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
|
||||
<MetricCard density="compact" label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
|
||||
</MetricGrid>
|
||||
<Hash label="Result hash" value={selected.result.result_sha256} />
|
||||
</section>}
|
||||
<section className="voting-section voting-assurance">
|
||||
@@ -235,8 +258,7 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||
</div>
|
||||
</section>
|
||||
</PageScrollViewport>}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspaceLayout>
|
||||
{editing && <VotingBallotDialog
|
||||
open
|
||||
settings={settings}
|
||||
@@ -299,10 +321,6 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string | number }) {
|
||||
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
function Hash({ label, value }: { label: string; value?: string | null }) {
|
||||
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
|
||||
}
|
||||
|
||||
@@ -4,22 +4,12 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.voting-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 320px) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
|
||||
.voting-catalogue {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color, #d8dde3);
|
||||
}
|
||||
|
||||
.voting-toolbar,
|
||||
.voting-detail-heading,
|
||||
.voting-actions,
|
||||
.voting-editor-heading {
|
||||
@@ -28,54 +18,20 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.voting-toolbar {
|
||||
min-height: 50px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #d8dde3);
|
||||
}
|
||||
|
||||
.voting-list-viewport,
|
||||
.voting-detail-viewport {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.voting-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.voting-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 7px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voting-list-row:hover,
|
||||
.voting-list-row.is-selected {
|
||||
background: var(--hover-bg, rgba(54, 99, 135, 0.1));
|
||||
}
|
||||
|
||||
.voting-list-row > span:first-child,
|
||||
.voting-option-results > div > span:first-child {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.voting-list-row small,
|
||||
.voting-option-results small {
|
||||
color: var(--text-muted, #65717e);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.voting-workspace {
|
||||
@@ -96,7 +52,7 @@
|
||||
.voting-detail-heading {
|
||||
justify-content: space-between;
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid var(--border-color, #d8dde3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.voting-detail-heading h2,
|
||||
@@ -109,7 +65,7 @@
|
||||
}
|
||||
|
||||
.voting-detail-heading > div:first-child span {
|
||||
color: var(--text-muted, #65717e);
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@@ -118,25 +74,8 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.voting-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.voting-metrics > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color, #d8dde3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.voting-metrics span,
|
||||
.voting-hash span {
|
||||
color: var(--text-muted, #65717e);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -149,7 +88,7 @@
|
||||
.voting-cast-panel {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-color, #d8dde3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.voting-options,
|
||||
@@ -170,7 +109,7 @@
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 7px 9px;
|
||||
background: var(--surface-muted, rgba(127, 137, 147, 0.08));
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.voting-options label > span {
|
||||
@@ -183,7 +122,7 @@
|
||||
}
|
||||
|
||||
.voting-history time {
|
||||
color: var(--text-muted, #65717e);
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@@ -200,13 +139,7 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.voting-empty {
|
||||
padding: 24px;
|
||||
color: var(--text-muted, #65717e);
|
||||
}
|
||||
|
||||
.voting-ballot-dialog {
|
||||
width: min(1040px, calc(100vw - 32px));
|
||||
height: min(820px, calc(100vh - 32px));
|
||||
}
|
||||
|
||||
@@ -223,12 +156,6 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.voting-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.voting-editor-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -262,19 +189,7 @@
|
||||
grid-template-columns: minmax(180px, 1.2fr) minmax(160px, 1fr) 90px 34px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.voting-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(160px, 34%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.voting-catalogue {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border-color, #d8dde3);
|
||||
}
|
||||
|
||||
.voting-metrics,
|
||||
.voting-editor-grid,
|
||||
@media (max-width: 760px) {
|
||||
.voting-option-row,
|
||||
.voting-elector-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user