17 Commits
Author SHA1 Message Date
zemion f8ab0f0e8d fix(packaging): expose immutable WebUI Git package for v0.1.21
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:11 +02:00
zemion 05c8c97044 Release govoplan-voting v0.1.21: unify interface contracts and documentation 2026-09-08 01:32:54 +02:00
zemion 00650aee81 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:42 +02:00
zemion 64ff8da814 docs(voting): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 20:27:26 +02:00
zemion 7e03fe62ac feat(voting): add governed DSAR coverage 2026-08-21 12:15:56 +02:00
zemion 13bdeccdab refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion 89cc5f0189 feat: align voting with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion dc9fdc9143 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion f0f0286866 Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion e3c0db76c9 Adopt shared WebUI layout primitives 2026-08-18 10:42:55 +02:00
zemion 5608022b3f Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:51 +02:00
zemion d8224e4676 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 20:34:12 +02:00
zemion bf248d172d Release v0.1.16
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 19:52:24 +02:00
zemion 98a73622ba Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:20 +02:00
zemion befe8aef82 Make package publication retries hash-safe 2026-08-04 14:32:21 +02:00
zemion 8cfd6bfd48 Establish certifiable Voting provider boundary 2026-08-04 14:01:26 +02:00
zemion 0168c5ecd5 Harden module package publication 2026-08-04 14:01:16 +02:00
20 changed files with 1626 additions and 171 deletions
+83 -22
View File
@@ -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
+24
View File
@@ -20,3 +20,27 @@ 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).
## Git-source WebUI package
The repository root exposes `@govoplan/voting-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/voting-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+94
View File
@@ -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.
+80
View File
@@ -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/)
+17
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@govoplan/voting-webui",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/voting.css": "./webui/src/styles/voting.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-voting"
version = "0.1.14"
version = "0.1.21"
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.45", "govoplan-access>=0.1.18"]
[tool.setuptools.packages.find]
where = ["src"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Voting module."""
__version__ = "0.1.14"
__version__ = "0.1.21"
@@ -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,
+185 -3
View File
@@ -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.21"
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,82 @@ manifest = ModuleManifest(
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="voting.workspace-layout",
title="Voting workspace layout",
summary="Find workspace actions and read consistently arranged content.",
body="Reload and New ballot remain in the workspace-wide top bar, even when a ballot is selected or the catalogue is empty. Reload sits immediately before New. Ballot actions such as opening, casting, certification, challenge, and annulment stay with the selected ballot. New ballot stays visible but disabled without management permission. The shared layout changes no electorate or assurance policy; administrators retain the existing separate management, casting, certification, and administrative grants.",
layer="static",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
order=5,
translations={"de": {
"title": "Abstimmungen: Aufbau des Arbeitsbereichs",
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
"body": "Neu laden und Neue Abstimmung bleiben in der arbeitsbereichsweiten oberen Leiste, auch bei ausgewählter Abstimmung oder leerem Katalog. Neu laden steht unmittelbar vor Neu. Öffnen, Stimmabgabe, Zertifizierung, Anfechtung und Annullierung bleiben bei der ausgewählten Abstimmung. Neue Abstimmung bleibt ohne Verwaltungsrecht sichtbar, aber deaktiviert. Das gemeinsame Layout verändert weder Wählerschaft noch Zusicherung; die getrennten Verwaltungs-, Stimmabgabe-, Zertifizierungs- und Administrationsrechte bleiben bestehen.",
}},
),
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 +406,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 +500,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 +554,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 +575,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
+38
View File
@@ -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 = {
+315
View File
@@ -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()
+13 -1
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/voting-webui",
"version": "0.1.14",
"version": "0.1.21",
"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.45",
"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">
+57 -39
View File
@@ -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,17 @@ import {
FormField,
IconButton,
LoadingIndicator,
MetricCard,
MetricGrid,
PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
hasScope,
i18nMessage,
usePlatformLanguage,
@@ -143,29 +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>
<WorkspaceFrame as="main" className="voting-page" label="Ballots" interfaceId="voting.workspace" helpContextId="voting.workspace" helpModuleId="voting">
<WorkspaceActionBar
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void loadList().catch((reason) => setError(message(reason, "Ballots could not be loaded."))), loading: loading || busy, label: "Refresh ballots" }}
createAction={<Button variant="primary" disabled={!canManage || busy} disabledReason={busy ? VOTING_I18N.busy : !canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>}
helpAction={<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />}
/>
<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={<>
<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}
@@ -295,14 +317,10 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
void cast();
}}
/>
</main>
</WorkspaceFrame>
);
}
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>;
}
+8 -93
View File
@@ -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;