Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64ff8da814 | ||
|
|
7e03fe62ac | ||
|
|
13bdeccdab | ||
|
|
89cc5f0189 | ||
|
|
dc9fdc9143 | ||
|
|
f0f0286866 | ||
|
|
e3c0db76c9 | ||
|
|
5608022b3f | ||
|
|
d8224e4676 | ||
|
|
bf248d172d | ||
|
|
98a73622ba | ||
|
|
befe8aef82 | ||
|
|
8cfd6bfd48 | ||
|
|
0168c5ecd5 |
@@ -14,6 +14,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
publish-packages:
|
publish-packages:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
with:
|
with:
|
||||||
@@ -29,7 +31,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
@@ -43,24 +44,6 @@ jobs:
|
|||||||
echo "Release tag is not contained in main" >&2
|
echo "Release tag is not contained in main" >&2
|
||||||
exit 1
|
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"
|
git checkout --detach "$tag"
|
||||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$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 escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const gitTag = specifier.match(
|
const gitTag = specifier.match(
|
||||||
new RegExp(
|
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) {
|
if (gitTag) {
|
||||||
@@ -180,6 +163,78 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: module-packages-${{ gitea.ref_name }}
|
name: module-packages-${{ gitea.ref_name }}
|
||||||
path: dist/package-artifacts.json
|
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
|
- name: Publish wheel and WebUI package
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -189,13 +244,17 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
test -n "$PACKAGE_USERNAME"
|
test -n "$PACKAGE_USERNAME"
|
||||||
test -n "$PACKAGE_TOKEN"
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
python -m twine upload --non-interactive \
|
python -m twine upload --non-interactive \
|
||||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
dist/*.whl
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact wheel is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
webui_packages=(dist/*.tgz)
|
webui_packages=(dist/*.tgz)
|
||||||
if (( ${#webui_packages[@]} )); then
|
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||||
npmrc="$(mktemp)"
|
npmrc="$(mktemp)"
|
||||||
trap 'rm -f "$npmrc"' EXIT
|
trap 'rm -f "$npmrc"' EXIT
|
||||||
chmod 600 "$npmrc"
|
chmod 600 "$npmrc"
|
||||||
@@ -203,7 +262,9 @@ jobs:
|
|||||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
> "$npmrc"
|
> "$npmrc"
|
||||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "${webui_packages[0]}" \
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
--ignore-scripts --access public \
|
--ignore-scripts --access public \
|
||||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
--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
|
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,
|
See [the domain and assurance boundary](docs/VOTING_DOMAIN.md) for operations,
|
||||||
security, recovery, and integration details.
|
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
|
weighted counts, a result hash, and evidence. GovOPlaN does not claim that a
|
||||||
provider or deployment satisfies legal or certification requirements merely
|
provider or deployment satisfies legal or certification requirements merely
|
||||||
because the adapter contract is implemented.
|
because the adapter contract is implemented.
|
||||||
|
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
|
Core bounds provider evidence to JSON, 64 items and 64 KiB and rejects fields
|
||||||
that can carry credentials, private key material, plaintext, or raw
|
that can carry credentials, private key material, plaintext, or raw
|
||||||
selections before Voting or Committee can persist the projection.
|
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
|
the ballot opens; externally hosted providers may continue to require a
|
||||||
pre-existing reference.
|
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
|
## Lifecycle and concurrency
|
||||||
|
|
||||||
Ballots move through `draft -> open -> closed -> certified`. A closed or
|
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
|
- raw selections are never returned by list, detail, result, or history APIs
|
||||||
- provider result keys must exactly match frozen options
|
- provider result keys must exactly match frozen options
|
||||||
- external results require evidence and cannot exceed the frozen electorate
|
- external results require evidence and cannot exceed the frozen electorate
|
||||||
|
- 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
|
- certification and annulment use separate permissions
|
||||||
|
|||||||
+2
-2
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-voting"
|
name = "govoplan-voting"
|
||||||
version = "0.1.14"
|
version = "0.1.19"
|
||||||
description = "Governed voting, ballot assurance, tally, and certification for GovOPlaN."
|
description = "Governed voting, ballot assurance, tally, and certification for GovOPlaN."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
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]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN Voting module."""
|
"""GovOPlaN Voting module."""
|
||||||
|
|
||||||
__version__ = "0.1.14"
|
__version__ = "0.1.19"
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -21,8 +21,10 @@ from govoplan_core.core.voting import (
|
|||||||
ExternalVotingCastRequest,
|
ExternalVotingCastRequest,
|
||||||
ExternalVotingFinalizationRequest,
|
ExternalVotingFinalizationRequest,
|
||||||
ExternalVotingPreparationRequest,
|
ExternalVotingPreparationRequest,
|
||||||
|
VOTING_CERTIFICATION_NOT_CERTIFIED,
|
||||||
VotingReceipt,
|
VotingReceipt,
|
||||||
VotingResult,
|
VotingResult,
|
||||||
|
VotingProviderAssuranceDeclaration,
|
||||||
)
|
)
|
||||||
from govoplan_voting.backend.db.models import (
|
from govoplan_voting.backend.db.models import (
|
||||||
VotingConfidentialBallot,
|
VotingConfidentialBallot,
|
||||||
@@ -49,6 +51,19 @@ class LocalConfidentialVotingProvider:
|
|||||||
def __init__(self, registry: object | None) -> None:
|
def __init__(self, registry: object | None) -> None:
|
||||||
self._registry = registry
|
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(
|
def prepare_ballot(
|
||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from govoplan_core.core.module_guards import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -27,6 +28,7 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
@@ -37,6 +39,10 @@ from govoplan_core.core.voting import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_voting.backend.db import models as voting_models
|
from govoplan_voting.backend.db import models as voting_models
|
||||||
|
from govoplan_voting.backend.dsar_provider import (
|
||||||
|
VOTING_DSAR_CAPABILITY,
|
||||||
|
VotingDsarProvider,
|
||||||
|
)
|
||||||
from govoplan_voting.backend.service import SqlVotingBallots
|
from govoplan_voting.backend.service import SqlVotingBallots
|
||||||
from govoplan_voting.backend.local_confidential_provider import (
|
from govoplan_voting.backend.local_confidential_provider import (
|
||||||
LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
LOCAL_CONFIDENTIAL_PROVIDER_ID,
|
||||||
@@ -46,7 +52,7 @@ from govoplan_voting.backend.local_confidential_provider import (
|
|||||||
|
|
||||||
MODULE_ID = "voting"
|
MODULE_ID = "voting"
|
||||||
MODULE_NAME = "Voting"
|
MODULE_NAME = "Voting"
|
||||||
MODULE_VERSION = "0.1.14"
|
MODULE_VERSION = "0.1.19"
|
||||||
READ_SCOPE = "voting:ballot:read"
|
READ_SCOPE = "voting:ballot:read"
|
||||||
MANAGE_SCOPE = "voting:ballot:manage"
|
MANAGE_SCOPE = "voting:ballot:manage"
|
||||||
CAST_SCOPE = "voting:ballot:cast"
|
CAST_SCOPE = "voting:ballot:cast"
|
||||||
@@ -95,6 +101,10 @@ def _local_confidential_provider(
|
|||||||
return LocalConfidentialVotingProvider(context.registry)
|
return LocalConfidentialVotingProvider(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(_context: ModuleContext) -> VotingDsarProvider:
|
||||||
|
return VotingDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
current = session.query(voting_models.VotingBallotRevision).filter(
|
current = session.query(voting_models.VotingBallotRevision).filter(
|
||||||
voting_models.VotingBallotRevision.tenant_id == tenant_id,
|
voting_models.VotingBallotRevision.tenant_id == tenant_id,
|
||||||
@@ -124,6 +134,7 @@ manifest = ModuleManifest(
|
|||||||
name=voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID),
|
name=voting_provider_capability(LOCAL_CONFIDENTIAL_PROVIDER_ID),
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceProvider(name=VOTING_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -222,6 +233,17 @@ manifest = ModuleManifest(
|
|||||||
order=39,
|
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=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="voting.navigation",
|
id="voting.navigation",
|
||||||
@@ -252,13 +274,22 @@ manifest = ModuleManifest(
|
|||||||
voting_provider_capability(
|
voting_provider_capability(
|
||||||
LOCAL_CONFIDENTIAL_PROVIDER_ID
|
LOCAL_CONFIDENTIAL_PROVIDER_ID
|
||||||
): _local_confidential_provider,
|
): _local_confidential_provider,
|
||||||
|
VOTING_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation(
|
CAPABILITY_VOTING_BALLOTS: CapabilityDocumentation(
|
||||||
label="Governed ballots",
|
label="Governed ballots",
|
||||||
summary="Creates frozen electorates, records eligible votes, closes deterministic tallies, and certifies aggregate results.",
|
summary="Creates frozen electorates, records eligible votes, closes deterministic tallies, and certifies aggregate results.",
|
||||||
contract_version="0.1.0",
|
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(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
@@ -289,6 +320,67 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
documentation=(
|
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(
|
DocumentationTopic(
|
||||||
id="voting.assurance",
|
id="voting.assurance",
|
||||||
title="Voting assurance and certification",
|
title="Voting assurance and certification",
|
||||||
@@ -296,20 +388,62 @@ manifest = ModuleManifest(
|
|||||||
body=(
|
body=(
|
||||||
"Opening a ballot freezes its definition and electorate hashes. Native recorded ballots retain active vote records for reconstruction; they are not secret. "
|
"Opening a ballot freezes its definition and electorate hashes. Native recorded ballots retain active vote records for reconstruction; they are not secret. "
|
||||||
"Confidential, secret, and externally certified profiles require an installed provider and retain only aggregate results, receipts, hashes, and evidence. "
|
"Confidential, secret, and externally certified profiles require an installed provider and retain only aggregate results, receipts, hashes, and evidence. "
|
||||||
|
"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. "
|
"The bundled local confidential provider encrypts raw selections through Encryption and supports interactive casting, but remains server-readable and uncertified. "
|
||||||
"Closure, certification, challenge, and annulment remain separate auditable transitions."
|
"Closure, certification, challenge, and annulment remain separate auditable transitions."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "operator", "module_admin", "product_owner", "auditor"),
|
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=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
label="Voting domain and assurance boundary",
|
label="Voting domain and assurance boundary",
|
||||||
href="govoplan-voting/docs/VOTING_DOMAIN.md",
|
href="govoplan-voting/docs/VOTING_DOMAIN.md",
|
||||||
kind="repository",
|
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={
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"voting.navigation",
|
"voting.navigation",
|
||||||
@@ -348,7 +482,27 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
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={
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
"seed": True,
|
"seed": True,
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
"voting.field.assurance-profile",
|
"voting.field.assurance-profile",
|
||||||
@@ -382,6 +536,7 @@ manifest = ModuleManifest(
|
|||||||
"The native profile is recorded and reconstructable, not cryptographically secret.",
|
"The native profile is recorded and reconstructable, not cryptographically secret.",
|
||||||
"Confidential, secret, and externally certified profiles require an installed provider capability and fail closed otherwise.",
|
"Confidential, secret, and externally certified profiles require an installed provider capability and fail closed otherwise.",
|
||||||
"The bundled local confidential provider is server-decryptable and is neither anonymous, coercion-resistant, secret, nor externally certified.",
|
"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.",
|
"Formal public-election certification remains a deployment-specific legal, organizational, and provider assurance decision.",
|
||||||
),
|
),
|
||||||
supported_authority_modes=("native_authoritative", "external_authoritative"),
|
supported_authority_modes=("native_authoritative", "external_authoritative"),
|
||||||
@@ -402,7 +557,11 @@ manifest = ModuleManifest(
|
|||||||
reference_packages=("product.service-to-decision",),
|
reference_packages=("product.service-to-decision",),
|
||||||
migration_docs=("docs/VOTING_DOMAIN.md",),
|
migration_docs=("docs/VOTING_DOMAIN.md",),
|
||||||
recovery_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",),
|
operations_docs=("docs/VOTING_DOMAIN.md",),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ from govoplan_core.core.voting import (
|
|||||||
VotingBallotCreateCommand,
|
VotingBallotCreateCommand,
|
||||||
VotingBallotRef,
|
VotingBallotRef,
|
||||||
VotingCastCommand,
|
VotingCastCommand,
|
||||||
|
VotingCapabilityError,
|
||||||
VotingReceipt,
|
VotingReceipt,
|
||||||
VotingResult,
|
VotingResult,
|
||||||
|
require_voting_provider_assurance,
|
||||||
voting_provider_capability,
|
voting_provider_capability,
|
||||||
)
|
)
|
||||||
from govoplan_voting.backend.db.models import (
|
from govoplan_voting.backend.db.models import (
|
||||||
@@ -265,6 +267,16 @@ class SqlVotingBallots:
|
|||||||
raise VotingStoreError(
|
raise VotingStoreError(
|
||||||
"The selected Voting assurance profile requires an available external provider."
|
"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)
|
definition_hash, electorate_hash = _frozen_hashes(payload)
|
||||||
payload["definition_sha256"] = definition_hash
|
payload["definition_sha256"] = definition_hash
|
||||||
payload["electorate_sha256"] = electorate_hash
|
payload["electorate_sha256"] = electorate_hash
|
||||||
@@ -343,6 +355,7 @@ class SqlVotingBallots:
|
|||||||
"electorate_sha256": electorate_hash,
|
"electorate_sha256": electorate_hash,
|
||||||
"provider_id": payload.get("provider_id"),
|
"provider_id": payload.get("provider_id"),
|
||||||
"provider_ballot_ref": payload.get("provider_ballot_ref"),
|
"provider_ballot_ref": payload.get("provider_ballot_ref"),
|
||||||
|
"provider_assurance": payload.get("provider_assurance"),
|
||||||
"provider_evidence": [dict(item) for item in provider_evidence],
|
"provider_evidence": [dict(item) for item in provider_evidence],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -403,6 +416,7 @@ class SqlVotingBallots:
|
|||||||
raise VotingStoreError(
|
raise VotingStoreError(
|
||||||
"This Voting provider does not expose an interactive cast capability."
|
"This Voting provider does not expose an interactive cast capability."
|
||||||
)
|
)
|
||||||
|
_require_pinned_provider_assurance(current.payload, provider)
|
||||||
try:
|
try:
|
||||||
receipt = provider.cast_ballot(
|
receipt = provider.cast_ballot(
|
||||||
typed_session,
|
typed_session,
|
||||||
@@ -863,6 +877,7 @@ class SqlVotingBallots:
|
|||||||
provider = _capability(self._registry, voting_provider_capability(provider_id))
|
provider = _capability(self._registry, voting_provider_capability(provider_id))
|
||||||
if not isinstance(provider, ExternalVotingProvider):
|
if not isinstance(provider, ExternalVotingProvider):
|
||||||
raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.")
|
raise VotingStoreError(f"Voting provider is unavailable: {provider_id}.")
|
||||||
|
_require_pinned_provider_assurance(current.payload, provider)
|
||||||
electorate = list(current.payload["electorate"])
|
electorate = list(current.payload["electorate"])
|
||||||
try:
|
try:
|
||||||
result = provider.finalize_ballot(
|
result = provider.finalize_ballot(
|
||||||
@@ -944,6 +959,7 @@ def _payload_from_command(command: VotingBallotCreateCommand) -> dict[str, Any]:
|
|||||||
"closes_at": _datetime_text(command.closes_at),
|
"closes_at": _datetime_text(command.closes_at),
|
||||||
"provider_id": _optional_text(command.provider_id),
|
"provider_id": _optional_text(command.provider_id),
|
||||||
"provider_ballot_ref": _optional_text(command.provider_ballot_ref),
|
"provider_ballot_ref": _optional_text(command.provider_ballot_ref),
|
||||||
|
"provider_assurance": None,
|
||||||
"metadata": dict(command.metadata),
|
"metadata": dict(command.metadata),
|
||||||
"definition_sha256": None,
|
"definition_sha256": None,
|
||||||
"electorate_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.")
|
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]:
|
def _frozen_hashes(payload: Mapping[str, Any]) -> tuple[str, str]:
|
||||||
electorate = list(payload.get("electorate") or [])
|
electorate = list(payload.get("electorate") or [])
|
||||||
definition = {
|
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):
|
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:
|
def test_route_and_surfaces_remain_declared(self) -> None:
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
self.assertIsNotNone(frontend)
|
self.assertIsNotNone(frontend)
|
||||||
@@ -21,9 +29,13 @@ class VotingInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
reference = topics["voting.reference.fields-and-consequences"]
|
reference = topics["voting.reference.fields-and-consequences"]
|
||||||
self.assertIn("voting.ballot", guide.metadata["help_contexts"])
|
self.assertIn("voting.ballot", guide.metadata["help_contexts"])
|
||||||
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
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("cast", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("challenge_or_annul", reference.metadata["consequence_classes"])
|
self.assertIn("challenge_or_annul", reference.metadata["consequence_classes"])
|
||||||
|
self.assertEqual("reference", reference.metadata["kind"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+104
-1
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
import hashlib
|
import hashlib
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
@@ -10,10 +10,13 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from govoplan_core.core.voting import (
|
from govoplan_core.core.voting import (
|
||||||
|
VOTING_CERTIFICATION_CERTIFIED,
|
||||||
|
VOTING_CERTIFICATION_IN_EVALUATION,
|
||||||
VotingBallotCreateCommand,
|
VotingBallotCreateCommand,
|
||||||
VotingCastCommand,
|
VotingCastCommand,
|
||||||
VotingElector,
|
VotingElector,
|
||||||
VotingOption,
|
VotingOption,
|
||||||
|
VotingProviderAssuranceDeclaration,
|
||||||
voting_provider_capability,
|
voting_provider_capability,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.encryption import (
|
from govoplan_core.core.encryption import (
|
||||||
@@ -51,6 +54,17 @@ class FakeRegistry:
|
|||||||
return self.capabilities.get(name)
|
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:
|
class FakeKeyVault:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.vaults: dict[tuple[str, str], object] = {}
|
self.vaults: dict[tuple[str, str], object] = {}
|
||||||
@@ -290,6 +304,95 @@ class VotingTests(unittest.TestCase):
|
|||||||
idempotency_key="open-secret",
|
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(
|
def test_local_confidential_provider_encrypts_casts_and_returns_aggregates(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/voting-webui",
|
"name": "@govoplan/voting-webui",
|
||||||
"version": "0.1.14",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"./styles/voting.css": "./src/styles/voting.css"
|
"./styles/voting.css": "./src/styles/voting.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.14",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20"
|
"react-dom": ">=19.2.7 <20"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { FormGrid,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
@@ -118,6 +118,7 @@ export default function VotingBallotDialog({
|
|||||||
onClose={requestClose}
|
onClose={requestClose}
|
||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
portal
|
portal
|
||||||
|
size="wide"
|
||||||
className="voting-ballot-dialog"
|
className="voting-ballot-dialog"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -128,7 +129,7 @@ export default function VotingBallotDialog({
|
|||||||
<div className="voting-ballot-editor">
|
<div className="voting-ballot-editor">
|
||||||
<div className="voting-editor-help"><DocumentationHelpLink reference={VOTING_FIELD_DOCUMENTATION} /></div>
|
<div className="voting-editor-help"><DocumentationHelpLink reference={VOTING_FIELD_DOCUMENTATION} /></div>
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
{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="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
|
||||||
<FormField label="Method">
|
<FormField label="Method">
|
||||||
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value as VotingBallotDraft["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>
|
</FormField>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</FormGrid>
|
||||||
|
|
||||||
<EditorHeading title="Options" onAdd={() => setDraft({ ...draft, options: [...draft.options, { key: `option-${draft.options.length + 1}`, label: "", description: "" }] })} disabled={busy} />
|
<EditorHeading title="Options" onAdd={() => setDraft({ ...draft, options: [...draft.options, { key: `option-${draft.options.length + 1}`, label: "", description: "" }] })} disabled={busy} />
|
||||||
<div className="voting-editor-list">
|
<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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { ActionBlockerHint,
|
||||||
ActionBlockerHint,
|
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -10,8 +9,16 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
|
MetricCard,
|
||||||
|
MetricGrid,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceLayout,
|
||||||
hasScope,
|
hasScope,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
@@ -144,28 +151,44 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="voting-page">
|
<main className="voting-page">
|
||||||
<div className="voting-shell">
|
<WorkspaceLayout
|
||||||
<aside className="voting-catalogue">
|
variant="split"
|
||||||
<div className="voting-toolbar">
|
primarySize="default"
|
||||||
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? VOTING_I18N.loading : busy ? VOTING_I18N.busy : undefined} onClick={() => void loadList()} />
|
primaryScrollable={false}
|
||||||
<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>
|
contentScrollable={false}
|
||||||
<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />
|
surface="contained"
|
||||||
</div>
|
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">
|
<PageScrollViewport className="voting-list-viewport">
|
||||||
{loading && <LoadingIndicator label="Loading ballots" />}
|
{loading && <LoadingIndicator label="Loading ballots" />}
|
||||||
{!loading && items.length === 0 && <div className="voting-empty">No ballots.</div>}
|
{!loading && items.length === 0 && <StatePanel size="compact" description="No ballots." />}
|
||||||
<div className="voting-list" role="list">
|
<SelectionList variant="navigation" label="Ballots">
|
||||||
{items.map((item) => <button type="button" role="listitem" className={`voting-list-row${item.id === selectedId ? " is-selected" : ""}`} key={item.id} onClick={() => setSelectedId(item.id)}>
|
{items.map((item) => <SelectionListItem selected={item.id === selectedId} key={item.id} onClick={() => setSelectedId(item.id)}>
|
||||||
<span><strong>{item.title}</strong><small>{humanize(item.assurance_profile)}</small></span>
|
<SelectionListItemContent title={item.title} description={humanize(item.assurance_profile)} />
|
||||||
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
||||||
</button>)}
|
</SelectionListItem>)}
|
||||||
</div>
|
</SelectionList>
|
||||||
</PageScrollViewport>
|
</PageScrollViewport>
|
||||||
</aside>
|
</>}
|
||||||
<section className="voting-workspace">
|
>
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</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">
|
{selected && <PageScrollViewport className="voting-detail-viewport">
|
||||||
<div className="voting-detail-heading">
|
<div className="voting-detail-heading">
|
||||||
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
|
<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>}
|
{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>
|
</div>
|
||||||
<div className="voting-metrics">
|
<MetricGrid columns={4} spacing="block">
|
||||||
<Metric label="Electors" value={selected.electorate.length} />
|
<MetricCard density="compact" label="Electors" value={selected.electorate.length} />
|
||||||
<Metric label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
|
<MetricCard density="compact" label="Eligible weight" value={selected.electorate.reduce((total, item) => total + item.weight, 0)} />
|
||||||
<Metric label="Quorum" value={selected.quorum_weight} />
|
<MetricCard density="compact" label="Quorum" value={selected.quorum_weight} />
|
||||||
<Metric label="Assurance" value={humanize(selected.assurance_profile)} />
|
<MetricCard density="compact" label="Assurance" value={humanize(selected.assurance_profile)} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
{selected.description && <p className="voting-description">{selected.description}</p>}
|
{selected.description && <p className="voting-description">{selected.description}</p>}
|
||||||
{selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible && <section className="voting-cast-panel">
|
{selected.state === "open" && selected.assurance_profile === "recorded" && canCast && eligible && <section className="voting-cast-panel">
|
||||||
<h3>Cast vote</h3>
|
<h3>Cast vote</h3>
|
||||||
@@ -215,12 +238,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
</section>
|
</section>
|
||||||
{selected.result && <section className="voting-section">
|
{selected.result && <section className="voting-section">
|
||||||
<h3>Result</h3>
|
<h3>Result</h3>
|
||||||
<div className="voting-metrics">
|
<MetricGrid columns={4} spacing="block">
|
||||||
<Metric label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
|
<MetricCard density="compact" label="Votes" value={`${selected.result.cast_count} / ${selected.result.eligible_count}`} />
|
||||||
<Metric label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
|
<MetricCard density="compact" label="Cast weight" value={`${selected.result.cast_weight} / ${selected.result.eligible_weight}`} />
|
||||||
<Metric label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
|
<MetricCard density="compact" label="Quorum" value={selected.result.quorum_met ? "Met" : "Not met"} />
|
||||||
<Metric label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
|
<MetricCard density="compact" label="Threshold" value={selected.result.threshold_met ? "Met" : "Not met"} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<Hash label="Result hash" value={selected.result.result_sha256} />
|
<Hash label="Result hash" value={selected.result.result_sha256} />
|
||||||
</section>}
|
</section>}
|
||||||
<section className="voting-section voting-assurance">
|
<section className="voting-section voting-assurance">
|
||||||
@@ -235,8 +258,7 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</PageScrollViewport>}
|
</PageScrollViewport>}
|
||||||
</section>
|
</WorkspaceLayout>
|
||||||
</div>
|
|
||||||
{editing && <VotingBallotDialog
|
{editing && <VotingBallotDialog
|
||||||
open
|
open
|
||||||
settings={settings}
|
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 }) {
|
function Hash({ label, value }: { label: string; value?: string | null }) {
|
||||||
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
|
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,12 @@
|
|||||||
overflow: hidden;
|
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 {
|
.voting-catalogue {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--border-color, #d8dde3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-toolbar,
|
|
||||||
.voting-detail-heading,
|
.voting-detail-heading,
|
||||||
.voting-actions,
|
.voting-actions,
|
||||||
.voting-editor-heading {
|
.voting-editor-heading {
|
||||||
@@ -28,54 +18,20 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-toolbar {
|
|
||||||
min-height: 50px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-color, #d8dde3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-list-viewport,
|
.voting-list-viewport,
|
||||||
.voting-detail-viewport {
|
.voting-detail-viewport {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
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 {
|
.voting-option-results > div > span:first-child {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-list-row small,
|
|
||||||
.voting-option-results small {
|
.voting-option-results small {
|
||||||
color: var(--text-muted, #65717e);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-workspace {
|
.voting-workspace {
|
||||||
@@ -96,7 +52,7 @@
|
|||||||
.voting-detail-heading {
|
.voting-detail-heading {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
border-bottom: 1px solid var(--border-color, #d8dde3);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-detail-heading h2,
|
.voting-detail-heading h2,
|
||||||
@@ -109,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.voting-detail-heading > div:first-child span {
|
.voting-detail-heading > div:first-child span {
|
||||||
color: var(--text-muted, #65717e);
|
color: var(--muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,25 +74,8 @@
|
|||||||
justify-content: flex-end;
|
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 {
|
.voting-hash span {
|
||||||
color: var(--text-muted, #65717e);
|
color: var(--muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -149,7 +88,7 @@
|
|||||||
.voting-cast-panel {
|
.voting-cast-panel {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
border-top: 1px solid var(--border-color, #d8dde3);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-options,
|
.voting-options,
|
||||||
@@ -170,7 +109,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
background: var(--surface-muted, rgba(127, 137, 147, 0.08));
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-options label > span {
|
.voting-options label > span {
|
||||||
@@ -183,7 +122,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.voting-history time {
|
.voting-history time {
|
||||||
color: var(--text-muted, #65717e);
|
color: var(--muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,13 +139,7 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-empty {
|
|
||||||
padding: 24px;
|
|
||||||
color: var(--text-muted, #65717e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-ballot-dialog {
|
.voting-ballot-dialog {
|
||||||
width: min(1040px, calc(100vw - 32px));
|
|
||||||
height: min(820px, calc(100vh - 32px));
|
height: min(820px, calc(100vh - 32px));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,12 +156,6 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-editor-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-editor-wide {
|
.voting-editor-wide {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -262,19 +189,7 @@
|
|||||||
grid-template-columns: minmax(180px, 1.2fr) minmax(160px, 1fr) 90px 34px;
|
grid-template-columns: minmax(180px, 1.2fr) minmax(160px, 1fr) 90px 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 760px) {
|
||||||
.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,
|
|
||||||
.voting-option-row,
|
.voting-option-row,
|
||||||
.voting-elector-row {
|
.voting-elector-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user