feat(encryption): add governed DSAR coverage
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user