feat(postbox): enforce unified inbox separation

This commit is contained in:
2026-08-20 04:22:29 +02:00
parent 174ee97719
commit 41ea8d8e23
16 changed files with 1029 additions and 25 deletions
@@ -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",
]
+52
View File
@@ -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")
+98 -2
View File
@@ -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,
+35
View File
@@ -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
+173 -4
View File
@@ -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(