Complete Postbox access transition matrix

This commit is contained in:
2026-07-31 18:21:36 +02:00
parent cbeaca979d
commit 0b6ebc3c74
9 changed files with 741 additions and 24 deletions
@@ -8,6 +8,8 @@ from govoplan_core.core.postbox import (
PostboxAccessDecisionRef,
PostboxAction,
PostboxActorRef,
PostboxBindingStatus,
normalize_postbox_classification,
)
@@ -20,9 +22,18 @@ class PostboxAccessContext:
organization_unit_id: str | None
function_id: str | None
holder_count: int
binding_available: bool
binding_status: PostboxBindingStatus
classification: str
binding_assignments: tuple[OrganizationFunctionAssignmentRef, ...]
@property
def classification_allowed(self) -> bool:
classification = normalize_postbox_classification(self.classification)
return (
classification is not None
and classification in self.actor.authorized_classifications
)
@property
def base(self) -> dict[str, object]:
return {
@@ -32,6 +43,9 @@ class PostboxAccessContext:
"function_id": self.function_id,
"holder_count": self.holder_count,
"vacant": self.holder_count == 0,
"classification": self.classification,
"classification_allowed": self.classification_allowed,
"binding_status": self.binding_status,
}
@@ -53,6 +67,8 @@ def evaluate_postbox_access(
holder_count: int,
binding_available: bool,
binding_assignments: Sequence[OrganizationFunctionAssignmentRef],
binding_status: PostboxBindingStatus | None = None,
classification: str = "internal",
) -> PostboxAccessDecisionRef:
context = PostboxAccessContext(
postbox_id=postbox_id,
@@ -62,7 +78,12 @@ def evaluate_postbox_access(
organization_unit_id=organization_unit_id,
function_id=function_id,
holder_count=holder_count,
binding_available=binding_available,
binding_status=(
binding_status
if binding_status is not None
else "active" if binding_available else "missing"
),
classification=classification,
binding_assignments=tuple(binding_assignments),
)
for rule in ACCESS_DECISION_TABLE:
@@ -115,10 +136,74 @@ def _administrator(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
def _binding_missing(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
reasons = {
"missing": (
"function_binding_missing",
"This Postbox has no current organization-function binding.",
),
"not_effective": (
"function_binding_not_effective",
"The organization-function binding is not currently effective.",
),
"unit_missing": (
"organization_unit_missing",
"The organization unit bound to this Postbox no longer exists.",
),
"unit_inactive": (
"organization_unit_inactive",
"The organization unit bound to this Postbox is inactive.",
),
"unit_tenant_mismatch": (
"organization_unit_tenant_mismatch",
"The bound organization unit belongs to another tenant.",
),
"function_missing": (
"organization_function_missing",
"The organization function bound to this Postbox no longer exists.",
),
"function_inactive": (
"organization_function_inactive",
"The organization function bound to this Postbox is inactive.",
),
"function_tenant_mismatch": (
"organization_function_tenant_mismatch",
"The bound organization function belongs to another tenant.",
),
"function_reassigned": (
"organization_function_reassigned",
"The bound function no longer belongs to the bound organization unit.",
),
"directory_unavailable": (
"organization_directory_unavailable",
"The organization facts required for this access decision are unavailable.",
),
}
reason_code, explanation = reasons.get(
context.binding_status,
reasons["missing"],
)
return _deny(
context,
reason_code="function_binding_missing",
explanation="This Postbox has no active organization-function binding.",
reason_code=reason_code,
explanation=explanation,
)
def _classification_denied(
context: PostboxAccessContext,
) -> PostboxAccessDecisionRef:
if normalize_postbox_classification(context.classification) is None:
return _deny(
context,
reason_code="classification_unsupported",
explanation="The Postbox uses an unsupported classification.",
)
return _deny(
context,
reason_code="classification_clearance_missing",
explanation=(
"The account is not authorized for this Postbox classification."
),
)
@@ -215,10 +300,15 @@ ACCESS_DECISION_TABLE = (
matches=lambda context: context.action == "administer",
decision=_administrator,
),
AccessRule(
name="classification_clearance",
matches=lambda context: not context.classification_allowed,
decision=_classification_denied,
),
AccessRule(
name="active_function_binding",
matches=lambda context: (
not context.binding_available
context.binding_status != "active"
or not context.function_id
or not context.organization_unit_id
),
+23 -2
View File
@@ -52,10 +52,13 @@ MODULE_VERSION = "0.1.2"
READ_SCOPE = "postbox:postbox:read"
SEND_SCOPE = "postbox:message:write"
REPLY_SCOPE = "postbox:message:reply"
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
DELIVERY_SCOPE = "postbox:delivery:write"
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
TEMPLATE_ADMIN_SCOPE = "postbox:template:admin"
CONFIDENTIAL_SCOPE = "postbox:classification:confidential"
RESTRICTED_SCOPE = "postbox:classification:restricted"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -81,7 +84,12 @@ PERMISSIONS = (
_permission(
SEND_SCOPE,
"Send through assigned postboxes",
"Create replies and new messages in postboxes available through the current function context.",
"Create new messages in postboxes available through the current function context.",
),
_permission(
REPLY_SCOPE,
"Reply through assigned postboxes",
"Reply to messages in postboxes available through the current function context.",
),
_permission(
ACKNOWLEDGE_SCOPE,
@@ -103,6 +111,16 @@ PERMISSIONS = (
"Administer postbox templates",
"Create, revise, publish, and retire reusable function-scoped postbox templates.",
),
_permission(
CONFIDENTIAL_SCOPE,
"Access confidential Postbox content",
"Discover and use confidential Postboxes and messages when function access also permits it.",
),
_permission(
RESTRICTED_SCOPE,
"Access restricted Postbox content",
"Discover and use restricted Postboxes and messages when function access also permits it.",
),
)
ROLE_TEMPLATES = (
@@ -110,7 +128,7 @@ ROLE_TEMPLATES = (
slug="postbox_user",
name="Postbox user",
description="Use postboxes available through current function assignments.",
permissions=(READ_SCOPE, SEND_SCOPE, ACKNOWLEDGE_SCOPE),
permissions=(READ_SCOPE, SEND_SCOPE, REPLY_SCOPE, ACKNOWLEDGE_SCOPE),
default_authenticated=True,
),
RoleTemplate(
@@ -120,10 +138,13 @@ ROLE_TEMPLATES = (
permissions=(
READ_SCOPE,
SEND_SCOPE,
REPLY_SCOPE,
ACKNOWLEDGE_SCOPE,
DELIVERY_SCOPE,
BINDING_ADMIN_SCOPE,
TEMPLATE_ADMIN_SCOPE,
CONFIDENTIAL_SCOPE,
RESTRICTED_SCOPE,
),
),
)
+12
View File
@@ -18,8 +18,11 @@ from govoplan_core.db.session import get_session
from govoplan_postbox.backend.manifest import (
ACKNOWLEDGE_SCOPE,
BINDING_ADMIN_SCOPE,
CONFIDENTIAL_SCOPE,
DELIVERY_SCOPE,
READ_SCOPE,
REPLY_SCOPE,
RESTRICTED_SCOPE,
SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE,
)
@@ -80,6 +83,8 @@ def _actor(
actions.update(("discover", "read"))
if has_scope(principal, SEND_SCOPE):
actions.add("send")
if has_scope(principal, REPLY_SCOPE):
actions.add("reply")
if has_scope(principal, ACKNOWLEDGE_SCOPE):
actions.add("acknowledge")
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
@@ -90,12 +95,18 @@ def _actor(
selected = assignment_context_id
if selected is None and len(principal.function_assignment_ids) == 1:
selected = next(iter(principal.function_assignment_ids))
classifications = {"public", "internal"}
if has_scope(principal, CONFIDENTIAL_SCOPE):
classifications.add("confidential")
if has_scope(principal, RESTRICTED_SCOPE):
classifications.update(("confidential", "restricted"))
return PostboxActorRef(
account_id=principal.account_id,
identity_id=principal.identity_id,
selected_assignment_id=selected,
acting_for_account_id=principal.acting_for_account_id,
authorized_actions=frozenset(actions), # type: ignore[arg-type]
authorized_classifications=frozenset(classifications), # type: ignore[arg-type]
)
@@ -213,6 +224,7 @@ def api_postbox_access(
"discover",
"read",
"send",
"reply",
"acknowledge",
"administer",
] = "read",
+16 -5
View File
@@ -6,6 +6,14 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
PostboxClassification = Literal[
"public",
"internal",
"confidential",
"restricted",
]
class PostboxAccessDecisionResponse(BaseModel):
allowed: bool
action: str
@@ -19,6 +27,9 @@ class PostboxAccessDecisionResponse(BaseModel):
selected_assignment_id: str | None = None
holder_count: int = 0
vacant: bool = True
classification: str = "internal"
classification_allowed: bool = True
binding_status: str = "active"
class PostboxDirectoryItem(BaseModel):
@@ -132,7 +143,7 @@ class PostboxDeliveryCreateRequest(BaseModel):
subject: str = Field(min_length=1, max_length=1000)
body_text: str | None = None
sender_label: str | None = Field(default=None, max_length=500)
classification: str = Field(default="internal", min_length=1, max_length=50)
classification: PostboxClassification = "internal"
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
expires_at: datetime | None = None
@@ -161,7 +172,7 @@ class PostboxLinkedCopyPolicyPayload(BaseModel):
target_function_type_id: str | None = Field(default=None, max_length=36)
target_template_id: str | None = Field(default=None, max_length=36)
fanout: Literal["nearest", "all"] = "nearest"
allowed_classifications: list[str] = Field(
allowed_classifications: list[PostboxClassification] = Field(
default_factory=lambda: ["internal"],
max_length=20,
)
@@ -275,7 +286,7 @@ class PostboxRoutePreviewTarget(BaseModel):
class PostboxRouteDryRunRequest(BaseModel):
target: PostboxTargetPayload
producer_module: str = Field(min_length=1, max_length=100)
classification: str = Field(default="internal", min_length=1, max_length=50)
classification: PostboxClassification = "internal"
expires_at: datetime | None = None
@@ -295,7 +306,7 @@ class PostboxExactCreateRequest(BaseModel):
organization_unit_id: str = Field(min_length=1, max_length=36)
function_id: str = Field(min_length=1, max_length=36)
address_key: str | None = Field(default=None, max_length=120)
classification: str = Field(default="internal", min_length=1, max_length=50)
classification: PostboxClassification = "internal"
class PostboxTemplateRevisionPayload(BaseModel):
@@ -312,7 +323,7 @@ class PostboxTemplateRevisionPayload(BaseModel):
min_length=1,
max_length=500,
)
classification: str = Field(default="internal", min_length=1, max_length=50)
classification: PostboxClassification = "internal"
allow_vacant_delivery: bool = True
routing_policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload
+126 -12
View File
@@ -48,6 +48,7 @@ from govoplan_core.core.postbox import (
PostboxAction,
PostboxActorRef,
PostboxAttachmentRef,
PostboxBindingStatus,
PostboxDeliveryCatalogRef,
PostboxDeliveryReceiptSummaryRef,
PostboxDeliveryRequest,
@@ -62,6 +63,8 @@ from govoplan_core.core.postbox import (
PostboxOrganizationUnitTargetRef,
PostboxParticipantRef,
PostboxTargetRef,
normalize_postbox_classification,
postbox_classification_allows,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.security.time import utc_now
@@ -501,6 +504,7 @@ class PostboxService:
tenant_id=tenant_id,
allowed_ids=allowed_ids,
account_id=actor.account_id,
allowed_classifications=tuple(actor.authorized_classifications),
query=query,
state=state,
)
@@ -548,6 +552,7 @@ class PostboxService:
tenant_id=tenant_id,
allowed_ids=allowed_ids,
account_id=actor.account_id,
allowed_classifications=tuple(actor.authorized_classifications),
query=query,
state=state,
)
@@ -563,12 +568,14 @@ class PostboxService:
tenant_id: str,
allowed_ids: Sequence[str],
account_id: str,
allowed_classifications: Sequence[str],
query: str | None,
state: PostboxMessageListState,
):
result = session.query(PostboxMessage).filter(
PostboxMessage.tenant_id == tenant_id,
PostboxMessage.postbox_id.in_(allowed_ids),
PostboxMessage.classification.in_(allowed_classifications),
)
needle = (query or "").strip().casefold()
if needle:
@@ -628,14 +635,24 @@ class PostboxService:
)
if message is None:
return None
decision = self.explain_access(
decision = self._message_access_decision(
db,
tenant_id=tenant_id,
postbox_id=message.postbox_id,
message=message,
actor=actor,
action="read",
)
if not decision.allowed:
self._record_access_event(
db,
tenant_id=tenant_id,
postbox_id=message.postbox_id,
message_id=message.id,
actor=actor,
action="message.read",
outcome="denied",
reason_code=decision.reason_code,
assignment_id=decision.selected_assignment_id,
)
return None
self._record_access_event(
db,
@@ -668,14 +685,24 @@ class PostboxService:
action: PostboxAction = (
"acknowledge" if state == "acknowledged" else "read"
)
decision = self.explain_access(
decision = self._message_access_decision(
db,
tenant_id=tenant_id,
postbox_id=message.postbox_id,
message=message,
actor=actor,
action=action,
)
if not decision.allowed:
self._record_access_event(
db,
tenant_id=tenant_id,
postbox_id=message.postbox_id,
message_id=message.id,
actor=actor,
action=f"message.{state}",
outcome="denied",
reason_code=decision.reason_code,
assignment_id=decision.selected_assignment_id,
)
raise PostboxError("access_denied", decision.explanation)
availability = _message_availability(message)
if availability != "available":
@@ -787,6 +814,15 @@ class PostboxService:
)
if postbox.status != "active":
raise PostboxError("postbox_inactive", "The target Postbox is not active.")
classification = self._validate_classification(request.classification)
if not postbox_classification_allows(
postbox.classification,
classification,
):
raise PostboxError(
"classification_not_allowed",
"The message classification exceeds the target Postbox classification.",
)
holders = self._holders(
request.tenant_id,
@@ -806,7 +842,7 @@ class PostboxService:
subject=request.subject.strip() or "(No subject)",
body_text=request.body_text,
status="delivered",
classification=request.classification,
classification=classification,
sender_label=request.sender_label,
producer_module=request.producer_module,
producer_resource_type=request.producer_resource_type,
@@ -947,6 +983,7 @@ class PostboxService:
classification: str,
expires_at: datetime | None,
) -> dict[str, object]:
classification = self._validate_classification(classification)
entry = self.resolve_postbox(
session,
tenant_id=tenant_id,
@@ -2175,6 +2212,7 @@ class PostboxService:
classification: str,
actor_id: str | None,
) -> Postbox:
classification = self._validate_classification(classification)
unit, function = self._validate_function_target(
tenant_id=tenant_id,
organization_unit_id=organization_unit_id,
@@ -2292,6 +2330,7 @@ class PostboxService:
actor_id: str | None,
routing_policy: Mapping[str, object] | None = None,
) -> PostboxTemplate:
classification = self._validate_classification(classification)
clean_slug = _slug(slug or name, fallback="template")
if (
session.query(PostboxTemplate)
@@ -2368,6 +2407,7 @@ class PostboxService:
actor_id: str | None,
routing_policy: Mapping[str, object] | None = None,
) -> PostboxTemplate:
classification = self._validate_classification(classification)
template = self._get_template(
session,
tenant_id=tenant_id,
@@ -2879,6 +2919,31 @@ class PostboxService:
raise PostboxError("message_not_found", "Postbox message not found.")
return message
def _message_access_decision(
self,
session: Session,
*,
message: PostboxMessage,
actor: PostboxActorRef,
action: PostboxAction,
) -> PostboxAccessDecisionRef:
postbox = self._get_postbox(
session,
tenant_id=message.tenant_id,
postbox_id=message.postbox_id,
)
return self._access_decision(
postbox,
actor=actor,
action=action,
assignments=self._assignments_for_actor(
actor,
tenant_id=message.tenant_id,
),
holder_cache={},
classification=message.classification,
)
def _get_template(
self,
session: Session,
@@ -3007,7 +3072,14 @@ class PostboxService:
}
def _active_binding(self, postbox: Postbox) -> PostboxBinding | None:
return self._binding_resolution(postbox)[0]
def _binding_resolution(
self,
postbox: Postbox,
) -> tuple[PostboxBinding | None, PostboxBindingStatus]:
now = utc_now()
current: PostboxBinding | None = None
for binding in postbox.bindings:
if not binding.is_active:
continue
@@ -3015,8 +3087,38 @@ class PostboxService:
continue
if binding.valid_until and binding.valid_until <= now:
continue
return binding
return None
current = binding
break
if current is None:
return (None, "not_effective" if postbox.bindings else "missing")
if not current.organization_unit_id or not current.function_id:
return current, "missing"
try:
unit = self._organizations.get_organization_unit(
current.organization_unit_id
)
function = self._organizations.get_function(current.function_id)
except Exception:
logger.exception(
"Postbox organization binding resolution failed",
extra={"postbox_id": postbox.id},
)
return current, "directory_unavailable"
if unit is None:
return current, "unit_missing"
if unit.tenant_id != postbox.tenant_id:
return current, "unit_tenant_mismatch"
if unit.status != "active":
return current, "unit_inactive"
if function is None:
return current, "function_missing"
if function.tenant_id != postbox.tenant_id:
return current, "function_tenant_mismatch"
if function.status != "active":
return current, "function_inactive"
if function.organization_unit_id != unit.id:
return current, "function_reassigned"
return current, "active"
def _access_decision(
self,
@@ -3026,8 +3128,9 @@ class PostboxService:
action: PostboxAction,
assignments: Sequence[OrganizationFunctionAssignmentRef],
holder_cache: dict[str, tuple[OrganizationFunctionAssignmentRef, ...]],
classification: str | None = None,
) -> PostboxAccessDecisionRef:
binding = self._active_binding(postbox)
binding, binding_status = self._binding_resolution(postbox)
function_id = (
binding.function_id
if binding is not None
@@ -3041,7 +3144,7 @@ class PostboxService:
holders = self._holders(postbox.tenant_id, function_id, holder_cache)
holder_count = len({holder.identity_id for holder in holders})
binding_assignments: list[OrganizationFunctionAssignmentRef] = []
if binding is not None:
if binding is not None and binding_status == "active":
for assignment in assignments:
if assignment.tenant_id != postbox.tenant_id:
continue
@@ -3056,8 +3159,10 @@ class PostboxService:
organization_unit_id=unit_id,
function_id=function_id,
holder_count=holder_count,
binding_available=binding is not None,
binding_available=binding is not None and binding_status == "active",
binding_assignments=binding_assignments,
binding_status=binding_status,
classification=classification or postbox.classification,
)
def _assignment_matches_binding(
@@ -3469,6 +3574,15 @@ class PostboxService:
)
return unit, function
def _validate_classification(self, classification: str) -> str:
normalized = normalize_postbox_classification(classification)
if normalized is None:
raise PostboxError(
"classification_unsupported",
"Postbox classification must be public, internal, confidential, or restricted.",
)
return normalized
def _validate_scope(
self,
*,