feat(postbox): add governed content protection profiles
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user