feat(postbox): add governed content protection profiles

This commit is contained in:
2026-08-20 03:42:58 +02:00
parent 15d93aaa25
commit 174ee97719
16 changed files with 3917 additions and 383 deletions
+256 -15
View File
@@ -28,6 +28,7 @@ from govoplan_core.core.files import (
PostboxFileReferenceRequest,
postbox_file_reference_provider,
)
from govoplan_core.core.encryption import encryption_content_cipher
from govoplan_core.db.session import get_session
from govoplan_postbox.backend.manifest import (
ACKNOWLEDGE_SCOPE,
@@ -60,6 +61,14 @@ from govoplan_postbox.backend.schemas import (
PostboxMessageStateRequest,
PostboxOrganizationTargetsResponse,
PostboxMutationRequest,
PostboxProtectionProfileItem,
PostboxProtectionProfileListResponse,
PostboxProtectionPolicyUpdateRequest,
PostboxProtectionTransformRequest,
PostboxProtectionTransitionCreateRequest,
PostboxProtectionTransitionItemResponse,
PostboxProtectionTransitionListResponse,
PostboxProtectionTransitionResponse,
PostboxRouteDryRunRequest,
PostboxRouteDryRunResponse,
PostboxTemplateCreateRequest,
@@ -71,6 +80,10 @@ from govoplan_postbox.backend.schemas import (
PostboxTemplateReviseRequest,
)
from govoplan_postbox.backend.service import PostboxError
from govoplan_postbox.backend.protection_profiles import (
POSTBOX_PROTECTION_PROFILE_DEFINITIONS,
POSTBOX_STANDARD_PROFILE,
)
from govoplan_postbox.backend.principals import (
PostboxPrincipalError,
actor_from_principal,
@@ -122,6 +135,8 @@ def _http_error(exc: PostboxError) -> HTTPException:
"revision_not_found",
"grouping_not_found",
"target_not_found",
"protection_transition_not_found",
"transition_item_not_found",
}:
code = status.HTTP_404_NOT_FOUND
elif exc.code in {"access_denied", "grouping_source_denied"}:
@@ -195,6 +210,11 @@ def _authoring_request(
idempotency_key=payload.idempotency_key,
subject=payload.subject,
body_text=payload.body_text,
ciphertext_ref=payload.ciphertext_ref,
signed_manifest_ref=payload.signed_manifest_ref,
wrapped_keys=tuple(
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
),
classification=payload.classification,
participants=tuple(
PostboxParticipantRef(**participant.model_dump())
@@ -236,9 +256,7 @@ def _template_item(template) -> PostboxTemplateItem:
"scope_kind": revision.scope_kind,
"scope_id": revision.scope_id,
"scope_structure_id": revision.scope_structure_id,
"scope_relation_type_ids": list(
revision.scope_relation_type_ids or []
),
"scope_relation_type_ids": list(revision.scope_relation_type_ids or []),
"name_pattern": revision.name_pattern,
"address_pattern": revision.address_pattern,
"classification": revision.classification,
@@ -246,6 +264,7 @@ def _template_item(template) -> PostboxTemplateItem:
"portal_visible": revision.portal_visible,
"encryption_profile": revision.encryption_profile,
"encryption_vault_id": revision.encryption_vault_id,
"protection_policy": dict(revision.history_policy or {}),
"history_policy": dict(revision.history_policy or {}),
"routing_policy": dict(revision.routing_policy or {}),
"retention_policy": dict(revision.retention_policy or {}),
@@ -291,6 +310,48 @@ def _grouping_item(
)
def _protection_transition_item(value) -> PostboxProtectionTransitionResponse:
return PostboxProtectionTransitionResponse(
id=value.id,
postbox_id=value.postbox_id,
source_profile=value.source_profile,
target_profile=value.target_profile,
source_vault_id=value.source_vault_id,
target_vault_id=value.target_vault_id,
history_mode=value.history_mode,
authority_mode=value.authority_mode,
required_quorum=value.required_quorum,
evidence_refs=list(value.evidence_refs or []),
reason=value.reason,
state=value.state,
message_count=value.message_count,
completed_count=value.completed_count,
failed_count=value.failed_count,
requested_by=value.requested_by,
activated_at=value.activated_at,
completed_at=value.completed_at,
resource_revision=value.resource_revision,
etag=value.strong_etag,
configuration_snapshot=dict(value.configuration_snapshot or {}),
items=[
PostboxProtectionTransitionItemResponse(
id=item.id,
message_id=item.message_id,
source_profile=item.source_profile,
target_profile=item.target_profile,
state=item.state,
source_digest=item.source_digest,
target_digest=item.target_digest,
completed_by=item.completed_by,
completed_at=item.completed_at,
error_code=item.error_code,
evidence=dict(item.evidence or {}),
)
for item in value.items
],
)
@router.get("/directory", response_model=PostboxDirectoryResponse)
def api_postbox_directory(
assignment_context_id: str | None = None,
@@ -523,7 +584,13 @@ def api_resolve_postbox_message_attachments(
)
provider = postbox_file_reference_provider(get_registry())
file_types = {"file", "file_asset", "files:file", "file_version", "files:file_version"}
file_types = {
"file",
"file_asset",
"files:file",
"file_version",
"files:file_version",
}
requests = tuple(
PostboxFileReferenceRequest(
reference_type=attachment.reference_type,
@@ -544,9 +611,7 @@ def api_resolve_postbox_message_attachments(
if provider is not None and requests
else ()
)
by_reference = {
(item.reference_type, item.reference_id): item for item in resolved
}
by_reference = {(item.reference_type, item.reference_id): item for item in resolved}
items: list[PostboxAttachmentResolutionItem] = []
for attachment in message.attachments:
resolution = by_reference.get(
@@ -667,8 +732,7 @@ def api_deliver_to_postbox(
ciphertext_ref=payload.ciphertext_ref,
signed_manifest_ref=payload.signed_manifest_ref,
wrapped_keys=tuple(
PostboxWrappedKeyRef(**item.model_dump())
for item in payload.wrapped_keys
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
),
external_recipient_tokens=tuple(
PostboxExternalRecipientTokenRef(**item.model_dump())
@@ -853,13 +917,9 @@ def api_postbox_organization_targets(
) -> PostboxOrganizationTargetsResponse:
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
return PostboxOrganizationTargetsResponse(
units=list(
get_service().organization_targets(tenant_id=principal.tenant_id)
),
units=list(get_service().organization_targets(tenant_id=principal.tenant_id)),
structures=list(
get_service().organization_hierarchy_targets(
tenant_id=principal.tenant_id
)
get_service().organization_hierarchy_targets(tenant_id=principal.tenant_id)
),
)
@@ -881,6 +941,187 @@ def api_admin_postboxes(
)
@router.get(
"/admin/protection-profiles",
response_model=PostboxProtectionProfileListResponse,
)
def api_postbox_protection_profiles(
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionProfileListResponse:
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
managed_available = encryption_content_cipher(get_registry()) is not None
return PostboxProtectionProfileListResponse(
standard_profile=POSTBOX_STANDARD_PROFILE,
profiles=[
PostboxProtectionProfileItem(
**asdict(profile),
available=(
managed_available if profile.requires_encryption_module else True
),
)
for profile in POSTBOX_PROTECTION_PROFILE_DEFINITIONS
],
)
@router.get(
"/admin/postboxes/{postbox_id}/protection-transitions",
response_model=PostboxProtectionTransitionListResponse,
)
def api_list_postbox_protection_transitions(
postbox_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionTransitionListResponse:
_require(principal, BINDING_ADMIN_SCOPE)
try:
values = get_service().list_protection_transitions(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxProtectionTransitionListResponse(
transitions=[_protection_transition_item(value) for value in values]
)
@router.put(
"/admin/postboxes/{postbox_id}/protection-policy",
response_model=PostboxDirectoryItem,
)
def api_update_postbox_protection_policy(
postbox_id: str,
payload: PostboxProtectionPolicyUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox",
resource_id=postbox_id,
base_revision=payload.base_revision,
)
try:
get_service().update_protection_policy(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
protection_policy=payload.protection_policy.model_dump(),
actor_id=principal.account_id,
expected_revision=payload.base_revision,
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox_id),
)
)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/postboxes/{postbox_id}/protection-transitions",
response_model=PostboxProtectionTransitionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_postbox_protection_transition(
postbox_id: str,
payload: PostboxProtectionTransitionCreateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionTransitionResponse:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox",
resource_id=postbox_id,
base_revision=payload.base_revision,
)
try:
value = get_service().create_protection_transition(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
expected_revision=payload.base_revision,
actor_id=principal.account_id,
**payload.model_dump(
exclude={"base_revision", "acknowledge_irreversibility"}
),
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _protection_transition_item(value)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/postboxes/{postbox_id}/protection-transitions/{transition_id}/transform",
response_model=PostboxProtectionTransitionResponse,
)
def api_apply_postbox_protection_transform(
postbox_id: str,
transition_id: str,
payload: PostboxProtectionTransformRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionTransitionResponse:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_protection_transition",
resource_id=transition_id,
base_revision=payload.base_revision,
)
try:
value = get_service().apply_client_protection_transform(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
transition_id=transition_id,
expected_revision=payload.base_revision,
actor_id=principal.account_id,
wrapped_keys=tuple(
PostboxWrappedKeyRef(**item.model_dump())
for item in payload.wrapped_keys
),
**payload.model_dump(exclude={"base_revision", "wrapped_keys"}),
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _protection_transition_item(value)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/postboxes",
response_model=PostboxDirectoryItem,