feat(postbox): enforce unified inbox separation
This commit is contained in:
@@ -167,6 +167,11 @@ class PostboxTemplateRevision(Base, TimestampMixin):
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
grouping_policy: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
routing_policy: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
@@ -1012,6 +1017,7 @@ class PostboxGroupingSource(Base, TimestampMixin):
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
grouping: Mapped[PostboxGrouping] = relationship(back_populates="sources")
|
||||
postbox: Mapped[Postbox] = relationship()
|
||||
|
||||
|
||||
class PostboxAccessEvent(Base, TimestampMixin):
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
|
||||
PostboxGroupingPolicyMode = Literal[
|
||||
"allow",
|
||||
"same_classification",
|
||||
"separate",
|
||||
]
|
||||
|
||||
|
||||
class NormalizedPostboxGroupingPolicy(TypedDict):
|
||||
mode: PostboxGroupingPolicyMode
|
||||
reason: str | None
|
||||
|
||||
|
||||
def normalize_postbox_grouping_policy(
|
||||
policy: Mapping[str, object] | None = None,
|
||||
) -> NormalizedPostboxGroupingPolicy:
|
||||
value = policy or {}
|
||||
mode = str(value.get("mode") or "allow").strip().casefold()
|
||||
if mode not in {"allow", "same_classification", "separate"}:
|
||||
mode = "separate"
|
||||
reason_value = value.get("reason")
|
||||
reason = str(reason_value).strip() if reason_value is not None else None
|
||||
return {
|
||||
"mode": mode, # type: ignore[typeddict-item]
|
||||
"reason": reason or None,
|
||||
}
|
||||
|
||||
|
||||
def grouping_policy_conflicts(
|
||||
sources: Sequence[tuple[str, str, Mapping[str, object] | None]],
|
||||
) -> tuple[str, ...]:
|
||||
"""Return privacy-safe conflict codes for a proposed source projection."""
|
||||
|
||||
if len(sources) <= 1:
|
||||
return ()
|
||||
policies = [
|
||||
(postbox_id, classification, normalize_postbox_grouping_policy(policy))
|
||||
for postbox_id, classification, policy in sources
|
||||
]
|
||||
conflicts: list[str] = []
|
||||
if any(policy["mode"] == "separate" for _, _, policy in policies):
|
||||
conflicts.append("source_requires_separation")
|
||||
if (
|
||||
any(policy["mode"] == "same_classification" for _, _, policy in policies)
|
||||
and len({classification for _, classification, _ in policies}) > 1
|
||||
):
|
||||
conflicts.append("classification_separation_required")
|
||||
return tuple(conflicts)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"NormalizedPostboxGroupingPolicy",
|
||||
"PostboxGroupingPolicyMode",
|
||||
"grouping_policy_conflicts",
|
||||
"normalize_postbox_grouping_policy",
|
||||
]
|
||||
@@ -531,6 +531,58 @@ manifest = ModuleManifest(
|
||||
related_modules=("search", "idm", "encryption"),
|
||||
order=34,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.unified-inbox-policy",
|
||||
title="Configure source-preserving unified Postbox views",
|
||||
summary="Group currently visible function Postboxes without merging containers, bypassing separation policy, or granting authority.",
|
||||
body=(
|
||||
"A personal unified view stores ordered Postbox identifiers only. Messages, receipts, retention, encryption, function and unit provenance, and audit evidence remain at the source. "
|
||||
"Every exact Postbox and immutable template revision can allow grouping, require all combined sources to share its classification, or require that Postbox to remain separate. Administrators record an explanation and the API returns that rule as constraint provenance. "
|
||||
"Postbox validates a grouping when it is saved and validates every aggregate message query again, so assignment churn or a later stricter rule cannot leave an unsafe combined projection active. Temporarily unavailable source preferences remain stored but reveal no metadata or counts. "
|
||||
"The stable `?grouping=<grouping-id>` route parameter lets a task-focused View select a personal projection. Unknown, hidden, or stale identifiers never grant access; current IDM assignment, acting context, classification, and Postbox permission are always rechecked."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("administrator", "user", "auditor"),
|
||||
related_modules=("views", "idm", "policy", "audit"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Postbox",
|
||||
href="/postbox",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Postbox administration",
|
||||
href="/admin?section=postbox",
|
||||
kind="runtime",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Quellenerhaltende zusammengefasste Postfachansichten konfigurieren",
|
||||
"summary": "Aktuell sichtbare Funktionspostfächer gruppieren, ohne Container zusammenzuführen, Trennregeln zu umgehen oder Berechtigungen zu erteilen.",
|
||||
"body": (
|
||||
"Eine persönliche zusammengefasste Ansicht speichert nur geordnete Postfachkennungen. Nachrichten, Lesestatus, Aufbewahrung, Verschlüsselung, Funktions- und Organisationsbezug sowie Prüfnachweise verbleiben an der Quelle. "
|
||||
"Jedes exakte Postfach und jede unveränderliche Vorlagenrevision kann Gruppierung erlauben, für alle Quellen dieselbe Klassifikation verlangen oder das Postfach vollständig getrennt halten. Die Administration hinterlegt eine Begründung; die API liefert Regel und Herkunft als Einschränkung. "
|
||||
"Postbox prüft die Regel beim Speichern und erneut bei jeder zusammengefassten Nachrichtenabfrage. Änderungen an Zuweisungen oder später verschärfte Regeln lassen daher keine unsichere Projektion bestehen. Vorübergehend unsichtbare Quellenpräferenzen bleiben ohne Preisgabe von Metadaten oder Zählwerten erhalten. "
|
||||
"Der stabile Routenparameter `?grouping=<grouping-id>` erlaubt einer aufgabenbezogenen View die Auswahl einer persönlichen Projektion. Unbekannte, unsichtbare oder veraltete Kennungen erteilen keinen Zugriff; aktuelle IDM-Zuweisung, Handlungskontext, Klassifikation und Postfachberechtigung werden stets erneut geprüft."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "guide",
|
||||
"help_contexts": [
|
||||
"postbox.inbox.directory",
|
||||
"postbox.action.delete-grouping",
|
||||
"postbox.admin.templates",
|
||||
],
|
||||
"privacy_notes": [
|
||||
"Hidden grouping sources do not expose metadata or counts.",
|
||||
"A View selects a projection but never grants Postbox access.",
|
||||
],
|
||||
},
|
||||
order=34,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.content-protection-policy",
|
||||
title="Choose and change Postbox content protection",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""v0.1.18 governed unified-Postbox grouping policy.
|
||||
|
||||
Revision ID: d8b4f1a6c9e2
|
||||
Revises: a7c1e4f8b2d6
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d8b4f1a6c9e2"
|
||||
down_revision = "a7c1e4f8b2d6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("postbox_template_revisions") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"grouping_policy",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("postbox_template_revisions") as batch_op:
|
||||
batch_op.drop_column("grouping_policy")
|
||||
@@ -52,6 +52,7 @@ from govoplan_postbox.backend.schemas import (
|
||||
PostboxGroupingItem,
|
||||
PostboxGroupingListResponse,
|
||||
PostboxGroupingPayload,
|
||||
PostboxGroupingPolicyUpdateRequest,
|
||||
PostboxGroupingUpdateRequest,
|
||||
PostboxMaterializeRequest,
|
||||
PostboxMessageItem,
|
||||
@@ -80,6 +81,10 @@ from govoplan_postbox.backend.schemas import (
|
||||
PostboxTemplateReviseRequest,
|
||||
)
|
||||
from govoplan_postbox.backend.service import PostboxError
|
||||
from govoplan_postbox.backend.grouping_policies import (
|
||||
grouping_policy_conflicts,
|
||||
normalize_postbox_grouping_policy,
|
||||
)
|
||||
from govoplan_postbox.backend.protection_profiles import (
|
||||
POSTBOX_PROTECTION_PROFILE_DEFINITIONS,
|
||||
POSTBOX_STANDARD_PROFILE,
|
||||
@@ -145,6 +150,7 @@ def _http_error(exc: PostboxError) -> HTTPException:
|
||||
"template_slug_exists",
|
||||
"address_collision",
|
||||
"idempotency_conflict",
|
||||
"grouping_policy_conflict",
|
||||
}:
|
||||
code = status.HTTP_409_CONFLICT
|
||||
else:
|
||||
@@ -265,6 +271,7 @@ def _template_item(template) -> PostboxTemplateItem:
|
||||
"encryption_profile": revision.encryption_profile,
|
||||
"encryption_vault_id": revision.encryption_vault_id,
|
||||
"protection_policy": dict(revision.history_policy or {}),
|
||||
"grouping_policy": dict(revision.grouping_policy or {}),
|
||||
"history_policy": dict(revision.history_policy or {}),
|
||||
"routing_policy": dict(revision.routing_policy or {}),
|
||||
"retention_policy": dict(revision.retention_policy or {}),
|
||||
@@ -290,6 +297,48 @@ def _grouping_item(
|
||||
if source.postbox_id in visible_ids
|
||||
]
|
||||
counts = counts_by_postbox or {}
|
||||
constraints = []
|
||||
policy_sources = []
|
||||
for source in grouping.sources:
|
||||
if source.postbox_id not in visible_ids:
|
||||
continue
|
||||
settings = (
|
||||
source.postbox.settings
|
||||
if isinstance(source.postbox.settings, Mapping)
|
||||
else {}
|
||||
)
|
||||
policy = normalize_postbox_grouping_policy(
|
||||
settings.get("grouping_policy")
|
||||
if isinstance(settings.get("grouping_policy"), Mapping)
|
||||
else None
|
||||
)
|
||||
policy_sources.append(
|
||||
(
|
||||
source.postbox_id,
|
||||
source.postbox.classification,
|
||||
policy,
|
||||
)
|
||||
)
|
||||
if policy["mode"] == "allow":
|
||||
continue
|
||||
constraints.append(
|
||||
{
|
||||
"code": (
|
||||
"source_requires_separation"
|
||||
if policy["mode"] == "separate"
|
||||
else "classification_separation_required"
|
||||
),
|
||||
"mode": policy["mode"],
|
||||
"postbox_id": source.postbox_id,
|
||||
"reason": policy["reason"],
|
||||
"enforced_by": "postbox_configuration",
|
||||
}
|
||||
)
|
||||
count_source_ids = (
|
||||
[]
|
||||
if grouping_policy_conflicts(policy_sources)
|
||||
else visible_source_ids
|
||||
)
|
||||
return PostboxGroupingItem(
|
||||
id=grouping.id,
|
||||
name=grouping.name,
|
||||
@@ -299,12 +348,13 @@ def _grouping_item(
|
||||
postbox_ids=visible_source_ids,
|
||||
total_count=sum(
|
||||
int(counts.get(postbox_id, {}).get("total", 0))
|
||||
for postbox_id in visible_source_ids
|
||||
for postbox_id in count_source_ids
|
||||
),
|
||||
unread_count=sum(
|
||||
int(counts.get(postbox_id, {}).get("unread", 0))
|
||||
for postbox_id in visible_source_ids
|
||||
for postbox_id in count_source_ids
|
||||
),
|
||||
constraints=constraints,
|
||||
created_at=grouping.created_at,
|
||||
updated_at=grouping.updated_at,
|
||||
)
|
||||
@@ -1033,6 +1083,52 @@ def api_update_postbox_protection_policy(
|
||||
return item
|
||||
|
||||
|
||||
@router.put(
|
||||
"/admin/postboxes/{postbox_id}/grouping-policy",
|
||||
response_model=PostboxDirectoryItem,
|
||||
)
|
||||
def api_update_postbox_grouping_policy(
|
||||
postbox_id: str,
|
||||
payload: PostboxGroupingPolicyUpdateRequest,
|
||||
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_grouping_policy(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
grouping_policy=payload.grouping_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,
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from govoplan_postbox.backend.grouping_policies import PostboxGroupingPolicyMode
|
||||
from govoplan_postbox.backend.protection_profiles import (
|
||||
POSTBOX_MANAGED_ENVELOPE_PROFILE,
|
||||
POSTBOX_PLAINTEXT_PROFILE,
|
||||
@@ -58,6 +59,7 @@ class PostboxDirectoryItem(BaseModel):
|
||||
key_epoch: int = Field(default=1, ge=1)
|
||||
encryption_vault_id: str | None = None
|
||||
protection_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
grouping_policy: dict[str, Any] = Field(default_factory=dict)
|
||||
access: PostboxAccessDecisionResponse | None = None
|
||||
resource_revision: int = Field(default=1, ge=1)
|
||||
etag: str | None = None
|
||||
@@ -465,6 +467,16 @@ class PostboxProtectionPolicyPayload(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class PostboxGroupingPolicyPayload(BaseModel):
|
||||
mode: PostboxGroupingPolicyMode = "allow"
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_reason(self) -> "PostboxGroupingPolicyPayload":
|
||||
self.reason = self.reason.strip() if self.reason else None
|
||||
return self
|
||||
|
||||
|
||||
class PostboxExactCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=500)
|
||||
description: str | None = None
|
||||
@@ -478,6 +490,9 @@ class PostboxExactCreateRequest(BaseModel):
|
||||
protection_policy: PostboxProtectionPolicyPayload = Field(
|
||||
default_factory=PostboxProtectionPolicyPayload
|
||||
)
|
||||
grouping_policy: PostboxGroupingPolicyPayload = Field(
|
||||
default_factory=PostboxGroupingPolicyPayload
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_encryption(self) -> "PostboxExactCreateRequest":
|
||||
@@ -498,6 +513,11 @@ class PostboxProtectionPolicyUpdateRequest(BaseModel):
|
||||
protection_policy: PostboxProtectionPolicyPayload
|
||||
|
||||
|
||||
class PostboxGroupingPolicyUpdateRequest(BaseModel):
|
||||
base_revision: int = Field(ge=1)
|
||||
grouping_policy: PostboxGroupingPolicyPayload
|
||||
|
||||
|
||||
class PostboxTemplateRevisionPayload(BaseModel):
|
||||
function_type_id: str | None = Field(default=None, max_length=36)
|
||||
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
|
||||
@@ -522,6 +542,9 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
||||
protection_policy: PostboxProtectionPolicyPayload = Field(
|
||||
default_factory=PostboxProtectionPolicyPayload
|
||||
)
|
||||
grouping_policy: PostboxGroupingPolicyPayload = Field(
|
||||
default_factory=PostboxGroupingPolicyPayload
|
||||
)
|
||||
routing_policy: PostboxRoutingPolicyPayload = Field(
|
||||
default_factory=PostboxRoutingPolicyPayload
|
||||
)
|
||||
@@ -861,10 +884,22 @@ class PostboxGroupingUpdateRequest(PostboxGroupingPayload):
|
||||
base_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class PostboxGroupingConstraintItem(BaseModel):
|
||||
code: Literal[
|
||||
"source_requires_separation",
|
||||
"classification_separation_required",
|
||||
]
|
||||
mode: PostboxGroupingPolicyMode
|
||||
postbox_id: str
|
||||
reason: str | None = None
|
||||
enforced_by: Literal["postbox_configuration"] = "postbox_configuration"
|
||||
|
||||
|
||||
class PostboxGroupingItem(PostboxGroupingPayload):
|
||||
id: str
|
||||
total_count: int = Field(default=0, ge=0)
|
||||
unread_count: int = Field(default=0, ge=0)
|
||||
constraints: list[PostboxGroupingConstraintItem] = Field(default_factory=list)
|
||||
resource_revision: int = Field(ge=1)
|
||||
etag: str
|
||||
created_at: datetime
|
||||
|
||||
@@ -106,6 +106,10 @@ from govoplan_postbox.backend.hierarchy_routing import (
|
||||
normalized_routing_policy,
|
||||
plan_hierarchy_routes,
|
||||
)
|
||||
from govoplan_postbox.backend.grouping_policies import (
|
||||
grouping_policy_conflicts,
|
||||
normalize_postbox_grouping_policy,
|
||||
)
|
||||
from govoplan_postbox.backend.access_decisions import evaluate_postbox_access
|
||||
from govoplan_postbox.backend.protection_profiles import (
|
||||
POSTBOX_EXTERNAL_E2EE_PROFILE,
|
||||
@@ -746,6 +750,11 @@ class PostboxService:
|
||||
state: PostboxMessageListState = "all",
|
||||
) -> tuple[PostboxMessageRef, ...]:
|
||||
db = _session(session)
|
||||
self._enforce_grouping_policy(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
postbox_ids=postbox_ids,
|
||||
)
|
||||
history_cutoffs = self._allowed_postbox_history_cutoffs(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
@@ -794,6 +803,11 @@ class PostboxService:
|
||||
state: PostboxMessageListState = "all",
|
||||
) -> int:
|
||||
db = _session(session)
|
||||
self._enforce_grouping_policy(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
postbox_ids=postbox_ids,
|
||||
)
|
||||
history_cutoffs = self._allowed_postbox_history_cutoffs(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
@@ -828,7 +842,7 @@ class PostboxService:
|
||||
limit: int = 100,
|
||||
query: str | None = None,
|
||||
) -> tuple[tuple[PostboxMessageRef, ...], int]:
|
||||
"""Return unread messages that can still be acted on by this actor."""
|
||||
"""Return source-labelled unread work; this is not a personal grouping."""
|
||||
|
||||
db = _session(session)
|
||||
history_cutoffs = self._allowed_postbox_history_cutoffs(
|
||||
@@ -2942,6 +2956,7 @@ class PostboxService:
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
protection_policy: Mapping[str, object] | None = None,
|
||||
grouping_policy: Mapping[str, object] | None = None,
|
||||
) -> Postbox:
|
||||
classification = self._validate_classification(classification)
|
||||
_validate_encryption_configuration(
|
||||
@@ -2987,6 +3002,7 @@ class PostboxService:
|
||||
encryption_profile=encryption_profile,
|
||||
encryption_vault_id=encryption_vault_id,
|
||||
protection_policy=protection_policy,
|
||||
grouping_policy=grouping_policy,
|
||||
portal_visible=portal_visible,
|
||||
)
|
||||
|
||||
@@ -3107,6 +3123,67 @@ class PostboxService:
|
||||
)
|
||||
return postbox
|
||||
|
||||
def update_grouping_policy(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_id: str,
|
||||
grouping_policy: Mapping[str, object],
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
) -> Postbox:
|
||||
postbox = self._get_postbox(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
postbox_id=postbox_id,
|
||||
)
|
||||
self._claim_resource_revision(
|
||||
session,
|
||||
model=Postbox,
|
||||
resource=postbox,
|
||||
resource_type="postbox",
|
||||
tenant_id=tenant_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
settings = dict(postbox.settings or {})
|
||||
previous = normalize_postbox_grouping_policy(
|
||||
_mapping(settings.get("grouping_policy"))
|
||||
)
|
||||
current = normalize_postbox_grouping_policy(grouping_policy)
|
||||
settings["grouping_policy"] = current
|
||||
postbox.settings = settings
|
||||
session.flush()
|
||||
self._record_access_event(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
postbox_id=postbox.id,
|
||||
actor=(
|
||||
PostboxActorRef(
|
||||
account_id=actor_id,
|
||||
authorized_actions=frozenset({"administer"}),
|
||||
)
|
||||
if actor_id
|
||||
else None
|
||||
),
|
||||
action="postbox.grouping.policy.update",
|
||||
outcome="allowed",
|
||||
reason_code="administrator",
|
||||
details={"previous": previous, "current": current},
|
||||
)
|
||||
_publish_postbox_event(
|
||||
session,
|
||||
"postbox.grouping.policy.updated.v1",
|
||||
tenant_id=tenant_id,
|
||||
resource_type="postbox",
|
||||
resource_id=postbox.id,
|
||||
postbox_id=postbox.id,
|
||||
actor_type="user",
|
||||
actor_id=actor_id,
|
||||
payload={"previous": previous, "current": current},
|
||||
)
|
||||
return postbox
|
||||
|
||||
def list_protection_transitions(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -4114,11 +4191,12 @@ class PostboxService:
|
||||
encryption_profile: str,
|
||||
encryption_vault_id: str | None,
|
||||
protection_policy: Mapping[str, object] | None,
|
||||
grouping_policy: Mapping[str, object] | None = None,
|
||||
template_id: str | None,
|
||||
context_key: str | None,
|
||||
limit: int,
|
||||
) -> dict[str, object]:
|
||||
del description, portal_visible, protection_policy
|
||||
del description, portal_visible, protection_policy, grouping_policy
|
||||
self._validate_classification(classification)
|
||||
_validate_encryption_configuration(
|
||||
encryption_profile,
|
||||
@@ -4311,6 +4389,7 @@ class PostboxService:
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
protection_policy: Mapping[str, object] | None = None,
|
||||
grouping_policy: Mapping[str, object] | None = None,
|
||||
) -> PostboxTemplate:
|
||||
classification = self._validate_classification(classification)
|
||||
_validate_encryption_configuration(
|
||||
@@ -4366,6 +4445,7 @@ class PostboxService:
|
||||
history_policy=normalize_postbox_protection_policy(
|
||||
dict(protection_policy or {})
|
||||
),
|
||||
grouping_policy=normalize_postbox_grouping_policy(grouping_policy),
|
||||
routing_policy=normalized_routing_policy(routing_policy),
|
||||
retention_policy={},
|
||||
created_by=actor_id,
|
||||
@@ -4407,6 +4487,7 @@ class PostboxService:
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
protection_policy: Mapping[str, object] | None = None,
|
||||
grouping_policy: Mapping[str, object] | None = None,
|
||||
) -> PostboxTemplate:
|
||||
classification = self._validate_classification(classification)
|
||||
_validate_encryption_configuration(
|
||||
@@ -4465,6 +4546,7 @@ class PostboxService:
|
||||
history_policy=normalize_postbox_protection_policy(
|
||||
dict(protection_policy or {})
|
||||
),
|
||||
grouping_policy=normalize_postbox_grouping_policy(grouping_policy),
|
||||
routing_policy=normalized_routing_policy(routing_policy),
|
||||
retention_policy={},
|
||||
created_by=actor_id,
|
||||
@@ -4763,6 +4845,66 @@ class PostboxService:
|
||||
)
|
||||
|
||||
# Grouping projections
|
||||
@staticmethod
|
||||
def _grouping_policy_sources(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_ids: Sequence[str],
|
||||
) -> tuple[tuple[str, str, Mapping[str, object] | None], ...]:
|
||||
requested = tuple(dict.fromkeys(postbox_ids))
|
||||
if not requested:
|
||||
return ()
|
||||
rows = (
|
||||
session.query(Postbox.id, Postbox.classification, Postbox.settings)
|
||||
.filter(
|
||||
Postbox.tenant_id == tenant_id,
|
||||
Postbox.id.in_(requested),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
by_id = {
|
||||
str(postbox_id): (
|
||||
str(classification),
|
||||
_mapping(settings).get("grouping_policy"),
|
||||
)
|
||||
for postbox_id, classification, settings in rows
|
||||
}
|
||||
return tuple(
|
||||
(
|
||||
postbox_id,
|
||||
by_id[postbox_id][0],
|
||||
(
|
||||
by_id[postbox_id][1]
|
||||
if isinstance(by_id[postbox_id][1], Mapping)
|
||||
else None
|
||||
),
|
||||
)
|
||||
for postbox_id in requested
|
||||
if postbox_id in by_id
|
||||
)
|
||||
|
||||
def _enforce_grouping_policy(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_ids: Sequence[str],
|
||||
) -> None:
|
||||
conflicts = grouping_policy_conflicts(
|
||||
self._grouping_policy_sources(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
postbox_ids=postbox_ids,
|
||||
)
|
||||
)
|
||||
if conflicts:
|
||||
raise PostboxError(
|
||||
"grouping_policy_conflict",
|
||||
"The requested unified Postbox projection conflicts with an enforced source separation policy: "
|
||||
+ ", ".join(conflicts),
|
||||
)
|
||||
|
||||
def list_groupings(
|
||||
self,
|
||||
session: Session,
|
||||
@@ -4772,7 +4914,11 @@ class PostboxService:
|
||||
) -> tuple[PostboxGrouping, ...]:
|
||||
return tuple(
|
||||
session.query(PostboxGrouping)
|
||||
.options(selectinload(PostboxGrouping.sources))
|
||||
.options(
|
||||
selectinload(PostboxGrouping.sources).selectinload(
|
||||
PostboxGroupingSource.postbox
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
PostboxGrouping.tenant_id == tenant_id,
|
||||
PostboxGrouping.account_id == actor.account_id,
|
||||
@@ -4798,7 +4944,11 @@ class PostboxService:
|
||||
) -> PostboxGrouping:
|
||||
grouping = (
|
||||
session.query(PostboxGrouping)
|
||||
.options(selectinload(PostboxGrouping.sources))
|
||||
.options(
|
||||
selectinload(PostboxGrouping.sources).selectinload(
|
||||
PostboxGroupingSource.postbox
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
PostboxGrouping.id == grouping_id,
|
||||
PostboxGrouping.tenant_id == tenant_id,
|
||||
@@ -4847,6 +4997,11 @@ class PostboxService:
|
||||
if postbox_id not in allowed and postbox_id not in requested
|
||||
)
|
||||
saved_ids = (*requested, *retained_hidden_ids)
|
||||
self._enforce_grouping_policy(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
postbox_ids=saved_ids,
|
||||
)
|
||||
if grouping is None:
|
||||
grouping = PostboxGrouping(
|
||||
tenant_id=tenant_id,
|
||||
@@ -5480,6 +5635,13 @@ class PostboxService:
|
||||
and isinstance(postbox.settings.get("protection_policy"), Mapping)
|
||||
else normalize_postbox_protection_policy()
|
||||
),
|
||||
grouping_policy=(
|
||||
normalize_postbox_grouping_policy(
|
||||
_mapping(postbox.settings.get("grouping_policy"))
|
||||
)
|
||||
if isinstance(postbox.settings, Mapping)
|
||||
else normalize_postbox_grouping_policy()
|
||||
),
|
||||
access=decision,
|
||||
resource_revision=postbox.resource_revision,
|
||||
etag=postbox.strong_etag,
|
||||
@@ -5714,6 +5876,7 @@ class PostboxService:
|
||||
encryption_profile: str | None = None,
|
||||
encryption_vault_id: str | None = None,
|
||||
protection_policy: Mapping[str, object] | None = None,
|
||||
grouping_policy: Mapping[str, object] | None = None,
|
||||
portal_visible: bool = False,
|
||||
) -> Postbox:
|
||||
effective_profile = (
|
||||
@@ -5736,6 +5899,11 @@ class PostboxService:
|
||||
if revision is not None
|
||||
else normalize_postbox_protection_policy(dict(protection_policy or {}))
|
||||
)
|
||||
effective_grouping_policy = (
|
||||
normalize_postbox_grouping_policy(revision.grouping_policy)
|
||||
if revision is not None
|
||||
else normalize_postbox_grouping_policy(grouping_policy)
|
||||
)
|
||||
if (
|
||||
effective_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE
|
||||
and not str(effective_vault_id or "").strip()
|
||||
@@ -5780,6 +5948,7 @@ class PostboxService:
|
||||
),
|
||||
"portal_visible": effective_portal_visible,
|
||||
"protection_policy": effective_protection_policy,
|
||||
"grouping_policy": effective_grouping_policy,
|
||||
},
|
||||
)
|
||||
postbox.bindings.append(
|
||||
|
||||
Reference in New Issue
Block a user