feat: complete postbox access evidence

This commit is contained in:
2026-07-30 14:26:59 +02:00
parent 28b60782de
commit bdc161e889
10 changed files with 858 additions and 96 deletions
@@ -0,0 +1,235 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Sequence
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
from govoplan_core.core.postbox import (
PostboxAccessDecisionRef,
PostboxAction,
PostboxActorRef,
)
@dataclass(frozen=True, slots=True)
class PostboxAccessContext:
postbox_id: str
postbox_active: bool
action: PostboxAction
actor: PostboxActorRef
organization_unit_id: str | None
function_id: str | None
holder_count: int
binding_available: bool
binding_assignments: tuple[OrganizationFunctionAssignmentRef, ...]
@property
def base(self) -> dict[str, object]:
return {
"action": self.action,
"postbox_id": self.postbox_id,
"organization_unit_id": self.organization_unit_id,
"function_id": self.function_id,
"holder_count": self.holder_count,
"vacant": self.holder_count == 0,
}
@dataclass(frozen=True, slots=True)
class AccessRule:
name: str
matches: Callable[[PostboxAccessContext], bool]
decision: Callable[[PostboxAccessContext], PostboxAccessDecisionRef]
def evaluate_postbox_access(
*,
postbox_id: str,
postbox_active: bool,
action: PostboxAction,
actor: PostboxActorRef,
organization_unit_id: str | None,
function_id: str | None,
holder_count: int,
binding_available: bool,
binding_assignments: Sequence[OrganizationFunctionAssignmentRef],
) -> PostboxAccessDecisionRef:
context = PostboxAccessContext(
postbox_id=postbox_id,
postbox_active=postbox_active,
action=action,
actor=actor,
organization_unit_id=organization_unit_id,
function_id=function_id,
holder_count=holder_count,
binding_available=binding_available,
binding_assignments=tuple(binding_assignments),
)
for rule in ACCESS_DECISION_TABLE:
if rule.matches(context):
return rule.decision(context)
return _assignment_decision(context)
def _deny(
context: PostboxAccessContext,
*,
reason_code: str,
explanation: str,
) -> PostboxAccessDecisionRef:
return PostboxAccessDecisionRef(
allowed=False,
reason_code=reason_code,
explanation=explanation,
**context.base,
)
def _inactive(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
return _deny(
context,
reason_code="postbox_inactive",
explanation="The Postbox is not active.",
)
def _permission_missing(
context: PostboxAccessContext,
) -> PostboxAccessDecisionRef:
return _deny(
context,
reason_code="generic_permission_missing",
explanation=(
"The account lacks the generic Postbox permission for this action."
),
)
def _administrator(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
return PostboxAccessDecisionRef(
allowed=True,
reason_code="generic_administrator",
explanation="The account has Postbox administration permission.",
**context.base,
)
def _binding_missing(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
return _deny(
context,
reason_code="function_binding_missing",
explanation="This Postbox has no active organization-function binding.",
)
def _assignment_decision(
context: PostboxAccessContext,
) -> PostboxAccessDecisionRef:
eligible: list[OrganizationFunctionAssignmentRef] = []
acting_candidates: list[OrganizationFunctionAssignmentRef] = []
for assignment in context.binding_assignments:
if assignment.source != "acting_for":
eligible.append(assignment)
continue
acting_candidates.append(assignment)
if context.actor.selected_assignment_id != assignment.id:
continue
if (
assignment.acting_for_account_id
and context.actor.acting_for_account_id
!= assignment.acting_for_account_id
):
return _deny(
context,
reason_code="acting_account_mismatch",
explanation=(
"The selected acting assignment belongs to another "
"represented account context."
),
)
eligible.append(assignment)
if not eligible:
if acting_candidates and not context.actor.selected_assignment_id:
return _deny(
context,
reason_code="acting_context_required",
explanation=(
"Select the acting assignment context before opening this "
"Postbox."
),
)
if acting_candidates:
return _deny(
context,
reason_code="acting_assignment_not_selected",
explanation=(
"The selected assignment context does not grant access to "
"this Postbox."
),
)
return _deny(
context,
reason_code="effective_assignment_missing",
explanation=(
"No current function assignment grants this account access "
"to the Postbox."
),
)
selected = next(
(
assignment
for assignment in eligible
if assignment.id == context.actor.selected_assignment_id
),
eligible[0],
)
return PostboxAccessDecisionRef(
allowed=True,
reason_code=f"effective_{selected.source}_assignment",
explanation=(
"Access follows the current effective organization-function "
f"assignment ({selected.source})."
),
assignment_ids=tuple(item.id for item in eligible),
assignment_sources=tuple(item.source for item in eligible),
selected_assignment_id=selected.id,
**context.base,
)
ACCESS_DECISION_TABLE = (
AccessRule(
name="inactive_postbox",
matches=lambda context: not context.postbox_active,
decision=_inactive,
),
AccessRule(
name="generic_permission",
matches=lambda context: context.action not in context.actor.authorized_actions,
decision=_permission_missing,
),
AccessRule(
name="administrator",
matches=lambda context: context.action == "administer",
decision=_administrator,
),
AccessRule(
name="active_function_binding",
matches=lambda context: (
not context.binding_available
or not context.function_id
or not context.organization_unit_id
),
decision=_binding_missing,
),
)
__all__ = [
"ACCESS_DECISION_TABLE",
"AccessRule",
"PostboxAccessContext",
"evaluate_postbox_access",
]
+1 -1
View File
@@ -48,7 +48,7 @@ from govoplan_postbox.backend.db import models as postbox_models
MODULE_ID = "postbox"
MODULE_NAME = "Postbox"
MODULE_VERSION = "0.1.1"
MODULE_VERSION = "0.1.2"
READ_SCOPE = "postbox:postbox:read"
SEND_SCOPE = "postbox:message:write"
+1
View File
@@ -69,6 +69,7 @@ class PostboxMessageItem(BaseModel):
subject: str
body_text: str | None = None
status: str
availability: Literal["available", "withdrawn", "expired"]
classification: str
sender_label: str | None = None
delivered_at: datetime
+237 -85
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import logging
import re
from collections import Counter
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from typing import Literal
@@ -48,11 +49,13 @@ from govoplan_core.core.postbox import (
PostboxActorRef,
PostboxAttachmentRef,
PostboxDeliveryCatalogRef,
PostboxDeliveryReceiptSummaryRef,
PostboxDeliveryRequest,
PostboxDeliveryRejected,
PostboxDeliveryResult,
PostboxDeliveryTemplateRef,
PostboxDirectoryEntryRef,
PostboxMessageAvailability,
PostboxMessageRef,
PostboxMessageListState,
PostboxOrganizationFunctionTargetRef,
@@ -84,6 +87,7 @@ from govoplan_postbox.backend.hierarchy_routing import (
normalized_routing_policy,
plan_hierarchy_routes,
)
from govoplan_postbox.backend.access_decisions import evaluate_postbox_access
logger = logging.getLogger(__name__)
@@ -123,6 +127,28 @@ def _as_utc(value: datetime) -> datetime:
return value.astimezone(timezone.utc)
def _message_availability(
message: PostboxMessage,
*,
now: datetime | None = None,
) -> PostboxMessageAvailability:
if message.withdrawn_at is not None:
return "withdrawn"
if message.expires_at is not None and _as_utc(message.expires_at) <= _as_utc(
now or utc_now()
):
return "expired"
return "available"
def _first_timestamp(values: Sequence[datetime]) -> datetime | None:
return min(values, key=_as_utc) if values else None
def _last_timestamp(values: Sequence[datetime]) -> datetime | None:
return max(values, key=_as_utc) if values else None
def _publish_postbox_event(
session: Session,
event_type: str,
@@ -651,6 +677,12 @@ class PostboxService:
)
if not decision.allowed:
raise PostboxError("access_denied", decision.explanation)
availability = _message_availability(message)
if availability != "available":
raise PostboxError(
f"message_{availability}",
f"This Postbox message is {availability} and cannot be changed.",
)
receipt = (
db.query(PostboxMessageReceipt)
.filter(
@@ -1918,6 +1950,189 @@ class PostboxService:
)
return self._message_ref(message, account_id="")
def delivery_receipt_summaries(
self,
session: object,
*,
tenant_id: str,
producer_module: str,
delivery_ids: Sequence[str],
) -> Mapping[str, PostboxDeliveryReceiptSummaryRef]:
db = _session(session)
requested_ids = tuple(
dict.fromkeys(
str(delivery_id).strip()
for delivery_id in delivery_ids
if str(delivery_id).strip()
)
)
if not requested_ids:
return {}
if len(requested_ids) > 1000:
raise PostboxError(
"delivery_summary_limit",
"At most 1000 Postbox deliveries can be summarized at once.",
)
deliveries = (
db.query(PostboxDelivery)
.filter(
PostboxDelivery.tenant_id == tenant_id,
PostboxDelivery.producer_module == producer_module,
PostboxDelivery.id.in_(requested_ids),
)
.all()
)
if not deliveries:
return {}
accepted_ids = tuple(delivery.id for delivery in deliveries)
routes = (
db.query(PostboxRoute)
.filter(
PostboxRoute.tenant_id == tenant_id,
PostboxRoute.delivery_id.in_(accepted_ids),
)
.all()
)
routes_by_delivery: dict[str, list[PostboxRoute]] = {
delivery_id: [] for delivery_id in accepted_ids
}
message_ids_by_delivery: dict[str, set[str]] = {
delivery.id: {delivery.message_id} for delivery in deliveries
}
for route in routes:
routes_by_delivery.setdefault(route.delivery_id, []).append(route)
if route.target_message_id:
message_ids_by_delivery.setdefault(route.delivery_id, set()).add(
route.target_message_id
)
message_ids = tuple(
{
message_id
for ids in message_ids_by_delivery.values()
for message_id in ids
}
)
messages = (
db.query(PostboxMessage)
.options(selectinload(PostboxMessage.receipts))
.filter(
PostboxMessage.tenant_id == tenant_id,
PostboxMessage.id.in_(message_ids),
)
.all()
)
messages_by_id = {message.id: message for message in messages}
postbox_ids = tuple({message.postbox_id for message in messages})
postboxes = (
db.query(Postbox)
.options(
selectinload(Postbox.address_record),
selectinload(Postbox.bindings),
)
.filter(
Postbox.tenant_id == tenant_id,
Postbox.id.in_(postbox_ids),
)
.all()
)
postboxes_by_id = {postbox.id: postbox for postbox in postboxes}
holder_cache = self._holder_cache(
tenant_id=tenant_id,
postboxes=postboxes,
)
holder_counts: dict[str, int] = {}
for postbox in postboxes:
binding = self._active_binding(postbox)
function_id = (
binding.function_id
if binding is not None
else postbox.address_record.function_id
)
holder_counts[postbox.id] = len(
{
holder.identity_id
for holder in self._holders(
tenant_id,
function_id,
holder_cache,
)
}
)
now = utc_now()
summaries: dict[str, PostboxDeliveryReceiptSummaryRef] = {}
for delivery in deliveries:
delivery_messages = [
messages_by_id[message_id]
for message_id in message_ids_by_delivery.get(delivery.id, ())
if message_id in messages_by_id
]
read_times = [
receipt.read_at
for message in delivery_messages
for receipt in message.receipts
if receipt.read_at is not None
]
acknowledged_times = [
receipt.acknowledged_at
for message in delivery_messages
for receipt in message.receipts
if receipt.acknowledged_at is not None
]
availabilities = {
message.id: _message_availability(message, now=now)
for message in delivery_messages
}
readable_count = sum(
1
for message in delivery_messages
if availabilities[message.id] == "available"
and (
postbox := postboxes_by_id.get(message.postbox_id)
) is not None
and postbox.status == "active"
and holder_counts.get(postbox.id, 0) > 0
)
delivery_routes = routes_by_delivery.get(delivery.id, ())
route_status_counts = Counter(route.status for route in delivery_routes)
summaries[delivery.id] = PostboxDeliveryReceiptSummaryRef(
delivery_id=delivery.id,
message_id=delivery.message_id,
postbox_id=delivery.postbox_id,
delivery_status=delivery.status,
accepted_at=delivery.accepted_at,
current_holder_count=holder_counts.get(delivery.postbox_id, 0),
currently_readable=readable_count > 0,
message_count=len(delivery_messages),
routed_message_count=len(
{
route.target_message_id
for route in delivery_routes
if route.target_message_id
}
),
readable_message_count=readable_count,
read_receipt_count=len(read_times),
acknowledged_receipt_count=len(acknowledged_times),
withdrawn_message_count=sum(
availability == "withdrawn"
for availability in availabilities.values()
),
expired_message_count=sum(
availability == "expired"
for availability in availabilities.values()
),
first_read_at=_first_timestamp(read_times),
last_read_at=_last_timestamp(read_times),
first_acknowledged_at=_first_timestamp(acknowledged_times),
last_acknowledged_at=_last_timestamp(acknowledged_times),
route_status_counts=dict(sorted(route_status_counts.items())),
)
return summaries
# Administration
def list_admin_postboxes(
self,
@@ -2825,92 +3040,24 @@ class PostboxService:
)
holders = self._holders(postbox.tenant_id, function_id, holder_cache)
holder_count = len({holder.identity_id for holder in holders})
base = {
"action": action,
"postbox_id": postbox.id,
"organization_unit_id": unit_id,
"function_id": function_id,
"holder_count": holder_count,
"vacant": holder_count == 0,
}
if postbox.status != "active":
return PostboxAccessDecisionRef(
allowed=False,
reason_code="postbox_inactive",
explanation="The Postbox is not active.",
**base,
)
if action not in actor.authorized_actions:
return PostboxAccessDecisionRef(
allowed=False,
reason_code="generic_permission_missing",
explanation=(
"The account lacks the generic Postbox permission for this action."
),
**base,
)
if action == "administer":
return PostboxAccessDecisionRef(
allowed=True,
reason_code="generic_administrator",
explanation="The account has Postbox administration permission.",
**base,
)
if binding is None or not function_id or not unit_id:
return PostboxAccessDecisionRef(
allowed=False,
reason_code="function_binding_missing",
explanation=(
"This Postbox has no active organization-function binding."
),
**base,
)
matching: list[OrganizationFunctionAssignmentRef] = []
for assignment in assignments:
if assignment.tenant_id != postbox.tenant_id:
continue
if not self._assignment_matches_binding(assignment, binding):
continue
if assignment.source == "acting_for":
if actor.selected_assignment_id != assignment.id:
binding_assignments: list[OrganizationFunctionAssignmentRef] = []
if binding is not None:
for assignment in assignments:
if assignment.tenant_id != postbox.tenant_id:
continue
if (
assignment.acting_for_account_id
and actor.acting_for_account_id
!= assignment.acting_for_account_id
):
if not self._assignment_matches_binding(assignment, binding):
continue
matching.append(assignment)
if not matching:
return PostboxAccessDecisionRef(
allowed=False,
reason_code="effective_assignment_missing",
explanation=(
"No current function assignment grants this account access "
"to the Postbox."
),
**base,
)
selected = next(
(
assignment
for assignment in matching
if assignment.id == actor.selected_assignment_id
),
matching[0],
)
return PostboxAccessDecisionRef(
allowed=True,
reason_code=f"effective_{selected.source}_assignment",
explanation=(
"Access follows the current effective organization-function "
f"assignment ({selected.source})."
),
assignment_ids=tuple(item.id for item in matching),
assignment_sources=tuple(item.source for item in matching),
selected_assignment_id=selected.id,
**base,
binding_assignments.append(assignment)
return evaluate_postbox_access(
postbox_id=postbox.id,
postbox_active=postbox.status == "active",
action=action,
actor=actor,
organization_unit_id=unit_id,
function_id=function_id,
holder_count=holder_count,
binding_available=binding is not None,
binding_assignments=binding_assignments,
)
def _assignment_matches_binding(
@@ -3046,13 +3193,16 @@ class PostboxService:
),
None,
)
availability = _message_availability(message)
content_available = availability == "available"
return PostboxMessageRef(
id=message.id,
tenant_id=message.tenant_id,
postbox_id=message.postbox_id,
subject=message.subject,
body_text=message.body_text,
body_text=message.body_text if content_available else None,
status=message.status,
availability=availability,
classification=message.classification,
sender_label=message.sender_label,
delivered_at=message.delivered_at,
@@ -3076,6 +3226,7 @@ class PostboxService:
address=item.address,
)
for item in message.participants
if content_available
),
attachments=tuple(
PostboxAttachmentRef(
@@ -3088,6 +3239,7 @@ class PostboxService:
metadata=_mapping(item.metadata_),
)
for item in message.attachments
if content_available
),
metadata=_mapping(message.metadata_),
)