Complete Postbox access transition matrix
This commit is contained in:
@@ -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,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user