13 Commits
Author SHA1 Message Date
zemion 6edb1336af fix(packaging): expose immutable WebUI Git package for v0.1.20
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:09 +02:00
zemion df64af4d10 docs(encryption): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 19:31:23 +02:00
zemion 277d5f01e4 feat(encryption): add governed DSAR coverage 2026-08-21 13:15:26 +02:00
zemion 898d68aa1a style: use shared WebUI foundation tokens 2026-08-18 21:32:50 +02:00
zemion 1a24b373d6 Adopt shared WebUI layout primitives 2026-08-18 11:30:39 +02:00
zemion f8c22396b3 Adopt shared WebUI layout primitives 2026-08-18 10:42:51 +02:00
zemion 211cd2c950 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:44 +02:00
zemion 25f0163f59 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:58 +02:00
zemion 029406e9ee Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:06 +02:00
zemion e1d0742c46 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 12s
2026-08-04 15:10:19 +02:00
zemion 869f7bbdc4 Make package publication retries hash-safe 2026-08-04 14:32:19 +02:00
zemion 517dfb720e Record governed encryption lifecycle defaults 2026-08-04 14:01:44 +02:00
zemion 99a5fa52a5 Harden module package publication 2026-08-04 14:01:36 +02:00
13 changed files with 1271 additions and 59 deletions
+87 -26
View File
@@ -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"
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \ if [[ "$PUBLISH_PYPI" == 1 ]]; then
python -m twine upload --non-interactive \ TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \ python -m twine upload --non-interactive \
dist/*.whl --repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob 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
+18
View File
@@ -62,3 +62,21 @@ cd /mnt/DATA/git/govoplan-encryption
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \ PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
``` ```
## Git-source WebUI package
The repository root exposes `@govoplan/encryption-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/encryption-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+31
View File
@@ -18,6 +18,37 @@ certification claim. Stronger claims require a named protection profile whose
provider, clients, algorithms, backup procedure, and failure tests have passed provider, clients, algorithms, backup procedure, and failure tests have passed
the profile's conformance and security review. the profile's conformance and security review.
## Approved Product Baseline
The product decision recorded on 2026-08-04 separates module activation from
content protection. Enabling Encryption makes its capabilities and
administration available but never encrypts existing or new content by itself.
- System definitions are reusable profile templates. Tenants explicitly
activate profiles for an owner-module collection or individual object.
Accounts and groups remain policy subjects rather than cryptographic scopes.
- New objects inherit protection only inside an explicitly activated scope.
Existing objects move through an explicit, checkpointed, copy-on-write
migration with verified cutover.
- The first production profile is managed server-envelope encryption backed by
a KMS/HSM provider. The bundled local provider remains a bounded reference
implementation. Tenant-held and client E2EE profiles require separate
provider approval and conformance evidence.
- Recovery defaults to a high-assurance, distinct-custodian 2-of-3 quorum with
requester separation. Ordinary platform administration does not bypass the
ceremony.
- Horizontally scaled nodes may receive only short-lived, in-memory unwrap
grants. They do not persist usable key material on node-local storage.
- Disable remains blocked until every protected object is verifiably decrypted,
migrated, exported, or destroyed. Externally opaque ciphertext must be
resolved through its provider before the owning profile can be removed.
After a migration has retired plaintext, reversal is another governed
migration rather than retention of an undisclosed plaintext rollback copy.
True E2EE is deliberately not part of the first production profile because it
changes search, server-side processing, inspection, reporting, legal-hold
export, and recovery guarantees.
## Ownership Boundary ## Ownership Boundary
Encryption owns: Encryption owns:
+28 -3
View File
@@ -1,8 +1,33 @@
{ {
"name": "@govoplan/encryption", "name": "@govoplan/encryption-webui",
"version": "0.1.14", "version": "0.1.20",
"private": true, "private": true,
"description": "GovOPlaN encryption platform module scaffold.", "description": "GovOPlaN encryption platform module scaffold.",
"type": "module", "type": "module",
"peerDependencies": {} "peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/encryption.css": "./webui/src/styles/encryption.css"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
} }
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-encryption" name = "govoplan-encryption"
version = "0.1.14" version = "0.1.20"
description = "Optional key-vault and content-protection capabilities for GovOPlaN." description = "Optional key-vault and content-protection capabilities for GovOPlaN."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = ["cryptography>=44", "govoplan-core>=0.1.14"] dependencies = ["cryptography>=44", "govoplan-core>=0.1.18"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
@@ -0,0 +1,527 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_encryption.backend.db.models import (
ContentProtectionRecord,
EncryptionKeyOperation,
EncryptionVault,
ProtectionMigration,
RecoveryApproval,
RecoveryCeremony,
)
ENCRYPTION_DSAR_CAPABILITY = dsar_capability_name("encryption")
_MAX_RECORDS = 5_000
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
account_id: str
vault_id: str | None
key_operation_id: str | None
envelope_id: str | None
migration_id: str | None
recovery_id: str | None
@property
def narrowed(self) -> bool:
return any(
(
self.vault_id,
self.key_operation_id,
self.envelope_id,
self.migration_id,
self.recovery_id,
)
)
class EncryptionDsarProvider:
provider_id = "encryption"
module_id = "encryption"
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] = []
if not selectors.narrowed or selectors.vault_id:
query = db.query(EncryptionVault).filter(
EncryptionVault.tenant_id == tenant_id,
or_(
EncryptionVault.created_by == selectors.account_id,
EncryptionVault.updated_by == selectors.account_id,
),
)
if selectors.vault_id:
query = query.filter(EncryptionVault.vault_id == selectors.vault_id)
records.extend(
_vault_attribution(row, selectors.account_id)
for row in _limited(
query,
EncryptionVault.created_at,
EncryptionVault.id,
label="vault attribution",
)
)
if not selectors.narrowed or selectors.vault_id or selectors.key_operation_id:
query = db.query(EncryptionKeyOperation).filter(
EncryptionKeyOperation.tenant_id == tenant_id,
EncryptionKeyOperation.requested_by == selectors.account_id,
)
if selectors.vault_id:
query = query.filter(
EncryptionKeyOperation.vault_id == selectors.vault_id
)
if selectors.key_operation_id:
query = query.filter(
EncryptionKeyOperation.id == selectors.key_operation_id
)
records.extend(
_key_operation_attribution(row)
for row in _limited(
query,
EncryptionKeyOperation.created_at,
EncryptionKeyOperation.id,
label="key-operation attribution",
)
)
if not selectors.narrowed or selectors.vault_id or selectors.envelope_id:
query = db.query(ContentProtectionRecord).filter(
ContentProtectionRecord.tenant_id == tenant_id,
ContentProtectionRecord.registered_by == selectors.account_id,
)
if selectors.vault_id:
query = query.filter(
ContentProtectionRecord.vault_id == selectors.vault_id
)
if selectors.envelope_id:
query = query.filter(
ContentProtectionRecord.envelope_id == selectors.envelope_id
)
records.extend(
_protection_attribution(row)
for row in _limited(
query,
ContentProtectionRecord.created_at,
ContentProtectionRecord.id,
label="content-protection attribution",
)
)
if not selectors.narrowed or selectors.vault_id or selectors.migration_id:
query = db.query(ProtectionMigration).filter(
ProtectionMigration.tenant_id == tenant_id,
ProtectionMigration.requested_by == selectors.account_id,
)
if selectors.vault_id:
query = query.filter(
ProtectionMigration.target_vault_id == selectors.vault_id
)
if selectors.migration_id:
query = query.filter(ProtectionMigration.id == selectors.migration_id)
records.extend(
_migration_attribution(row)
for row in _limited(
query,
ProtectionMigration.created_at,
ProtectionMigration.id,
label="migration attribution",
)
)
if not selectors.narrowed or selectors.vault_id or selectors.recovery_id:
ceremonies = db.query(RecoveryCeremony).filter(
RecoveryCeremony.tenant_id == tenant_id,
RecoveryCeremony.requester_account_id == selectors.account_id,
)
if selectors.vault_id:
ceremonies = ceremonies.filter(
RecoveryCeremony.vault_id == selectors.vault_id
)
if selectors.recovery_id:
ceremonies = ceremonies.filter(
RecoveryCeremony.id == selectors.recovery_id
)
records.extend(
_recovery_request_attribution(row)
for row in _limited(
ceremonies,
RecoveryCeremony.created_at,
RecoveryCeremony.id,
label="recovery-request attribution",
)
)
approvals = db.query(RecoveryApproval).filter(
RecoveryApproval.tenant_id == tenant_id,
RecoveryApproval.approver_account_id == selectors.account_id,
)
if selectors.recovery_id:
approvals = approvals.filter(
RecoveryApproval.recovery_id == selectors.recovery_id
)
elif selectors.vault_id:
approvals = approvals.filter(
RecoveryApproval.recovery_id.in_(
db.query(RecoveryCeremony.id).filter(
RecoveryCeremony.tenant_id == tenant_id,
RecoveryCeremony.vault_id == selectors.vault_id,
)
)
)
records.extend(
_recovery_approval_attribution(row)
for row in _limited(
approvals,
RecoveryApproval.created_at,
RecoveryApproval.id,
label="recovery-approval attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError("Encryption DSAR result limit exceeded; narrow 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("Encryption DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
actions.append(
DsarErasureActionRef(
action_id=(
f"encryption: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 "Cryptographic custody evidence remains immutable."
),
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("Encryption DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind != "retain":
raise ValueError("Encryption DSAR publishes retain actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Cryptographic lifecycle and custody attribution remains "
"immutable security evidence."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
account = _coalesce(
subject.account_id,
references.get("encryption.account"),
references.get("access.account"),
)
values = {
"vault_id": _coalesce(
references.get("encryption.vault"),
references.get("encryption.vault_id"),
),
"key_operation_id": _coalesce(
references.get("encryption.key_operation"),
references.get("encryption.key_operation_id"),
),
"envelope_id": _coalesce(
references.get("encryption.envelope"),
references.get("encryption.envelope_id"),
),
"migration_id": _coalesce(
references.get("encryption.migration"),
references.get("encryption.migration_id"),
),
"recovery_id": _coalesce(
references.get("encryption.recovery"),
references.get("encryption.recovery_id"),
),
}
if account is _CONFLICT or any(value is _CONFLICT for value in values.values()):
return None
account_id = _optional_string(account)
if not account_id:
return None
return _SubjectSelectors(
account_id=account_id,
vault_id=_optional_string(values["vault_id"]),
key_operation_id=_optional_string(values["key_operation_id"]),
envelope_id=_optional_string(values["envelope_id"]),
migration_id=_optional_string(values["migration_id"]),
recovery_id=_optional_string(values["recovery_id"]),
)
def _vault_attribution(row: EncryptionVault, account_id: str) -> DsarRecordRef:
activities = []
if row.created_by == account_id:
activities.append("created_vault")
if row.updated_by == account_id:
activities.append("updated_vault")
return _record(
resource_type="vault_actor_attribution",
resource_id=row.id,
title="Encryption vault actor attribution",
data={
"vault_id": row.vault_id,
"purpose": row.purpose[:255],
"profile_kind": row.profile_kind,
"scope_type": row.scope_type,
"state": row.state,
"revision": row.revision,
"current_key_version": row.current_key_version,
"recovery_quorum": row.recovery_quorum,
"activities": activities,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
observed_at=row.updated_at,
)
def _key_operation_attribution(row: EncryptionKeyOperation) -> DsarRecordRef:
return _record(
resource_type="key_operation_actor_attribution",
resource_id=row.id,
title="Encryption key-operation actor attribution",
data={
"key_operation_id": row.id,
"vault_id": row.vault_id,
"key_version": row.key_version,
"operation": row.operation,
"state": row.state,
"activity": "requested_key_operation",
"created_at": _iso(row.created_at),
"completed_at": _iso(row.completed_at),
},
observed_at=row.completed_at or row.created_at,
)
def _protection_attribution(row: ContentProtectionRecord) -> DsarRecordRef:
return _record(
resource_type="content_protection_actor_attribution",
resource_id=row.id,
title="Content-protection registration attribution",
data={
"protection_record_id": row.id,
"owner_module": row.owner_module,
"resource_type": row.resource_type,
"profile_kind": row.profile_kind,
"state": row.state,
"activity": "registered_content_protection",
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
observed_at=row.updated_at,
)
def _migration_attribution(row: ProtectionMigration) -> DsarRecordRef:
return _record(
resource_type="protection_migration_actor_attribution",
resource_id=row.id,
title="Protection-migration actor attribution",
data={
"migration_id": row.id,
"mode": row.mode,
"state": row.state,
"activity": "requested_protection_migration",
"created_at": _iso(row.created_at),
"completed_at": _iso(row.completed_at),
},
observed_at=row.completed_at or row.created_at,
)
def _recovery_request_attribution(row: RecoveryCeremony) -> DsarRecordRef:
return _record(
resource_type="recovery_request_actor_attribution",
resource_id=row.id,
title="Encryption recovery-request attribution",
data={
"recovery_id": row.id,
"vault_id": row.vault_id,
"state": row.state,
"quorum": row.quorum,
"revision": row.revision,
"expires_at": _iso(row.expires_at),
"activity": "requested_recovery_ceremony",
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
},
observed_at=row.updated_at,
)
def _recovery_approval_attribution(row: RecoveryApproval) -> DsarRecordRef:
return _record(
resource_type="recovery_approval_actor_attribution",
resource_id=row.id,
title="Encryption recovery-approval attribution",
data={
"recovery_approval_id": row.id,
"recovery_id": row.recovery_id,
"decision": row.decision,
"activity": "decided_recovery_approval",
"created_at": _iso(row.created_at),
},
observed_at=row.created_at,
)
def _record(
*,
resource_type: str,
resource_id: str,
title: str,
data: dict[str, object],
observed_at: datetime | None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="encryption",
module_id="encryption",
resource_type=resource_type,
resource_id=resource_id,
category="cryptographic_custody_attribution",
title=title,
data=data,
observed_at=_aware(observed_at),
immutable_evidence=True,
retention_reason=(
"Cryptographic lifecycle and custody attribution is retained as immutable "
"security and accountability 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"Encryption 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 _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("Encryption DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"vault_actor_attribution",
"key_operation_actor_attribution",
"content_protection_actor_attribution",
"protection_migration_actor_attribution",
"recovery_request_actor_attribution",
"recovery_approval_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "encryption" or record.module_id != "encryption":
raise ValueError("Encryption DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Encryption DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "encryption" or action.module_id != "encryption":
raise ValueError("Encryption DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("encryption:retain:"):
raise ValueError("Encryption DSAR action identity is invalid.")
__all__ = ["ENCRYPTION_DSAR_CAPABILITY", "EncryptionDsarProvider"]
+147 -1
View File
@@ -17,6 +17,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,
@@ -32,6 +33,10 @@ from govoplan_core.core.modules import (
from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_encryption.backend.db import models from govoplan_encryption.backend.db import models
from govoplan_encryption.backend.dsar_provider import (
ENCRYPTION_DSAR_CAPABILITY,
EncryptionDsarProvider,
)
from govoplan_encryption.backend.local_provider import ( from govoplan_encryption.backend.local_provider import (
LOCAL_PROVIDER_ID, LOCAL_PROVIDER_ID,
LocalAesGcmProvider, LocalAesGcmProvider,
@@ -41,7 +46,7 @@ from govoplan_encryption.backend.service import SqlEncryptionService
MODULE_ID = "encryption" MODULE_ID = "encryption"
MODULE_NAME = "Encryption" MODULE_NAME = "Encryption"
MODULE_VERSION = "0.1.14" MODULE_VERSION = "0.1.20"
USE_SCOPE = "encryption:vault:use" USE_SCOPE = "encryption:vault:use"
ADMIN_SCOPE = "encryption:vault:admin" ADMIN_SCOPE = "encryption:vault:admin"
@@ -88,6 +93,10 @@ def _local_provider(context: ModuleContext) -> LocalAesGcmProvider:
return LocalAesGcmProvider(getattr(context.settings, "master_key_b64", None)) return LocalAesGcmProvider(getattr(context.settings, "master_key_b64", None))
def _dsar_provider(_context: ModuleContext) -> EncryptionDsarProvider:
return EncryptionDsarProvider()
def _disable_guard( def _disable_guard(
session: object | None, session: object | None,
_module_id: str, _module_id: str,
@@ -188,6 +197,7 @@ manifest = ModuleManifest(
name=CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT, name=CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
version="1.0.0", version="1.0.0",
), ),
ModuleInterfaceProvider(name=ENCRYPTION_DSAR_CAPABILITY, version="0.1.0"),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
@@ -235,6 +245,7 @@ manifest = ModuleManifest(
CAPABILITY_ENCRYPTION_CONTENT_CIPHER: _service, CAPABILITY_ENCRYPTION_CONTENT_CIPHER: _service,
CAPABILITY_ENCRYPTION_RECOVERY: _service, CAPABILITY_ENCRYPTION_RECOVERY: _service,
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT: _service, CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT: _service,
ENCRYPTION_DSAR_CAPABILITY: _dsar_provider,
f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}": _local_provider, f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}": _local_provider,
f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}": _local_provider, f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}": _local_provider,
}, },
@@ -281,6 +292,14 @@ manifest = ModuleManifest(
), ),
contract_version="1.0.0", contract_version="1.0.0",
), ),
ENCRYPTION_DSAR_CAPABILITY: CapabilityDocumentation(
label="Encryption data-subject request provider",
summary=(
"Exports minimized cryptographic custody attribution without key "
"material, ciphertext references, or protected evidence."
),
contract_version="0.1.0",
),
}, },
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
@@ -322,6 +341,80 @@ manifest = ModuleManifest(
), ),
), ),
documentation=( documentation=(
DocumentationTopic(
id="encryption.data-subject-requests",
title="Encryption data-subject requests",
summary=(
"Export cryptographic custody participation without exposing protected "
"content or key material."
),
body=(
"Encryption correlates only an exact tenant account identifier and can "
"narrow an already verified search to a vault, key operation, envelope, "
"migration, or recovery ceremony. The export reports minimized vault "
"administration, key-operation requests, protection registrations, "
"migrations, and recovery participation. It never returns provider or "
"public key references, wrapped keys, nonces, ciphertext locations, "
"resource identifiers, digests, request payloads, assurance and policy "
"references, recovery reasons, idempotency keys, or provenance. Feature "
"modules remain responsible for exporting the plaintext semantics of "
"their own protected resources. Cryptographic lifecycle and custody "
"attribution remains immutable security evidence and is retained rather "
"than automatically erased."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "administrator", "security_officer", "auditor"),
related_modules=("core", "identity_trust", "audit", "policy"),
order=95,
metadata={
"kind": "reference",
"help_contexts": [
"encryption.admin.operations",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_custody_attribution": (
"Returns minimized lifecycle activity for the exact account."
),
"exclude_cryptographic_secrets": (
"Never returns key material, ciphertext references, nonces, or protected evidence."
),
"retain_cryptographic_evidence": (
"Preserves immutable custody and recovery accountability."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zur Verschlüsselung",
"summary": (
"Mitwirkung an kryptografischer Verwahrung ausgeben, ohne geschützte Inhalte oder Schlüsselmaterial offenzulegen."
),
"body": (
"Encryption gleicht ausschließlich eine exakte Mandantenkontokennung ab und kann eine bereits verifizierte "
"Suche auf einen Tresor, Schlüsselvorgang, Umschlag, eine Migration oder Wiederherstellungszeremonie "
"einschränken. Die Ausgabe meldet minimierte Tresoradministration, Schlüsselvorgangsanfragen, "
"Schutzregistrierungen, Migrationen und Wiederherstellungsmitwirkung. Sie enthält niemals Anbieter- oder "
"öffentliche Schlüsselverweise, umhüllte Schlüssel, Nonces, Chiffratorte, Ressourcenkennungen, Prüfsummen, "
"Anfrageinhalte, Zusicherungs- und Regelverweise, Wiederherstellungsgründe, Idempotenzschlüssel oder Provenienz. "
"Fachmodule bleiben für die Ausgabe der Klartextsemantik ihrer geschützten Ressourcen verantwortlich. "
"Zuordnungen des kryptografischen Lebenszyklus und der Verwahrung bleiben unveränderliche Sicherheitsnachweise "
"und werden nicht automatisch gelöscht."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_custody_attribution": "Gibt minimierte Lebenszyklusaktivität für das exakte Konto zurück.",
"exclude_cryptographic_secrets": "Gibt niemals Schlüsselmaterial, Chiffratverweise, Nonces oder geschützte Nachweise zurück.",
"retain_cryptographic_evidence": "Bewahrt unveränderliche Verantwortungsnachweise zu Verwahrung und Wiederherstellung.",
}
}
},
),
DocumentationTopic( DocumentationTopic(
id="encryption.boundary", id="encryption.boundary",
title="Encryption and key-custody boundary", title="Encryption and key-custody boundary",
@@ -357,6 +450,23 @@ manifest = ModuleManifest(
kind="repository", kind="repository",
), ),
), ),
metadata={"kind": "reference"},
translations={
"de": {
"title": "Abgrenzung von Verschlüsselung und Schlüsselverwahrung",
"summary": (
"Optionale Fähigkeiten für Tresore, Inhaltsschutz, Rotation, Wiederherstellung und Deaktivierungsnachweise."
),
"body": (
"Encryption schützt fachmoduleigene Inhalte, ohne deren fachliche Eigentümerschaft zu übernehmen. Die "
"Wiederherstellung von Ressourceneigentum gewährt niemals stillschweigend kryptografische Schlüssel. "
"Lebenszyklusaktionen mit hohem Risiko erfordern eine aktuelle Identity-Trust-Zusicherung. Die Deaktivierung "
"bleibt gesperrt, bis jeder Umschlag migriert, entschlüsselt, ausdrücklich exportiert oder kryptografisch "
"vernichtet wurde. Der mitgelieferte lokale AES-GCM-Anbieter ist serverseitig lesbar und erfordert den "
"Deployment-Hauptschlüssel; er begründet keine Ende-zu-Ende-Verschlüsselung."
),
}
},
), ),
DocumentationTopic( DocumentationTopic(
id="encryption.administration", id="encryption.administration",
@@ -380,6 +490,23 @@ manifest = ModuleManifest(
audience=("administrator", "security_officer", "auditor"), audience=("administrator", "security_officer", "auditor"),
related_modules=OPTIONAL_DEPENDENCIES, related_modules=OPTIONAL_DEPENDENCIES,
order=110, order=110,
metadata={"kind": "reference"},
translations={
"de": {
"title": "Verschlüsselungsvorgänge administrieren",
"summary": (
"Sichere Tresor- und Umschlagmetadaten prüfen, den Schlüssellebenszyklus steuern, Migrationen koordinieren und Deaktivierungsbereitschaft nachweisen."
),
"body": (
"Die Encryption-Administration zeigt begrenzte Mandantenmetadaten, aber niemals Anbieterschlüsselverweise, "
"umhüllte Schlüssel, Chiffratorte oder Klartext. Rotation erzeugt eine neue aktuelle Version, während "
"bestehende Umschläge an ihre Version gebunden bleiben. Widerruf und Vernichtung können bereits erlangtes "
"Material nicht zurückrufen und Inhalte unzugänglich machen. Migrationen bleiben zweiphasig: Das "
"Eigentümermodul führt den dauerhaften Inhaltsvorgang aus und hält vor Erfolg Nachweise fest. Die "
"Deaktivierungsvorprüfung sperrt, bis jeder Umschlag einen endgültigen Verbleib hat."
),
}
},
), ),
DocumentationTopic( DocumentationTopic(
id="encryption.recovery", id="encryption.recovery",
@@ -399,8 +526,27 @@ manifest = ModuleManifest(
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("administrator", "security_officer", "auditor"), audience=("administrator", "security_officer", "auditor"),
conditions=(
DocumentationCondition(required_scopes=(RECOVERY_SCOPE,)),
),
related_modules=("identity_trust", "access", "audit", "policy"), related_modules=("identity_trust", "access", "audit", "policy"),
order=120, order=120,
metadata={"kind": "workflow"},
translations={
"de": {
"title": "Wiederherstellungszeremonie für Verschlüsselung durchführen",
"summary": (
"Zeitlich begrenzte Wiederherstellung mit hoher Zusicherung und Quorum unterschiedlicher Verwahrender beantragen und entscheiden."
),
"body": (
"Die antragstellende Person legt Regel und aktuellen Nachweis hoher Zusicherung vor und darf dieselbe "
"Zeremonie nicht genehmigen. Jede verwahrende Person kann genau einmal entscheiden; eine Ablehnung beendet "
"den Antrag und Genehmigungen müssen das Tresorquorum vor Ablauf erreichen. Die Genehmigung autorisiert einen "
"späteren Anbietervorgang. Sie gibt kein Schlüsselmaterial frei, überträgt kein Ressourceneigentum und beweist "
"nicht, dass die Wiederherstellung erfolgreich ausgeführt wurde."
),
}
},
), ),
), ),
architecture=declared_module_architecture( architecture=declared_module_architecture(
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
from govoplan_encryption.backend.manifest import get_manifest
class EncryptionDocumentationTests(unittest.TestCase):
def test_german_reference_documentation_is_complete(self) -> None:
topics = get_manifest().documentation
self.assertEqual(4, len(topics))
for topic in topics:
translation = topic.translations.get("de", {})
self.assertTrue(translation.get("title"), topic.id)
self.assertTrue(translation.get("summary"), topic.id)
self.assertTrue(translation.get("body"), topic.id)
if localizable_documentation_metadata_keys(topic):
self.assertEqual("1", topic.structured_translation_version, topic.id)
self.assertIn("de", topic.structured_translations, topic.id)
self.assertEqual((), documentation_structured_translation_issues(topic))
kinds = {topic.metadata.get("kind") for topic in topics}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
workflow = next(topic for topic in topics if topic.metadata.get("kind") == "workflow")
self.assertTrue(workflow.conditions)
if __name__ == "__main__":
unittest.main()
+372
View File
@@ -0,0 +1,372 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime, timedelta
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_encryption.backend.db.models import (
ContentProtectionRecord,
EncryptionKeyOperation,
EncryptionVault,
ProtectionMigration,
RecoveryApproval,
RecoveryCeremony,
)
from govoplan_encryption.backend.dsar_provider import (
ENCRYPTION_DSAR_CAPABILITY,
EncryptionDsarProvider,
)
from govoplan_encryption.backend.manifest import manifest
NOW = datetime(2026, 8, 22, 11, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: EncryptionDsarProvider) -> None:
self.provider = provider
def capability_names(self):
return (ENCRYPTION_DSAR_CAPABILITY,)
def capability_owner(self, name):
if name != ENCRYPTION_DSAR_CAPABILITY:
raise KeyError(name)
return "encryption"
def tenant_entitlement_resolver(self):
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type("State", (), {"effective_modules": ("encryption",)})()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
if name != ENCRYPTION_DSAR_CAPABILITY:
raise KeyError(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "encryption"})(),)
class EncryptionDsarProviderTests(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 = EncryptionDsarProvider()
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_all(
(
EncryptionVault(
id="vault-row-1",
tenant_id="tenant-1",
vault_id="vault-1",
name="Resident postbox vault",
provider_id="provider-secret-do-not-export",
purpose="resident correspondence",
profile_kind="institutional",
scope_type="tenant",
scope_id="scope-secret-do-not-export",
policy_ref="policy-ref-do-not-export",
recovery_quorum=2,
state="active",
revision=2,
current_key_version=3,
create_idempotency_key="vault-idempotency-do-not-export",
create_request_digest="vault-digest-do-not-export",
provenance={"secret": "vault-provenance-do-not-export"},
created_by="account-1",
updated_by="account-1",
created_at=NOW,
updated_at=NOW,
),
EncryptionKeyOperation(
id="key-operation-1",
tenant_id="tenant-1",
vault_id="vault-1",
key_version=3,
operation="rotate",
provider_id="provider-secret-do-not-export",
state="complete",
idempotency_key="key-idempotency-do-not-export",
request_digest="key-request-digest-do-not-export",
request_payload={"secret": "request-payload-do-not-export"},
error_code="provider-error-do-not-export",
policy_decision_ref="policy-decision-do-not-export",
assurance_evidence_ref="assurance-evidence-do-not-export",
requested_by="account-1",
completed_at=NOW,
created_at=NOW,
updated_at=NOW,
),
ContentProtectionRecord(
id="protection-row-1",
envelope_id="envelope-1",
tenant_id="tenant-1",
owner_module="postbox",
resource_type="message",
resource_id="message-resource-do-not-export",
profile_kind="institutional",
profile_id="profile-secret-do-not-export",
provider_id="provider-secret-do-not-export",
vault_id="vault-1",
key_version=3,
algorithm_suite="AES-256-GCM",
ciphertext_ref="ciphertext-ref-do-not-export",
ciphertext_digest="ciphertext-digest-do-not-export",
authenticated_context_digest="context-digest-do-not-export",
wrapped_key_refs=["wrapped-key-ref-do-not-export"],
state="active",
source_envelope_id="source-envelope-do-not-export",
migration_id="migration-secret-do-not-export",
envelope_metadata={"secret": "envelope-metadata-do-not-export"},
idempotency_key="envelope-idempotency-do-not-export",
request_digest="envelope-request-digest-do-not-export",
policy_decision_ref="envelope-policy-do-not-export",
registered_by="account-1",
created_at=NOW,
updated_at=NOW,
),
ProtectionMigration(
id="migration-1",
tenant_id="tenant-1",
source_envelope_id="source-envelope-ref-do-not-export",
target_envelope_id="target-envelope-ref-do-not-export",
target_provider_id="target-provider-do-not-export",
target_vault_id="vault-1",
target_key_version=4,
target_algorithm_suite="AES-256-GCM",
mode="rewrap",
state="complete",
policy_decision_ref="migration-policy-do-not-export",
assurance_evidence_ref="migration-assurance-do-not-export",
idempotency_key="migration-idempotency-do-not-export",
request_digest="migration-request-digest-do-not-export",
evidence_refs=["migration-evidence-do-not-export"],
error_code="migration-error-do-not-export",
provenance={"secret": "migration-provenance-do-not-export"},
requested_by="account-1",
completed_at=NOW,
created_at=NOW,
updated_at=NOW,
),
RecoveryCeremony(
id="recovery-1",
tenant_id="tenant-1",
vault_id="vault-1",
state="approved",
requested_scope="scope-secret-do-not-export",
reason="recovery-reason-do-not-export",
quorum=2,
revision=2,
policy_decision_ref="recovery-policy-do-not-export",
requester_assurance_ref="requester-assurance-do-not-export",
requester_account_id="account-1",
idempotency_key="recovery-idempotency-do-not-export",
request_digest="recovery-request-digest-do-not-export",
expires_at=NOW + timedelta(hours=1),
execution_ref="execution-ref-do-not-export",
provenance={"secret": "recovery-provenance-do-not-export"},
created_at=NOW,
updated_at=NOW,
),
RecoveryApproval(
id="recovery-approval-1",
tenant_id="tenant-1",
recovery_id="recovery-1",
approver_account_id="account-1",
decision="approve",
reason="approval-reason-do-not-export",
assurance_evidence_ref="approval-assurance-do-not-export",
idempotency_key="approval-idempotency-do-not-export",
request_digest="approval-request-digest-do-not-export",
created_at=NOW,
updated_at=NOW,
),
EncryptionVault(
id="vault-row-other",
tenant_id="tenant-2",
vault_id="vault-other",
name="Other tenant vault",
provider_id="local",
purpose="other",
profile_kind="institutional",
scope_type="tenant",
policy_ref="policy",
recovery_quorum=2,
state="active",
revision=1,
create_idempotency_key="other-key",
create_request_digest="other-digest",
provenance={},
created_by="account-1",
updated_by="account-1",
),
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(account_id="account-1")
def test_search_exports_minimized_custody_attribution(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
self.assertEqual(
{
"vault_actor_attribution",
"key_operation_actor_attribution",
"content_protection_actor_attribution",
"protection_migration_actor_attribution",
"recovery_request_actor_attribution",
"recovery_approval_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
for excluded in (
"provider-secret-do-not-export",
"scope-secret-do-not-export",
"policy-ref-do-not-export",
"vault-idempotency-do-not-export",
"vault-digest-do-not-export",
"vault-provenance-do-not-export",
"key-idempotency-do-not-export",
"key-request-digest-do-not-export",
"request-payload-do-not-export",
"provider-error-do-not-export",
"policy-decision-do-not-export",
"assurance-evidence-do-not-export",
"message-resource-do-not-export",
"profile-secret-do-not-export",
"ciphertext-ref-do-not-export",
"ciphertext-digest-do-not-export",
"context-digest-do-not-export",
"wrapped-key-ref-do-not-export",
"source-envelope-do-not-export",
"envelope-metadata-do-not-export",
"target-provider-do-not-export",
"migration-policy-do-not-export",
"migration-assurance-do-not-export",
"migration-evidence-do-not-export",
"recovery-reason-do-not-export",
"requester-assurance-do-not-export",
"execution-ref-do-not-export",
"approval-reason-do-not-export",
"approval-assurance-do-not-export",
):
self.assertNotIn(excluded, exported)
def test_requires_exact_account_and_enforces_tenant(self) -> None:
email_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(email="custodian@example.test"),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"encryption.account": "account-other"},
),
)
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
self.assertEqual((), email_only)
self.assertEqual((), conflict)
self.assertNotIn("vault-row-other", {record.resource_id for record in records})
def test_recovery_narrowing_includes_request_and_approval_only(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"encryption.recovery": "recovery-1"},
),
)
self.assertEqual(
{
"recovery_request_actor_attribution",
"recovery_approval_actor_attribution",
},
{record.resource_type for record in records},
)
def test_erasure_retains_cryptographic_custody_evidence(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" and not action.executable for action in actions)
)
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
actions=actions,
request_id="dsar-encryption-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
def test_manifest_and_core_workflow_discover_provider(self) -> None:
self.assertIn(ENCRYPTION_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
"encryption.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-ENCRYPTION-1",
request_kind="access",
subject=self._subject(),
purpose="Encryption custody 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(6, row.search_result["record_count"])
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -12,6 +12,7 @@ from govoplan_core.core.encryption import (
CAPABILITY_ENCRYPTION_RECOVERY, CAPABILITY_ENCRYPTION_RECOVERY,
) )
from govoplan_encryption.backend.manifest import get_manifest from govoplan_encryption.backend.manifest import get_manifest
from govoplan_encryption.backend.dsar_provider import ENCRYPTION_DSAR_CAPABILITY
from govoplan_encryption.backend.local_provider import LOCAL_PROVIDER_ID from govoplan_encryption.backend.local_provider import LOCAL_PROVIDER_ID
@@ -28,6 +29,7 @@ class EncryptionManifestTests(unittest.TestCase):
CAPABILITY_ENCRYPTION_CONTENT_CIPHER, CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
CAPABILITY_ENCRYPTION_RECOVERY, CAPABILITY_ENCRYPTION_RECOVERY,
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT, CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
ENCRYPTION_DSAR_CAPABILITY,
f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}", f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}",
f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}", f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}",
}, },
+9 -4
View File
@@ -1,23 +1,28 @@
{ {
"name": "@govoplan/encryption-webui", "name": "@govoplan/encryption-webui",
"version": "0.1.14", "version": "0.1.20",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"module": "src/index.ts", "module": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
"exports": { "exports": {
".": { "types": "./src/index.ts", "import": "./src/index.ts" }, ".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/encryption.css": "./src/styles/encryption.css" "./styles/encryption.css": "./src/styles/encryption.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"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "optional": true } "@govoplan/core-webui": {
"optional": true
}
}, },
"scripts": { "scripts": {
"test:encryption-ui": "node tests/encryption-ui-structure.test.mjs" "test:encryption-ui": "node tests/encryption-ui-structure.test.mjs"
+12 -11
View File
@@ -1,3 +1,4 @@
import { MetricGrid } from "@govoplan/core-webui";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
ArrowRightLeft, ArrowRightLeft,
@@ -10,7 +11,7 @@ import {
Trash2, Trash2,
X X
} from "lucide-react"; } from "lucide-react";
import { import { FormGrid,
AdminPageLayout, AdminPageLayout,
Button, Button,
Card, Card,
@@ -286,12 +287,12 @@ export default function EncryptionAdminPanel({ settings, auth }: Props) {
error={error} error={error}
success={success} success={success}
actions={<><Button onClick={() => void load()} disabled={busy}><RefreshCw aria-hidden="true" /> Reload</Button>{canAdmin && <Button variant="primary" onClick={() => setCreating(true)} disabled={busy}><Plus aria-hidden="true" /> Add vault</Button>}</>}> actions={<><Button onClick={() => void load()} disabled={busy}><RefreshCw aria-hidden="true" /> Reload</Button>{canAdmin && <Button variant="primary" onClick={() => setCreating(true)} disabled={busy}><Plus aria-hidden="true" /> Add vault</Button>}</>}>
<div className="metric-grid compact"> <MetricGrid density="compact">
<MetricCard label="Vaults" value={vaults.length} tone="info" /> <MetricCard label="Vaults" value={vaults.length} tone="info" />
<MetricCard label="Protected envelopes" value={preflight?.protected_count ?? envelopes.length} tone="info" /> <MetricCard label="Protected envelopes" value={preflight?.protected_count ?? envelopes.length} tone="info" />
<MetricCard label="Unresolved before disable" value={unresolved} tone={unresolved ? "warning" : "good"} /> <MetricCard label="Unresolved before disable" value={unresolved} tone={unresolved ? "warning" : "good"} />
<MetricCard label="Pending recoveries" value={recoveries.filter((item) => item.state === "pending").length} tone={recoveries.some((item) => item.state === "pending") ? "warning" : "good"} /> <MetricCard label="Pending recoveries" value={recoveries.filter((item) => item.state === "pending").length} tone={recoveries.some((item) => item.state === "pending") ? "warning" : "good"} />
</div> </MetricGrid>
<Card title="Key vaults" collapsible collapseKey="encryption-vaults"> <Card title="Key vaults" collapsible collapseKey="encryption-vaults">
<p className="muted small-note">The operator view contains lifecycle metadata only. Provider key references and key material are intentionally excluded.</p> <p className="muted small-note">The operator view contains lifecycle metadata only. Provider key references and key material are intentionally excluded.</p>
@@ -322,7 +323,7 @@ export default function EncryptionAdminPanel({ settings, auth }: Props) {
</Card> </Card>
<Dialog open={creating} title="Add encryption vault" onClose={() => !busy && setCreating(false)} closeDisabled={busy} footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitVault()} disabled={busy || !vaultDraft.vault_id.trim() || !vaultDraft.name.trim() || !vaultDraft.policy_ref.trim()}>Create vault</Button></>}> <Dialog open={creating} title="Add encryption vault" onClose={() => !busy && setCreating(false)} closeDisabled={busy} footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitVault()} disabled={busy || !vaultDraft.vault_id.trim() || !vaultDraft.name.trim() || !vaultDraft.policy_ref.trim()}>Create vault</Button></>}>
<div className="encryption-form-grid"> <FormGrid columns={2} gap="small" collapseAt="narrow" className="encryption-form-grid">
<FormField label="Vault ID"><input value={vaultDraft.vault_id} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, vault_id: event.target.value })} /></FormField> <FormField label="Vault ID"><input value={vaultDraft.vault_id} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, vault_id: event.target.value })} /></FormField>
<FormField label="Name"><input value={vaultDraft.name} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, name: event.target.value })} /></FormField> <FormField label="Name"><input value={vaultDraft.name} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, name: event.target.value })} /></FormField>
<FormField label="Provider"><input value={vaultDraft.provider_id} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, provider_id: event.target.value })} /></FormField> <FormField label="Provider"><input value={vaultDraft.provider_id} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, provider_id: event.target.value })} /></FormField>
@@ -331,45 +332,45 @@ export default function EncryptionAdminPanel({ settings, auth }: Props) {
<FormField label="Recovery quorum"><input type="number" min={1} max={32} value={vaultDraft.recovery_quorum} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, recovery_quorum: Number(event.target.value) })} /></FormField> <FormField label="Recovery quorum"><input type="number" min={1} max={32} value={vaultDraft.recovery_quorum} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, recovery_quorum: Number(event.target.value) })} /></FormField>
<FormField label="Purpose"><input value={vaultDraft.purpose} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, purpose: event.target.value })} /></FormField> <FormField label="Purpose"><input value={vaultDraft.purpose} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, purpose: event.target.value })} /></FormField>
<FormField label="Policy reference"><input value={vaultDraft.policy_ref} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, policy_ref: event.target.value })} /></FormField> <FormField label="Policy reference"><input value={vaultDraft.policy_ref} disabled={busy} onChange={(event) => setVaultDraft({ ...vaultDraft, policy_ref: event.target.value })} /></FormField>
</div> </FormGrid>
<p className="muted small-note">Selecting end-to-end records a profile label only. It does not install or certify a client E2EE protocol.</p> <p className="muted small-note">Selecting end-to-end records a profile label only. It does not install or certify a client E2EE protocol.</p>
</Dialog> </Dialog>
<Dialog open={Boolean(lifecycle)} title={`${humanize(lifecycle?.action ?? "key")} vault key`} onClose={() => !busy && setLifecycle(null)} closeDisabled={busy} footer={<><Button onClick={() => setLifecycle(null)} disabled={busy}>Cancel</Button><Button variant={lifecycle?.action === "rotate" ? "primary" : "danger"} onClick={() => void submitLifecycle()} disabled={busy || !lifecycle?.reason.trim() || !lifecycle?.policyRef.trim() || !lifecycle?.assuranceRef.trim()}>Confirm {lifecycle?.action}</Button></>}> <Dialog open={Boolean(lifecycle)} title={`${humanize(lifecycle?.action ?? "key")} vault key`} onClose={() => !busy && setLifecycle(null)} closeDisabled={busy} footer={<><Button onClick={() => setLifecycle(null)} disabled={busy}>Cancel</Button><Button variant={lifecycle?.action === "rotate" ? "primary" : "danger"} onClick={() => void submitLifecycle()} disabled={busy || !lifecycle?.reason.trim() || !lifecycle?.policyRef.trim() || !lifecycle?.assuranceRef.trim()}>Confirm {lifecycle?.action}</Button></>}>
{lifecycle && <> {lifecycle && <>
<p>{lifecycleText(lifecycle.action)}</p> <p>{lifecycleText(lifecycle.action)}</p>
<div className="encryption-form-grid"> <FormGrid columns={2} gap="small" collapseAt="narrow" className="encryption-form-grid">
<FormField label="Reason"><textarea rows={3} value={lifecycle.reason} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, reason: event.target.value })} /></FormField> <FormField label="Reason"><textarea rows={3} value={lifecycle.reason} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, reason: event.target.value })} /></FormField>
<FormField label="Policy decision reference"><input value={lifecycle.policyRef} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, policyRef: event.target.value })} /></FormField> <FormField label="Policy decision reference"><input value={lifecycle.policyRef} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={lifecycle.assuranceRef} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, assuranceRef: event.target.value })} /></FormField> <FormField label="High-assurance evidence reference"><input value={lifecycle.assuranceRef} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, assuranceRef: event.target.value })} /></FormField>
{lifecycle.action === "destruction" && <FormField label="Destruction effective at"><input type="datetime-local" value={lifecycle.effectiveAt} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, effectiveAt: event.target.value })} /></FormField>} {lifecycle.action === "destruction" && <FormField label="Destruction effective at"><input type="datetime-local" value={lifecycle.effectiveAt} disabled={busy} onChange={(event) => setLifecycle({ ...lifecycle, effectiveAt: event.target.value })} /></FormField>}
</div> </FormGrid>
</>} </>}
</Dialog> </Dialog>
<Dialog open={Boolean(migration)} title="Prepare protection migration" onClose={() => !busy && setMigration(null)} closeDisabled={busy} footer={<><Button onClick={() => setMigration(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitMigration()} disabled={busy || !migration?.targetVaultId || !migration?.policyRef.trim() || !migration?.assuranceRef.trim()}>Authorize migration</Button></>}> <Dialog open={Boolean(migration)} title="Prepare protection migration" onClose={() => !busy && setMigration(null)} closeDisabled={busy} footer={<><Button onClick={() => setMigration(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitMigration()} disabled={busy || !migration?.targetVaultId || !migration?.policyRef.trim() || !migration?.assuranceRef.trim()}>Authorize migration</Button></>}>
{migration && <> {migration && <>
<p>This authorizes a two-phase content operation. The owning module must durably update or dispose of its content and record evidence before the migration can succeed.</p> <p>This authorizes a two-phase content operation. The owning module must durably update or dispose of its content and record evidence before the migration can succeed.</p>
<div className="encryption-form-grid"> <FormGrid columns={2} gap="small" collapseAt="narrow" className="encryption-form-grid">
<FormField label="Source envelope"><input value={migration.envelope.envelope_id} disabled /></FormField> <FormField label="Source envelope"><input value={migration.envelope.envelope_id} disabled /></FormField>
<FormField label="Mode"><select value={migration.mode} disabled={busy} onChange={(event) => setMigration({ ...migration, mode: event.target.value as MigrationDraft["mode"] })}><option value="rewrap">Rewrap</option><option value="reencrypt">Re-encrypt</option><option value="decrypt">Decrypt</option><option value="export">Export</option><option value="destroy">Destroy</option></select></FormField> <FormField label="Mode"><select value={migration.mode} disabled={busy} onChange={(event) => setMigration({ ...migration, mode: event.target.value as MigrationDraft["mode"] })}><option value="rewrap">Rewrap</option><option value="reencrypt">Re-encrypt</option><option value="decrypt">Decrypt</option><option value="export">Export</option><option value="destroy">Destroy</option></select></FormField>
<FormField label="Target vault"><select value={migration.targetVaultId} disabled={busy} onChange={(event) => setMigration({ ...migration, targetVaultId: event.target.value })}>{vaults.filter((vault) => vault.current_key_version && vault.state === "active").map((vault) => <option key={vault.vault_id} value={vault.vault_id}>{vault.name} · v{vault.current_key_version}</option>)}</select></FormField> <FormField label="Target vault"><select value={migration.targetVaultId} disabled={busy} onChange={(event) => setMigration({ ...migration, targetVaultId: event.target.value })}>{vaults.filter((vault) => vault.current_key_version && vault.state === "active").map((vault) => <option key={vault.vault_id} value={vault.vault_id}>{vault.name} · v{vault.current_key_version}</option>)}</select></FormField>
<FormField label="Policy decision reference"><input value={migration.policyRef} disabled={busy} onChange={(event) => setMigration({ ...migration, policyRef: event.target.value })} /></FormField> <FormField label="Policy decision reference"><input value={migration.policyRef} disabled={busy} onChange={(event) => setMigration({ ...migration, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={migration.assuranceRef} disabled={busy} onChange={(event) => setMigration({ ...migration, assuranceRef: event.target.value })} /></FormField> <FormField label="High-assurance evidence reference"><input value={migration.assuranceRef} disabled={busy} onChange={(event) => setMigration({ ...migration, assuranceRef: event.target.value })} /></FormField>
</div> </FormGrid>
</>} </>}
</Dialog> </Dialog>
<Dialog open={requestingRecovery} title="Request recovery ceremony" onClose={() => !busy && setRequestingRecovery(false)} closeDisabled={busy} footer={<><Button onClick={() => setRequestingRecovery(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitRecovery()} disabled={busy || !recoveryDraft.vaultId || !recoveryDraft.reason.trim() || !recoveryDraft.requestedScope.trim() || !recoveryDraft.policyRef.trim() || !recoveryDraft.assuranceRef.trim()}>Request recovery</Button></>}> <Dialog open={requestingRecovery} title="Request recovery ceremony" onClose={() => !busy && setRequestingRecovery(false)} closeDisabled={busy} footer={<><Button onClick={() => setRequestingRecovery(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void submitRecovery()} disabled={busy || !recoveryDraft.vaultId || !recoveryDraft.reason.trim() || !recoveryDraft.requestedScope.trim() || !recoveryDraft.policyRef.trim() || !recoveryDraft.assuranceRef.trim()}>Request recovery</Button></>}>
<p>The request expires automatically and needs the vault's configured number of distinct custodians. You cannot approve your own request.</p> <p>The request expires automatically and needs the vault's configured number of distinct custodians. You cannot approve your own request.</p>
<div className="encryption-form-grid"> <FormGrid columns={2} gap="small" collapseAt="narrow" className="encryption-form-grid">
<FormField label="Vault"><select value={recoveryDraft.vaultId} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, vaultId: event.target.value })}>{vaults.map((vault) => <option key={vault.vault_id} value={vault.vault_id}>{vault.name}</option>)}</select></FormField> <FormField label="Vault"><select value={recoveryDraft.vaultId} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, vaultId: event.target.value })}>{vaults.map((vault) => <option key={vault.vault_id} value={vault.vault_id}>{vault.name}</option>)}</select></FormField>
<FormField label="Requested scope"><input value={recoveryDraft.requestedScope} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, requestedScope: event.target.value })} /></FormField> <FormField label="Requested scope"><input value={recoveryDraft.requestedScope} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, requestedScope: event.target.value })} /></FormField>
<FormField label="Reason"><textarea rows={3} value={recoveryDraft.reason} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, reason: event.target.value })} /></FormField> <FormField label="Reason"><textarea rows={3} value={recoveryDraft.reason} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, reason: event.target.value })} /></FormField>
<FormField label="Expires at"><input type="datetime-local" value={recoveryDraft.expiresAt} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, expiresAt: event.target.value })} /></FormField> <FormField label="Expires at"><input type="datetime-local" value={recoveryDraft.expiresAt} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, expiresAt: event.target.value })} /></FormField>
<FormField label="Policy decision reference"><input value={recoveryDraft.policyRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, policyRef: event.target.value })} /></FormField> <FormField label="Policy decision reference"><input value={recoveryDraft.policyRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, policyRef: event.target.value })} /></FormField>
<FormField label="High-assurance evidence reference"><input value={recoveryDraft.assuranceRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, assuranceRef: event.target.value })} /></FormField> <FormField label="High-assurance evidence reference"><input value={recoveryDraft.assuranceRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, assuranceRef: event.target.value })} /></FormField>
</div> </FormGrid>
</Dialog> </Dialog>
<Dialog open={Boolean(recoveryDecision)} title={`${humanize(recoveryDecision?.decision ?? "decide")} recovery`} onClose={() => !busy && setRecoveryDecision(null)} closeDisabled={busy} footer={<><Button onClick={() => setRecoveryDecision(null)} disabled={busy}>Cancel</Button><Button variant={recoveryDecision?.decision === "reject" ? "danger" : "primary"} onClick={() => void submitRecoveryDecision()} disabled={busy || !recoveryDecision?.reason.trim() || !recoveryDecision?.assuranceRef.trim()}>Record {recoveryDecision?.decision}</Button></>}> <Dialog open={Boolean(recoveryDecision)} title={`${humanize(recoveryDecision?.decision ?? "decide")} recovery`} onClose={() => !busy && setRecoveryDecision(null)} closeDisabled={busy} footer={<><Button onClick={() => setRecoveryDecision(null)} disabled={busy}>Cancel</Button><Button variant={recoveryDecision?.decision === "reject" ? "danger" : "primary"} onClick={() => void submitRecoveryDecision()} disabled={busy || !recoveryDecision?.reason.trim() || !recoveryDecision?.assuranceRef.trim()}>Record {recoveryDecision?.decision}</Button></>}>
+2 -12
View File
@@ -4,12 +4,6 @@
overflow: auto; overflow: auto;
} }
.encryption-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem 1rem;
}
.encryption-form-grid .form-field:has(textarea) { .encryption-form-grid .form-field:has(textarea) {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -19,11 +13,11 @@
align-items: flex-start; align-items: flex-start;
gap: 0.8rem; gap: 0.8rem;
padding: 0.85rem 0; padding: 0.85rem 0;
border-top: 2px solid var(--color-warning, #c38b22); border-top: 2px solid var(--warning-border);
} }
.encryption-preflight.is-ready { .encryption-preflight.is-ready {
border-top-color: var(--color-success, #43885b); border-top-color: var(--success-border);
} }
.encryption-preflight svg { .encryption-preflight svg {
@@ -46,10 +40,6 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.encryption-form-grid {
grid-template-columns: minmax(0, 1fr);
}
.encryption-form-grid .form-field:has(textarea) { .encryption-form-grid .form-field:has(textarea) {
grid-column: auto; grid-column: auto;
} }