Compare commits
13
Commits
d9109f2a5b
...
v0.1.20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6edb1336af | ||
|
|
df64af4d10 | ||
|
|
277d5f01e4 | ||
|
|
898d68aa1a | ||
|
|
1a24b373d6 | ||
|
|
f8c22396b3 | ||
|
|
211cd2c950 | ||
|
|
25f0163f59 | ||
|
|
029406e9ee | ||
|
|
e1d0742c46 | ||
|
|
869f7bbdc4 | ||
|
|
517dfb720e | ||
|
|
99a5fa52a5 |
@@ -14,6 +14,8 @@ on:
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
@@ -29,7 +31,6 @@ jobs:
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
@@ -43,24 +44,6 @@ jobs:
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
python - "$tag" <<'PY'
|
||||
import fnmatch
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
tag = sys.argv[1]
|
||||
repository = os.environ["GITEA_REPOSITORY"]
|
||||
request = urllib.request.Request(
|
||||
f"{os.environ['GITEA_API_URL']}/repos/{repository}/tag_protections",
|
||||
headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
protections = json.load(response)
|
||||
if not any(fnmatch.fnmatchcase(tag, item.get("name_pattern", "")) for item in protections):
|
||||
raise SystemExit(f"Release tag {tag!r} is not covered by repository tag protection")
|
||||
PY
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
@@ -130,7 +113,7 @@ jobs:
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/GovOPlaN/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
@@ -180,6 +163,78 @@ jobs:
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
@@ -189,13 +244,17 @@ jobs:
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )); then
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
@@ -203,7 +262,9 @@ jobs:
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "${webui_packages[0]}" \
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
|
||||
@@ -62,3 +62,21 @@ cd /mnt/DATA/git/govoplan-encryption
|
||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
|
||||
/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.
|
||||
|
||||
@@ -18,6 +18,37 @@ certification claim. Stronger claims require a named protection profile whose
|
||||
provider, clients, algorithms, backup procedure, and failure tests have passed
|
||||
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
|
||||
|
||||
Encryption owns:
|
||||
|
||||
+28
-3
@@ -1,8 +1,33 @@
|
||||
{
|
||||
"name": "@govoplan/encryption",
|
||||
"version": "0.1.14",
|
||||
"name": "@govoplan/encryption-webui",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"description": "GovOPlaN encryption platform module scaffold.",
|
||||
"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
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-encryption"
|
||||
version = "0.1.14"
|
||||
version = "0.1.20"
|
||||
description = "Optional key-vault and content-protection capabilities for GovOPlaN."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = ["cryptography>=44", "govoplan-core>=0.1.14"]
|
||||
dependencies = ["cryptography>=44", "govoplan-core>=0.1.18"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
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"]
|
||||
@@ -17,6 +17,7 @@ from govoplan_core.core.module_guards import (
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -32,6 +33,10 @@ from govoplan_core.core.modules import (
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.db.base import Base
|
||||
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 (
|
||||
LOCAL_PROVIDER_ID,
|
||||
LocalAesGcmProvider,
|
||||
@@ -41,7 +46,7 @@ from govoplan_encryption.backend.service import SqlEncryptionService
|
||||
|
||||
MODULE_ID = "encryption"
|
||||
MODULE_NAME = "Encryption"
|
||||
MODULE_VERSION = "0.1.14"
|
||||
MODULE_VERSION = "0.1.20"
|
||||
|
||||
USE_SCOPE = "encryption:vault:use"
|
||||
ADMIN_SCOPE = "encryption:vault:admin"
|
||||
@@ -88,6 +93,10 @@ def _local_provider(context: ModuleContext) -> LocalAesGcmProvider:
|
||||
return LocalAesGcmProvider(getattr(context.settings, "master_key_b64", None))
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> EncryptionDsarProvider:
|
||||
return EncryptionDsarProvider()
|
||||
|
||||
|
||||
def _disable_guard(
|
||||
session: object | None,
|
||||
_module_id: str,
|
||||
@@ -188,6 +197,7 @@ manifest = ModuleManifest(
|
||||
name=CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
|
||||
version="1.0.0",
|
||||
),
|
||||
ModuleInterfaceProvider(name=ENCRYPTION_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
@@ -235,6 +245,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_ENCRYPTION_CONTENT_CIPHER: _service,
|
||||
CAPABILITY_ENCRYPTION_RECOVERY: _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_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}": _local_provider,
|
||||
},
|
||||
@@ -281,6 +292,14 @@ manifest = ModuleManifest(
|
||||
),
|
||||
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(
|
||||
module_id=MODULE_ID,
|
||||
@@ -322,6 +341,80 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
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(
|
||||
id="encryption.boundary",
|
||||
title="Encryption and key-custody boundary",
|
||||
@@ -357,6 +450,23 @@ manifest = ModuleManifest(
|
||||
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(
|
||||
id="encryption.administration",
|
||||
@@ -380,6 +490,23 @@ manifest = ModuleManifest(
|
||||
audience=("administrator", "security_officer", "auditor"),
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
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(
|
||||
id="encryption.recovery",
|
||||
@@ -399,8 +526,27 @@ manifest = ModuleManifest(
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("administrator", "security_officer", "auditor"),
|
||||
conditions=(
|
||||
DocumentationCondition(required_scopes=(RECOVERY_SCOPE,)),
|
||||
),
|
||||
related_modules=("identity_trust", "access", "audit", "policy"),
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -12,6 +12,7 @@ from govoplan_core.core.encryption import (
|
||||
CAPABILITY_ENCRYPTION_RECOVERY,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -28,6 +29,7 @@ class EncryptionManifestTests(unittest.TestCase):
|
||||
CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
|
||||
CAPABILITY_ENCRYPTION_RECOVERY,
|
||||
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT,
|
||||
ENCRYPTION_DSAR_CAPABILITY,
|
||||
f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}",
|
||||
f"{CAPABILITY_ENCRYPTION_CONTENT_CIPHER_PROVIDER_PREFIX}{LOCAL_PROVIDER_ID}",
|
||||
},
|
||||
|
||||
+9
-4
@@ -1,23 +1,28 @@
|
||||
{
|
||||
"name": "@govoplan/encryption-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": { "types": "./src/index.ts", "import": "./src/index.ts" },
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/encryption.css": "./src/styles/encryption.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": { "optional": true }
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:encryption-ui": "node tests/encryption-ui-structure.test.mjs"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MetricGrid } from "@govoplan/core-webui";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
Trash2,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import {
|
||||
import { FormGrid,
|
||||
AdminPageLayout,
|
||||
Button,
|
||||
Card,
|
||||
@@ -286,12 +287,12 @@ export default function EncryptionAdminPanel({ settings, auth }: Props) {
|
||||
error={error}
|
||||
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>}</>}>
|
||||
<div className="metric-grid compact">
|
||||
<MetricGrid density="compact">
|
||||
<MetricCard label="Vaults" value={vaults.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="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">
|
||||
<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>
|
||||
|
||||
<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="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>
|
||||
@@ -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="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>
|
||||
</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>
|
||||
</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></>}>
|
||||
{lifecycle && <>
|
||||
<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="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>
|
||||
{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 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 && <>
|
||||
<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="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="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>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</>}
|
||||
</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></>}>
|
||||
<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="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="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="High-assurance evidence reference"><input value={recoveryDraft.assuranceRef} disabled={busy} onChange={(event) => setRecoveryDraft({ ...recoveryDraft, assuranceRef: event.target.value })} /></FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</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></>}>
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
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) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -19,11 +13,11 @@
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
padding: 0.85rem 0;
|
||||
border-top: 2px solid var(--color-warning, #c38b22);
|
||||
border-top: 2px solid var(--warning-border);
|
||||
}
|
||||
|
||||
.encryption-preflight.is-ready {
|
||||
border-top-color: var(--color-success, #43885b);
|
||||
border-top-color: var(--success-border);
|
||||
}
|
||||
|
||||
.encryption-preflight svg {
|
||||
@@ -46,10 +40,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.encryption-form-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.encryption-form-grid .form-field:has(textarea) {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user