feat: complete postbox access evidence
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-postbox"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "Function-bound institutional postboxes for GovOPlaN."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
binding_assignments: list[OrganizationFunctionAssignmentRef] = []
|
||||
if binding is not None:
|
||||
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:
|
||||
continue
|
||||
if (
|
||||
assignment.acting_for_account_id
|
||||
and actor.acting_for_account_id
|
||||
!= assignment.acting_for_account_id
|
||||
):
|
||||
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_),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.postbox import PostboxActorRef
|
||||
from govoplan_postbox.backend.access_decisions import (
|
||||
ACCESS_DECISION_TABLE,
|
||||
evaluate_postbox_access,
|
||||
)
|
||||
|
||||
|
||||
def assignment(
|
||||
source: str = "direct",
|
||||
*,
|
||||
assignment_id: str | None = None,
|
||||
tenant_id: str = "tenant-1",
|
||||
acting_for_account_id: str | None = None,
|
||||
) -> OrganizationFunctionAssignmentRef:
|
||||
return OrganizationFunctionAssignmentRef(
|
||||
id=assignment_id or f"{source}-assignment",
|
||||
tenant_id=tenant_id,
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source=source, # type: ignore[arg-type]
|
||||
acting_for_account_id=acting_for_account_id,
|
||||
)
|
||||
|
||||
|
||||
def decide(
|
||||
*,
|
||||
status: str = "active",
|
||||
action: str = "read",
|
||||
authorized_actions: frozenset[str] = frozenset({"read"}),
|
||||
binding_available: bool = True,
|
||||
assignments=(),
|
||||
selected_assignment_id: str | None = None,
|
||||
acting_for_account_id: str | None = None,
|
||||
):
|
||||
return evaluate_postbox_access(
|
||||
postbox_id="postbox-1",
|
||||
postbox_active=status == "active",
|
||||
action=action, # type: ignore[arg-type]
|
||||
actor=PostboxActorRef(
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
selected_assignment_id=selected_assignment_id,
|
||||
acting_for_account_id=acting_for_account_id,
|
||||
authorized_actions=authorized_actions, # type: ignore[arg-type]
|
||||
),
|
||||
organization_unit_id="unit-1" if binding_available else None,
|
||||
function_id="function-1" if binding_available else None,
|
||||
holder_count=len(assignments),
|
||||
binding_available=binding_available,
|
||||
binding_assignments=assignments,
|
||||
)
|
||||
|
||||
|
||||
class PostboxAccessDecisionTableTests(unittest.TestCase):
|
||||
def test_rule_order_is_fail_closed(self) -> None:
|
||||
self.assertEqual(
|
||||
[rule.name for rule in ACCESS_DECISION_TABLE],
|
||||
[
|
||||
"inactive_postbox",
|
||||
"generic_permission",
|
||||
"administrator",
|
||||
"active_function_binding",
|
||||
],
|
||||
)
|
||||
inactive_admin = decide(
|
||||
status="archived",
|
||||
action="administer",
|
||||
authorized_actions=frozenset({"administer"}),
|
||||
)
|
||||
self.assertFalse(inactive_admin.allowed)
|
||||
self.assertEqual(inactive_admin.reason_code, "postbox_inactive")
|
||||
|
||||
def test_permission_and_binding_denials_have_stable_provenance(self) -> None:
|
||||
missing_permission = decide(
|
||||
assignments=(assignment(),),
|
||||
authorized_actions=frozenset(),
|
||||
)
|
||||
missing_binding = decide(binding_available=False)
|
||||
|
||||
self.assertEqual(
|
||||
missing_permission.reason_code,
|
||||
"generic_permission_missing",
|
||||
)
|
||||
self.assertEqual(
|
||||
missing_binding.reason_code,
|
||||
"function_binding_missing",
|
||||
)
|
||||
|
||||
def test_administration_is_generic_but_still_requires_active_postbox(self) -> None:
|
||||
decision = decide(
|
||||
action="administer",
|
||||
authorized_actions=frozenset({"administer"}),
|
||||
binding_available=False,
|
||||
)
|
||||
self.assertTrue(decision.allowed)
|
||||
self.assertEqual(decision.reason_code, "generic_administrator")
|
||||
|
||||
def test_direct_delegated_directory_governance_and_system_sources_are_allowed(self) -> None:
|
||||
for source in (
|
||||
"direct",
|
||||
"delegated",
|
||||
"directory",
|
||||
"governance",
|
||||
"system",
|
||||
):
|
||||
with self.subTest(source=source):
|
||||
decision = decide(assignments=(assignment(source),))
|
||||
self.assertTrue(decision.allowed)
|
||||
self.assertEqual(
|
||||
decision.reason_code,
|
||||
f"effective_{source}_assignment",
|
||||
)
|
||||
|
||||
def test_selected_direct_context_is_preferred_without_hiding_other_matches(self) -> None:
|
||||
direct = assignment("direct", assignment_id="direct-1")
|
||||
delegated = assignment("delegated", assignment_id="delegated-1")
|
||||
decision = decide(
|
||||
assignments=(direct, delegated),
|
||||
selected_assignment_id=delegated.id,
|
||||
)
|
||||
|
||||
self.assertTrue(decision.allowed)
|
||||
self.assertEqual(decision.selected_assignment_id, delegated.id)
|
||||
self.assertEqual(
|
||||
decision.assignment_ids,
|
||||
("direct-1", "delegated-1"),
|
||||
)
|
||||
|
||||
def test_acting_access_requires_exact_assignment_and_represented_account(self) -> None:
|
||||
acting = assignment(
|
||||
"acting_for",
|
||||
assignment_id="acting-1",
|
||||
acting_for_account_id="represented-1",
|
||||
)
|
||||
missing_context = decide(assignments=(acting,))
|
||||
wrong_assignment = decide(
|
||||
assignments=(acting,),
|
||||
selected_assignment_id="acting-other",
|
||||
)
|
||||
wrong_account = decide(
|
||||
assignments=(acting,),
|
||||
selected_assignment_id=acting.id,
|
||||
acting_for_account_id="represented-other",
|
||||
)
|
||||
allowed = decide(
|
||||
assignments=(acting,),
|
||||
selected_assignment_id=acting.id,
|
||||
acting_for_account_id="represented-1",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
missing_context.reason_code,
|
||||
"acting_context_required",
|
||||
)
|
||||
self.assertEqual(
|
||||
wrong_assignment.reason_code,
|
||||
"acting_assignment_not_selected",
|
||||
)
|
||||
self.assertEqual(
|
||||
wrong_account.reason_code,
|
||||
"acting_account_mismatch",
|
||||
)
|
||||
self.assertTrue(allowed.allowed)
|
||||
self.assertEqual(
|
||||
allowed.reason_code,
|
||||
"effective_acting_for_assignment",
|
||||
)
|
||||
|
||||
def test_vacancy_is_provenance_not_an_implicit_access_override(self) -> None:
|
||||
denied = decide(assignments=())
|
||||
self.assertFalse(denied.allowed)
|
||||
self.assertTrue(denied.vacant)
|
||||
self.assertEqual(
|
||||
denied.reason_code,
|
||||
"effective_assignment_missing",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+120
-1
@@ -49,7 +49,7 @@ from govoplan_postbox.backend.db.models import (
|
||||
PostboxTemplate,
|
||||
PostboxTemplateRevision,
|
||||
)
|
||||
from govoplan_postbox.backend.service import PostboxService
|
||||
from govoplan_postbox.backend.service import PostboxError, PostboxService
|
||||
|
||||
|
||||
POSTBOX_TABLES = (
|
||||
@@ -915,6 +915,12 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
actor=self.actor,
|
||||
state="acknowledged",
|
||||
)
|
||||
receipt_summary = service.delivery_receipt_summaries(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
producer_module="campaigns",
|
||||
delivery_ids=(first.delivery_id,),
|
||||
)[first.delivery_id]
|
||||
session.commit()
|
||||
|
||||
self.assertFalse(first.duplicate)
|
||||
@@ -922,6 +928,20 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
self.assertEqual(first.message_id, second.message_id)
|
||||
self.assertIsNotNone(marked.read_at)
|
||||
self.assertIsNotNone(marked.acknowledged_at)
|
||||
self.assertTrue(receipt_summary.currently_readable)
|
||||
self.assertEqual(1, receipt_summary.current_holder_count)
|
||||
self.assertEqual(1, receipt_summary.read_receipt_count)
|
||||
self.assertEqual(1, receipt_summary.acknowledged_receipt_count)
|
||||
self.assertEqual(0, receipt_summary.withdrawn_message_count)
|
||||
self.assertEqual(
|
||||
{},
|
||||
service.delivery_receipt_summaries(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
producer_module="another-module",
|
||||
delivery_ids=(first.delivery_id,),
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
session.query(PostboxMessage).count(),
|
||||
@@ -971,6 +991,95 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
[event.type for event in events],
|
||||
)
|
||||
|
||||
def test_withdrawn_and_expired_messages_keep_metadata_but_hide_content(
|
||||
self,
|
||||
) -> None:
|
||||
self.idm.assignments.append(self.assignment)
|
||||
with Session(self.engine) as session:
|
||||
postbox = self._create_exact(session)
|
||||
withdrawn = self.service.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_recipient",
|
||||
producer_resource_id="recipient-withdrawn",
|
||||
idempotency_key="withdrawn-message",
|
||||
subject="Withdrawn decision",
|
||||
body_text="Sensitive withdrawn content",
|
||||
),
|
||||
)
|
||||
expired = self.service.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_recipient",
|
||||
producer_resource_id="recipient-expired",
|
||||
idempotency_key="expired-message",
|
||||
subject="Expired decision",
|
||||
body_text="Sensitive expired content",
|
||||
expires_at=utc_now() - timedelta(minutes=1),
|
||||
),
|
||||
)
|
||||
withdrawn_model = session.get(PostboxMessage, withdrawn.message_id)
|
||||
assert withdrawn_model is not None
|
||||
withdrawn_model.withdrawn_at = utc_now()
|
||||
session.flush()
|
||||
|
||||
withdrawn_ref = self.service.get_message(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
message_id=withdrawn.message_id,
|
||||
actor=self.actor,
|
||||
)
|
||||
expired_ref = self.service.get_message(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
message_id=expired.message_id,
|
||||
actor=self.actor,
|
||||
)
|
||||
|
||||
assert withdrawn_ref is not None
|
||||
assert expired_ref is not None
|
||||
self.assertEqual("withdrawn", withdrawn_ref.availability)
|
||||
self.assertIsNone(withdrawn_ref.body_text)
|
||||
self.assertEqual((), withdrawn_ref.attachments)
|
||||
self.assertEqual("expired", expired_ref.availability)
|
||||
self.assertIsNone(expired_ref.body_text)
|
||||
with self.assertRaisesRegex(PostboxError, "withdrawn"):
|
||||
self.service.mark_message(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
message_id=withdrawn.message_id,
|
||||
actor=self.actor,
|
||||
state="read",
|
||||
)
|
||||
with self.assertRaisesRegex(PostboxError, "expired"):
|
||||
self.service.mark_message(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
message_id=expired.message_id,
|
||||
actor=self.actor,
|
||||
state="acknowledged",
|
||||
)
|
||||
|
||||
summaries = self.service.delivery_receipt_summaries(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
producer_module="campaigns",
|
||||
delivery_ids=(withdrawn.delivery_id, expired.delivery_id),
|
||||
)
|
||||
self.assertFalse(summaries[withdrawn.delivery_id].currently_readable)
|
||||
self.assertEqual(
|
||||
1,
|
||||
summaries[withdrawn.delivery_id].withdrawn_message_count,
|
||||
)
|
||||
self.assertFalse(summaries[expired.delivery_id].currently_readable)
|
||||
self.assertEqual(1, summaries[expired.delivery_id].expired_message_count)
|
||||
|
||||
def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
|
||||
self,
|
||||
) -> None:
|
||||
@@ -1023,6 +1132,16 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
session.commit()
|
||||
receipts = session.query(PostboxMessageReceipt).all()
|
||||
self.assertEqual([route.target_message_id], [item.message_id for item in receipts])
|
||||
summary = service.delivery_receipt_summaries(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
producer_module="campaigns",
|
||||
delivery_ids=(result.delivery_id,),
|
||||
)[result.delivery_id]
|
||||
self.assertEqual(2, summary.message_count)
|
||||
self.assertEqual(1, summary.routed_message_count)
|
||||
self.assertEqual(1, summary.read_receipt_count)
|
||||
self.assertEqual({"accepted": 1}, summary.route_status_counts)
|
||||
|
||||
def test_hierarchy_dry_run_explains_gates_depth_and_parallel_structure(
|
||||
self,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/postbox-webui",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -64,6 +64,7 @@ export type PostboxMessage = {
|
||||
subject: string;
|
||||
body_text?: string | null;
|
||||
status: string;
|
||||
availability: "available" | "withdrawn" | "expired";
|
||||
classification: string;
|
||||
sender_label?: string | null;
|
||||
delivered_at: string;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
isApiError,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
@@ -80,6 +81,7 @@ export default function PostboxPage({
|
||||
const [messages, setMessages] = useState<PostboxMessage[]>([]);
|
||||
const [selectedMessageId, setSelectedMessageId] = useState("");
|
||||
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
|
||||
const [unavailableSelection, setUnavailableSelection] = useState("");
|
||||
const [total, setTotal] = useState(0);
|
||||
const [messageState, setMessageState] = useState<MessageStateFilter>("all");
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
@@ -145,6 +147,7 @@ export default function PostboxPage({
|
||||
setMessages([]);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
setUnavailableSelection("");
|
||||
setTotal(0);
|
||||
return;
|
||||
}
|
||||
@@ -203,15 +206,28 @@ export default function PostboxPage({
|
||||
const message = await getPostboxMessage(settings, messageId);
|
||||
if (cancelled) return;
|
||||
if (!postboxes.some((postbox) => postbox.id === message.postbox_id)) {
|
||||
setError("The linked message is not available in your current Postbox assignments.");
|
||||
setSelectedMessageId(messageId);
|
||||
setSelectedMessage(null);
|
||||
setUnavailableSelection(
|
||||
"This message is outside your current Postbox assignments."
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSelectedScope("all");
|
||||
setSelectedPostboxId(message.postbox_id);
|
||||
setSelectedMessageId(message.id);
|
||||
setSelectedMessage(message);
|
||||
setUnavailableSelection("");
|
||||
} catch (loadError) {
|
||||
if (!cancelled) setError(errorMessage(loadError));
|
||||
if (!cancelled && isApiError(loadError, 403, 404)) {
|
||||
setSelectedMessageId(messageId);
|
||||
setSelectedMessage(null);
|
||||
setUnavailableSelection(
|
||||
"This message is no longer available or is outside your current Postbox assignments."
|
||||
);
|
||||
} else if (!cancelled) {
|
||||
setError(errorMessage(loadError));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -222,10 +238,11 @@ export default function PostboxPage({
|
||||
useEffect(() => {
|
||||
if (!selectedMessageId) return;
|
||||
let cancelled = false;
|
||||
setUnavailableSelection("");
|
||||
void (async () => {
|
||||
try {
|
||||
let message = await getPostboxMessage(settings, selectedMessageId);
|
||||
if (!message.read_at) {
|
||||
if (message.availability === "available" && !message.read_at) {
|
||||
message = await markPostboxMessage(settings, selectedMessageId, "read");
|
||||
}
|
||||
if (cancelled) return;
|
||||
@@ -234,7 +251,14 @@ export default function PostboxPage({
|
||||
items.map((item) => (item.id === message.id ? message : item))
|
||||
);
|
||||
} catch (loadError) {
|
||||
if (!cancelled) setError(errorMessage(loadError));
|
||||
if (!cancelled && isApiError(loadError, 403, 404)) {
|
||||
setSelectedMessage(null);
|
||||
setUnavailableSelection(
|
||||
"This message is no longer available or is outside your current Postbox assignments."
|
||||
);
|
||||
} else if (!cancelled) {
|
||||
setError(errorMessage(loadError));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -243,7 +267,13 @@ export default function PostboxPage({
|
||||
}, [selectedMessageId, settings]);
|
||||
|
||||
async function acknowledgeSelected() {
|
||||
if (!selectedMessage || !canAcknowledge) return;
|
||||
if (
|
||||
!selectedMessage ||
|
||||
selectedMessage.availability !== "available" ||
|
||||
!canAcknowledge
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -268,6 +298,7 @@ export default function PostboxPage({
|
||||
setPage(1);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
setUnavailableSelection("");
|
||||
}
|
||||
|
||||
function selectScope(scopeId: string) {
|
||||
@@ -276,6 +307,7 @@ export default function PostboxPage({
|
||||
setPage(1);
|
||||
setSelectedMessageId("");
|
||||
setSelectedMessage(null);
|
||||
setUnavailableSelection("");
|
||||
}
|
||||
|
||||
function openNewGrouping() {
|
||||
@@ -544,6 +576,12 @@ export default function PostboxPage({
|
||||
{message.acknowledged_at ? (
|
||||
<span><CheckCheck size={13} /> Acknowledged</span>
|
||||
) : null}
|
||||
{message.availability !== "available" ? (
|
||||
<StatusBadge
|
||||
status="warning"
|
||||
label={message.availability === "withdrawn" ? "Withdrawn" : "Expired"}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectionListItem>
|
||||
))}
|
||||
@@ -579,8 +617,20 @@ export default function PostboxPage({
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => void acknowledgeSelected()}
|
||||
disabled={!selectedMessage || Boolean(selectedMessage.acknowledged_at) || busy}
|
||||
disabledReason={!canAcknowledge ? "You cannot acknowledge Postbox messages." : undefined}
|
||||
disabled={
|
||||
!selectedMessage ||
|
||||
selectedMessage.availability !== "available" ||
|
||||
Boolean(selectedMessage.acknowledged_at) ||
|
||||
busy
|
||||
}
|
||||
disabledReason={
|
||||
!canAcknowledge
|
||||
? "You cannot acknowledge Postbox messages."
|
||||
: selectedMessage &&
|
||||
selectedMessage.availability !== "available"
|
||||
? "Withdrawn or expired messages cannot be acknowledged."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CheckCheck size={16} /> Acknowledge
|
||||
</Button>
|
||||
@@ -590,6 +640,12 @@ export default function PostboxPage({
|
||||
message={selectedMessage}
|
||||
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
|
||||
/>
|
||||
) : unavailableSelection ? (
|
||||
<div className="postbox-empty postbox-unavailable-message">
|
||||
<Archive size={24} />
|
||||
<strong>Message unavailable</strong>
|
||||
<p>{unavailableSelection}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="postbox-empty">
|
||||
<Inbox size={24} />
|
||||
@@ -694,6 +750,17 @@ function MessageDetail({
|
||||
}) {
|
||||
return (
|
||||
<div className="postbox-message-detail">
|
||||
{message.availability !== "available" ? (
|
||||
<DismissibleAlert
|
||||
tone="warning"
|
||||
compact
|
||||
resetKey={`${message.id}:${message.availability}`}
|
||||
>
|
||||
{message.availability === "withdrawn"
|
||||
? "This message was withdrawn. Its audit metadata remains visible, but its content and actions are unavailable."
|
||||
: "This message has expired. Its audit metadata remains visible, but its content and actions are unavailable."}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
<header>
|
||||
<div className="postbox-detail-status">
|
||||
<StatusBadge status={message.status} />
|
||||
|
||||
Reference in New Issue
Block a user