Complete Postbox access transition matrix
This commit is contained in:
@@ -8,6 +8,8 @@ from govoplan_core.core.postbox import (
|
|||||||
PostboxAccessDecisionRef,
|
PostboxAccessDecisionRef,
|
||||||
PostboxAction,
|
PostboxAction,
|
||||||
PostboxActorRef,
|
PostboxActorRef,
|
||||||
|
PostboxBindingStatus,
|
||||||
|
normalize_postbox_classification,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -20,9 +22,18 @@ class PostboxAccessContext:
|
|||||||
organization_unit_id: str | None
|
organization_unit_id: str | None
|
||||||
function_id: str | None
|
function_id: str | None
|
||||||
holder_count: int
|
holder_count: int
|
||||||
binding_available: bool
|
binding_status: PostboxBindingStatus
|
||||||
|
classification: str
|
||||||
binding_assignments: tuple[OrganizationFunctionAssignmentRef, ...]
|
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
|
@property
|
||||||
def base(self) -> dict[str, object]:
|
def base(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
@@ -32,6 +43,9 @@ class PostboxAccessContext:
|
|||||||
"function_id": self.function_id,
|
"function_id": self.function_id,
|
||||||
"holder_count": self.holder_count,
|
"holder_count": self.holder_count,
|
||||||
"vacant": self.holder_count == 0,
|
"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,
|
holder_count: int,
|
||||||
binding_available: bool,
|
binding_available: bool,
|
||||||
binding_assignments: Sequence[OrganizationFunctionAssignmentRef],
|
binding_assignments: Sequence[OrganizationFunctionAssignmentRef],
|
||||||
|
binding_status: PostboxBindingStatus | None = None,
|
||||||
|
classification: str = "internal",
|
||||||
) -> PostboxAccessDecisionRef:
|
) -> PostboxAccessDecisionRef:
|
||||||
context = PostboxAccessContext(
|
context = PostboxAccessContext(
|
||||||
postbox_id=postbox_id,
|
postbox_id=postbox_id,
|
||||||
@@ -62,7 +78,12 @@ def evaluate_postbox_access(
|
|||||||
organization_unit_id=organization_unit_id,
|
organization_unit_id=organization_unit_id,
|
||||||
function_id=function_id,
|
function_id=function_id,
|
||||||
holder_count=holder_count,
|
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),
|
binding_assignments=tuple(binding_assignments),
|
||||||
)
|
)
|
||||||
for rule in ACCESS_DECISION_TABLE:
|
for rule in ACCESS_DECISION_TABLE:
|
||||||
@@ -115,10 +136,74 @@ def _administrator(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
|
|||||||
|
|
||||||
|
|
||||||
def _binding_missing(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(
|
return _deny(
|
||||||
context,
|
context,
|
||||||
reason_code="function_binding_missing",
|
reason_code=reason_code,
|
||||||
explanation="This Postbox has no active organization-function binding.",
|
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",
|
matches=lambda context: context.action == "administer",
|
||||||
decision=_administrator,
|
decision=_administrator,
|
||||||
),
|
),
|
||||||
|
AccessRule(
|
||||||
|
name="classification_clearance",
|
||||||
|
matches=lambda context: not context.classification_allowed,
|
||||||
|
decision=_classification_denied,
|
||||||
|
),
|
||||||
AccessRule(
|
AccessRule(
|
||||||
name="active_function_binding",
|
name="active_function_binding",
|
||||||
matches=lambda context: (
|
matches=lambda context: (
|
||||||
not context.binding_available
|
context.binding_status != "active"
|
||||||
or not context.function_id
|
or not context.function_id
|
||||||
or not context.organization_unit_id
|
or not context.organization_unit_id
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -52,10 +52,13 @@ MODULE_VERSION = "0.1.2"
|
|||||||
|
|
||||||
READ_SCOPE = "postbox:postbox:read"
|
READ_SCOPE = "postbox:postbox:read"
|
||||||
SEND_SCOPE = "postbox:message:write"
|
SEND_SCOPE = "postbox:message:write"
|
||||||
|
REPLY_SCOPE = "postbox:message:reply"
|
||||||
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
|
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
|
||||||
DELIVERY_SCOPE = "postbox:delivery:write"
|
DELIVERY_SCOPE = "postbox:delivery:write"
|
||||||
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
|
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
|
||||||
TEMPLATE_ADMIN_SCOPE = "postbox:template: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:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
@@ -81,7 +84,12 @@ PERMISSIONS = (
|
|||||||
_permission(
|
_permission(
|
||||||
SEND_SCOPE,
|
SEND_SCOPE,
|
||||||
"Send through assigned postboxes",
|
"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(
|
_permission(
|
||||||
ACKNOWLEDGE_SCOPE,
|
ACKNOWLEDGE_SCOPE,
|
||||||
@@ -103,6 +111,16 @@ PERMISSIONS = (
|
|||||||
"Administer postbox templates",
|
"Administer postbox templates",
|
||||||
"Create, revise, publish, and retire reusable function-scoped 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 = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -110,7 +128,7 @@ ROLE_TEMPLATES = (
|
|||||||
slug="postbox_user",
|
slug="postbox_user",
|
||||||
name="Postbox user",
|
name="Postbox user",
|
||||||
description="Use postboxes available through current function assignments.",
|
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,
|
default_authenticated=True,
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
@@ -120,10 +138,13 @@ ROLE_TEMPLATES = (
|
|||||||
permissions=(
|
permissions=(
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
SEND_SCOPE,
|
SEND_SCOPE,
|
||||||
|
REPLY_SCOPE,
|
||||||
ACKNOWLEDGE_SCOPE,
|
ACKNOWLEDGE_SCOPE,
|
||||||
DELIVERY_SCOPE,
|
DELIVERY_SCOPE,
|
||||||
BINDING_ADMIN_SCOPE,
|
BINDING_ADMIN_SCOPE,
|
||||||
TEMPLATE_ADMIN_SCOPE,
|
TEMPLATE_ADMIN_SCOPE,
|
||||||
|
CONFIDENTIAL_SCOPE,
|
||||||
|
RESTRICTED_SCOPE,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ from govoplan_core.db.session import get_session
|
|||||||
from govoplan_postbox.backend.manifest import (
|
from govoplan_postbox.backend.manifest import (
|
||||||
ACKNOWLEDGE_SCOPE,
|
ACKNOWLEDGE_SCOPE,
|
||||||
BINDING_ADMIN_SCOPE,
|
BINDING_ADMIN_SCOPE,
|
||||||
|
CONFIDENTIAL_SCOPE,
|
||||||
DELIVERY_SCOPE,
|
DELIVERY_SCOPE,
|
||||||
READ_SCOPE,
|
READ_SCOPE,
|
||||||
|
REPLY_SCOPE,
|
||||||
|
RESTRICTED_SCOPE,
|
||||||
SEND_SCOPE,
|
SEND_SCOPE,
|
||||||
TEMPLATE_ADMIN_SCOPE,
|
TEMPLATE_ADMIN_SCOPE,
|
||||||
)
|
)
|
||||||
@@ -80,6 +83,8 @@ def _actor(
|
|||||||
actions.update(("discover", "read"))
|
actions.update(("discover", "read"))
|
||||||
if has_scope(principal, SEND_SCOPE):
|
if has_scope(principal, SEND_SCOPE):
|
||||||
actions.add("send")
|
actions.add("send")
|
||||||
|
if has_scope(principal, REPLY_SCOPE):
|
||||||
|
actions.add("reply")
|
||||||
if has_scope(principal, ACKNOWLEDGE_SCOPE):
|
if has_scope(principal, ACKNOWLEDGE_SCOPE):
|
||||||
actions.add("acknowledge")
|
actions.add("acknowledge")
|
||||||
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
|
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
|
||||||
@@ -90,12 +95,18 @@ def _actor(
|
|||||||
selected = assignment_context_id
|
selected = assignment_context_id
|
||||||
if selected is None and len(principal.function_assignment_ids) == 1:
|
if selected is None and len(principal.function_assignment_ids) == 1:
|
||||||
selected = next(iter(principal.function_assignment_ids))
|
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(
|
return PostboxActorRef(
|
||||||
account_id=principal.account_id,
|
account_id=principal.account_id,
|
||||||
identity_id=principal.identity_id,
|
identity_id=principal.identity_id,
|
||||||
selected_assignment_id=selected,
|
selected_assignment_id=selected,
|
||||||
acting_for_account_id=principal.acting_for_account_id,
|
acting_for_account_id=principal.acting_for_account_id,
|
||||||
authorized_actions=frozenset(actions), # type: ignore[arg-type]
|
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",
|
"discover",
|
||||||
"read",
|
"read",
|
||||||
"send",
|
"send",
|
||||||
|
"reply",
|
||||||
"acknowledge",
|
"acknowledge",
|
||||||
"administer",
|
"administer",
|
||||||
] = "read",
|
] = "read",
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ from typing import Any, Literal
|
|||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
PostboxClassification = Literal[
|
||||||
|
"public",
|
||||||
|
"internal",
|
||||||
|
"confidential",
|
||||||
|
"restricted",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class PostboxAccessDecisionResponse(BaseModel):
|
class PostboxAccessDecisionResponse(BaseModel):
|
||||||
allowed: bool
|
allowed: bool
|
||||||
action: str
|
action: str
|
||||||
@@ -19,6 +27,9 @@ class PostboxAccessDecisionResponse(BaseModel):
|
|||||||
selected_assignment_id: str | None = None
|
selected_assignment_id: str | None = None
|
||||||
holder_count: int = 0
|
holder_count: int = 0
|
||||||
vacant: bool = True
|
vacant: bool = True
|
||||||
|
classification: str = "internal"
|
||||||
|
classification_allowed: bool = True
|
||||||
|
binding_status: str = "active"
|
||||||
|
|
||||||
|
|
||||||
class PostboxDirectoryItem(BaseModel):
|
class PostboxDirectoryItem(BaseModel):
|
||||||
@@ -132,7 +143,7 @@ class PostboxDeliveryCreateRequest(BaseModel):
|
|||||||
subject: str = Field(min_length=1, max_length=1000)
|
subject: str = Field(min_length=1, max_length=1000)
|
||||||
body_text: str | None = None
|
body_text: str | None = None
|
||||||
sender_label: str | None = Field(default=None, max_length=500)
|
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)
|
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
|
||||||
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
|
||||||
expires_at: datetime | None = None
|
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_function_type_id: str | None = Field(default=None, max_length=36)
|
||||||
target_template_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"
|
fanout: Literal["nearest", "all"] = "nearest"
|
||||||
allowed_classifications: list[str] = Field(
|
allowed_classifications: list[PostboxClassification] = Field(
|
||||||
default_factory=lambda: ["internal"],
|
default_factory=lambda: ["internal"],
|
||||||
max_length=20,
|
max_length=20,
|
||||||
)
|
)
|
||||||
@@ -275,7 +286,7 @@ class PostboxRoutePreviewTarget(BaseModel):
|
|||||||
class PostboxRouteDryRunRequest(BaseModel):
|
class PostboxRouteDryRunRequest(BaseModel):
|
||||||
target: PostboxTargetPayload
|
target: PostboxTargetPayload
|
||||||
producer_module: str = Field(min_length=1, max_length=100)
|
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
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -295,7 +306,7 @@ class PostboxExactCreateRequest(BaseModel):
|
|||||||
organization_unit_id: str = Field(min_length=1, max_length=36)
|
organization_unit_id: str = Field(min_length=1, max_length=36)
|
||||||
function_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)
|
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):
|
class PostboxTemplateRevisionPayload(BaseModel):
|
||||||
@@ -312,7 +323,7 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
|||||||
min_length=1,
|
min_length=1,
|
||||||
max_length=500,
|
max_length=500,
|
||||||
)
|
)
|
||||||
classification: str = Field(default="internal", min_length=1, max_length=50)
|
classification: PostboxClassification = "internal"
|
||||||
allow_vacant_delivery: bool = True
|
allow_vacant_delivery: bool = True
|
||||||
routing_policy: PostboxRoutingPolicyPayload = Field(
|
routing_policy: PostboxRoutingPolicyPayload = Field(
|
||||||
default_factory=PostboxRoutingPolicyPayload
|
default_factory=PostboxRoutingPolicyPayload
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from govoplan_core.core.postbox import (
|
|||||||
PostboxAction,
|
PostboxAction,
|
||||||
PostboxActorRef,
|
PostboxActorRef,
|
||||||
PostboxAttachmentRef,
|
PostboxAttachmentRef,
|
||||||
|
PostboxBindingStatus,
|
||||||
PostboxDeliveryCatalogRef,
|
PostboxDeliveryCatalogRef,
|
||||||
PostboxDeliveryReceiptSummaryRef,
|
PostboxDeliveryReceiptSummaryRef,
|
||||||
PostboxDeliveryRequest,
|
PostboxDeliveryRequest,
|
||||||
@@ -62,6 +63,8 @@ from govoplan_core.core.postbox import (
|
|||||||
PostboxOrganizationUnitTargetRef,
|
PostboxOrganizationUnitTargetRef,
|
||||||
PostboxParticipantRef,
|
PostboxParticipantRef,
|
||||||
PostboxTargetRef,
|
PostboxTargetRef,
|
||||||
|
normalize_postbox_classification,
|
||||||
|
postbox_classification_allows,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.security.time import utc_now
|
from govoplan_core.security.time import utc_now
|
||||||
@@ -501,6 +504,7 @@ class PostboxService:
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
allowed_ids=allowed_ids,
|
allowed_ids=allowed_ids,
|
||||||
account_id=actor.account_id,
|
account_id=actor.account_id,
|
||||||
|
allowed_classifications=tuple(actor.authorized_classifications),
|
||||||
query=query,
|
query=query,
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
@@ -548,6 +552,7 @@ class PostboxService:
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
allowed_ids=allowed_ids,
|
allowed_ids=allowed_ids,
|
||||||
account_id=actor.account_id,
|
account_id=actor.account_id,
|
||||||
|
allowed_classifications=tuple(actor.authorized_classifications),
|
||||||
query=query,
|
query=query,
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
@@ -563,12 +568,14 @@ class PostboxService:
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
allowed_ids: Sequence[str],
|
allowed_ids: Sequence[str],
|
||||||
account_id: str,
|
account_id: str,
|
||||||
|
allowed_classifications: Sequence[str],
|
||||||
query: str | None,
|
query: str | None,
|
||||||
state: PostboxMessageListState,
|
state: PostboxMessageListState,
|
||||||
):
|
):
|
||||||
result = session.query(PostboxMessage).filter(
|
result = session.query(PostboxMessage).filter(
|
||||||
PostboxMessage.tenant_id == tenant_id,
|
PostboxMessage.tenant_id == tenant_id,
|
||||||
PostboxMessage.postbox_id.in_(allowed_ids),
|
PostboxMessage.postbox_id.in_(allowed_ids),
|
||||||
|
PostboxMessage.classification.in_(allowed_classifications),
|
||||||
)
|
)
|
||||||
needle = (query or "").strip().casefold()
|
needle = (query or "").strip().casefold()
|
||||||
if needle:
|
if needle:
|
||||||
@@ -628,14 +635,24 @@ class PostboxService:
|
|||||||
)
|
)
|
||||||
if message is None:
|
if message is None:
|
||||||
return None
|
return None
|
||||||
decision = self.explain_access(
|
decision = self._message_access_decision(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
message=message,
|
||||||
postbox_id=message.postbox_id,
|
|
||||||
actor=actor,
|
actor=actor,
|
||||||
action="read",
|
action="read",
|
||||||
)
|
)
|
||||||
if not decision.allowed:
|
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
|
return None
|
||||||
self._record_access_event(
|
self._record_access_event(
|
||||||
db,
|
db,
|
||||||
@@ -668,14 +685,24 @@ class PostboxService:
|
|||||||
action: PostboxAction = (
|
action: PostboxAction = (
|
||||||
"acknowledge" if state == "acknowledged" else "read"
|
"acknowledge" if state == "acknowledged" else "read"
|
||||||
)
|
)
|
||||||
decision = self.explain_access(
|
decision = self._message_access_decision(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
message=message,
|
||||||
postbox_id=message.postbox_id,
|
|
||||||
actor=actor,
|
actor=actor,
|
||||||
action=action,
|
action=action,
|
||||||
)
|
)
|
||||||
if not decision.allowed:
|
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)
|
raise PostboxError("access_denied", decision.explanation)
|
||||||
availability = _message_availability(message)
|
availability = _message_availability(message)
|
||||||
if availability != "available":
|
if availability != "available":
|
||||||
@@ -787,6 +814,15 @@ class PostboxService:
|
|||||||
)
|
)
|
||||||
if postbox.status != "active":
|
if postbox.status != "active":
|
||||||
raise PostboxError("postbox_inactive", "The target Postbox is not 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(
|
holders = self._holders(
|
||||||
request.tenant_id,
|
request.tenant_id,
|
||||||
@@ -806,7 +842,7 @@ class PostboxService:
|
|||||||
subject=request.subject.strip() or "(No subject)",
|
subject=request.subject.strip() or "(No subject)",
|
||||||
body_text=request.body_text,
|
body_text=request.body_text,
|
||||||
status="delivered",
|
status="delivered",
|
||||||
classification=request.classification,
|
classification=classification,
|
||||||
sender_label=request.sender_label,
|
sender_label=request.sender_label,
|
||||||
producer_module=request.producer_module,
|
producer_module=request.producer_module,
|
||||||
producer_resource_type=request.producer_resource_type,
|
producer_resource_type=request.producer_resource_type,
|
||||||
@@ -947,6 +983,7 @@ class PostboxService:
|
|||||||
classification: str,
|
classification: str,
|
||||||
expires_at: datetime | None,
|
expires_at: datetime | None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
classification = self._validate_classification(classification)
|
||||||
entry = self.resolve_postbox(
|
entry = self.resolve_postbox(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -2175,6 +2212,7 @@ class PostboxService:
|
|||||||
classification: str,
|
classification: str,
|
||||||
actor_id: str | None,
|
actor_id: str | None,
|
||||||
) -> Postbox:
|
) -> Postbox:
|
||||||
|
classification = self._validate_classification(classification)
|
||||||
unit, function = self._validate_function_target(
|
unit, function = self._validate_function_target(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
organization_unit_id=organization_unit_id,
|
organization_unit_id=organization_unit_id,
|
||||||
@@ -2292,6 +2330,7 @@ class PostboxService:
|
|||||||
actor_id: str | None,
|
actor_id: str | None,
|
||||||
routing_policy: Mapping[str, object] | None = None,
|
routing_policy: Mapping[str, object] | None = None,
|
||||||
) -> PostboxTemplate:
|
) -> PostboxTemplate:
|
||||||
|
classification = self._validate_classification(classification)
|
||||||
clean_slug = _slug(slug or name, fallback="template")
|
clean_slug = _slug(slug or name, fallback="template")
|
||||||
if (
|
if (
|
||||||
session.query(PostboxTemplate)
|
session.query(PostboxTemplate)
|
||||||
@@ -2368,6 +2407,7 @@ class PostboxService:
|
|||||||
actor_id: str | None,
|
actor_id: str | None,
|
||||||
routing_policy: Mapping[str, object] | None = None,
|
routing_policy: Mapping[str, object] | None = None,
|
||||||
) -> PostboxTemplate:
|
) -> PostboxTemplate:
|
||||||
|
classification = self._validate_classification(classification)
|
||||||
template = self._get_template(
|
template = self._get_template(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -2879,6 +2919,31 @@ class PostboxService:
|
|||||||
raise PostboxError("message_not_found", "Postbox message not found.")
|
raise PostboxError("message_not_found", "Postbox message not found.")
|
||||||
return message
|
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(
|
def _get_template(
|
||||||
self,
|
self,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -3007,7 +3072,14 @@ class PostboxService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _active_binding(self, postbox: Postbox) -> PostboxBinding | None:
|
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()
|
now = utc_now()
|
||||||
|
current: PostboxBinding | None = None
|
||||||
for binding in postbox.bindings:
|
for binding in postbox.bindings:
|
||||||
if not binding.is_active:
|
if not binding.is_active:
|
||||||
continue
|
continue
|
||||||
@@ -3015,8 +3087,38 @@ class PostboxService:
|
|||||||
continue
|
continue
|
||||||
if binding.valid_until and binding.valid_until <= now:
|
if binding.valid_until and binding.valid_until <= now:
|
||||||
continue
|
continue
|
||||||
return binding
|
current = binding
|
||||||
return None
|
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(
|
def _access_decision(
|
||||||
self,
|
self,
|
||||||
@@ -3026,8 +3128,9 @@ class PostboxService:
|
|||||||
action: PostboxAction,
|
action: PostboxAction,
|
||||||
assignments: Sequence[OrganizationFunctionAssignmentRef],
|
assignments: Sequence[OrganizationFunctionAssignmentRef],
|
||||||
holder_cache: dict[str, tuple[OrganizationFunctionAssignmentRef, ...]],
|
holder_cache: dict[str, tuple[OrganizationFunctionAssignmentRef, ...]],
|
||||||
|
classification: str | None = None,
|
||||||
) -> PostboxAccessDecisionRef:
|
) -> PostboxAccessDecisionRef:
|
||||||
binding = self._active_binding(postbox)
|
binding, binding_status = self._binding_resolution(postbox)
|
||||||
function_id = (
|
function_id = (
|
||||||
binding.function_id
|
binding.function_id
|
||||||
if binding is not None
|
if binding is not None
|
||||||
@@ -3041,7 +3144,7 @@ class PostboxService:
|
|||||||
holders = self._holders(postbox.tenant_id, function_id, holder_cache)
|
holders = self._holders(postbox.tenant_id, function_id, holder_cache)
|
||||||
holder_count = len({holder.identity_id for holder in holders})
|
holder_count = len({holder.identity_id for holder in holders})
|
||||||
binding_assignments: list[OrganizationFunctionAssignmentRef] = []
|
binding_assignments: list[OrganizationFunctionAssignmentRef] = []
|
||||||
if binding is not None:
|
if binding is not None and binding_status == "active":
|
||||||
for assignment in assignments:
|
for assignment in assignments:
|
||||||
if assignment.tenant_id != postbox.tenant_id:
|
if assignment.tenant_id != postbox.tenant_id:
|
||||||
continue
|
continue
|
||||||
@@ -3056,8 +3159,10 @@ class PostboxService:
|
|||||||
organization_unit_id=unit_id,
|
organization_unit_id=unit_id,
|
||||||
function_id=function_id,
|
function_id=function_id,
|
||||||
holder_count=holder_count,
|
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_assignments=binding_assignments,
|
||||||
|
binding_status=binding_status,
|
||||||
|
classification=classification or postbox.classification,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _assignment_matches_binding(
|
def _assignment_matches_binding(
|
||||||
@@ -3469,6 +3574,15 @@ class PostboxService:
|
|||||||
)
|
)
|
||||||
return unit, function
|
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(
|
def _validate_scope(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ def decide(
|
|||||||
assignments=(),
|
assignments=(),
|
||||||
selected_assignment_id: str | None = None,
|
selected_assignment_id: str | None = None,
|
||||||
acting_for_account_id: str | None = None,
|
acting_for_account_id: str | None = None,
|
||||||
|
classification: str = "internal",
|
||||||
|
authorized_classifications: frozenset[str] = frozenset(
|
||||||
|
{"public", "internal"}
|
||||||
|
),
|
||||||
|
binding_status: str | None = None,
|
||||||
):
|
):
|
||||||
return evaluate_postbox_access(
|
return evaluate_postbox_access(
|
||||||
postbox_id="postbox-1",
|
postbox_id="postbox-1",
|
||||||
@@ -49,12 +54,15 @@ def decide(
|
|||||||
selected_assignment_id=selected_assignment_id,
|
selected_assignment_id=selected_assignment_id,
|
||||||
acting_for_account_id=acting_for_account_id,
|
acting_for_account_id=acting_for_account_id,
|
||||||
authorized_actions=authorized_actions, # type: ignore[arg-type]
|
authorized_actions=authorized_actions, # type: ignore[arg-type]
|
||||||
|
authorized_classifications=authorized_classifications, # type: ignore[arg-type]
|
||||||
),
|
),
|
||||||
organization_unit_id="unit-1" if binding_available else None,
|
organization_unit_id="unit-1" if binding_available else None,
|
||||||
function_id="function-1" if binding_available else None,
|
function_id="function-1" if binding_available else None,
|
||||||
holder_count=len(assignments),
|
holder_count=len(assignments),
|
||||||
binding_available=binding_available,
|
binding_available=binding_available,
|
||||||
binding_assignments=assignments,
|
binding_assignments=assignments,
|
||||||
|
binding_status=binding_status, # type: ignore[arg-type]
|
||||||
|
classification=classification,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +74,7 @@ class PostboxAccessDecisionTableTests(unittest.TestCase):
|
|||||||
"inactive_postbox",
|
"inactive_postbox",
|
||||||
"generic_permission",
|
"generic_permission",
|
||||||
"administrator",
|
"administrator",
|
||||||
|
"classification_clearance",
|
||||||
"active_function_binding",
|
"active_function_binding",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -102,6 +111,65 @@ class PostboxAccessDecisionTableTests(unittest.TestCase):
|
|||||||
self.assertTrue(decision.allowed)
|
self.assertTrue(decision.allowed)
|
||||||
self.assertEqual(decision.reason_code, "generic_administrator")
|
self.assertEqual(decision.reason_code, "generic_administrator")
|
||||||
|
|
||||||
|
def test_reply_is_a_distinct_permission_decision(self) -> None:
|
||||||
|
missing = decide(
|
||||||
|
action="reply",
|
||||||
|
authorized_actions=frozenset({"send"}),
|
||||||
|
assignments=(assignment(),),
|
||||||
|
)
|
||||||
|
allowed = decide(
|
||||||
|
action="reply",
|
||||||
|
authorized_actions=frozenset({"reply"}),
|
||||||
|
assignments=(assignment(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(missing.reason_code, "generic_permission_missing")
|
||||||
|
self.assertTrue(allowed.allowed)
|
||||||
|
|
||||||
|
def test_classification_is_fail_closed_and_explained(self) -> None:
|
||||||
|
missing_clearance = decide(
|
||||||
|
classification="confidential",
|
||||||
|
assignments=(assignment(),),
|
||||||
|
)
|
||||||
|
allowed = decide(
|
||||||
|
classification="confidential",
|
||||||
|
authorized_classifications=frozenset(
|
||||||
|
{"public", "internal", "confidential"}
|
||||||
|
),
|
||||||
|
assignments=(assignment(),),
|
||||||
|
)
|
||||||
|
unsupported = decide(
|
||||||
|
classification="secret",
|
||||||
|
authorized_classifications=frozenset(
|
||||||
|
{"public", "internal", "confidential", "restricted"}
|
||||||
|
),
|
||||||
|
assignments=(assignment(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
missing_clearance.reason_code,
|
||||||
|
"classification_clearance_missing",
|
||||||
|
)
|
||||||
|
self.assertFalse(missing_clearance.classification_allowed)
|
||||||
|
self.assertTrue(allowed.allowed)
|
||||||
|
self.assertEqual(unsupported.reason_code, "classification_unsupported")
|
||||||
|
|
||||||
|
def test_binding_failures_have_stable_transition_reasons(self) -> None:
|
||||||
|
expected = {
|
||||||
|
"unit_inactive": "organization_unit_inactive",
|
||||||
|
"function_inactive": "organization_function_inactive",
|
||||||
|
"function_reassigned": "organization_function_reassigned",
|
||||||
|
"directory_unavailable": "organization_directory_unavailable",
|
||||||
|
}
|
||||||
|
for binding_status, reason_code in expected.items():
|
||||||
|
with self.subTest(binding_status=binding_status):
|
||||||
|
decision = decide(
|
||||||
|
assignments=(assignment(),),
|
||||||
|
binding_status=binding_status,
|
||||||
|
)
|
||||||
|
self.assertFalse(decision.allowed)
|
||||||
|
self.assertEqual(decision.reason_code, reason_code)
|
||||||
|
|
||||||
def test_direct_delegated_directory_governance_and_system_sources_are_allowed(self) -> None:
|
def test_direct_delegated_directory_governance_and_system_sources_are_allowed(self) -> None:
|
||||||
for source in (
|
for source in (
|
||||||
"direct",
|
"direct",
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.postbox import PostboxActorRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import (
|
||||||
|
DatabaseHandle,
|
||||||
|
get_database,
|
||||||
|
reset_database,
|
||||||
|
set_database,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
|
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||||
|
from govoplan_identity.backend.directory import SqlIdentityDirectory
|
||||||
|
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||||
|
from govoplan_idm.backend.directory import SqlIdmDirectory
|
||||||
|
from govoplan_organizations.backend.db.models import (
|
||||||
|
OrganizationFunction,
|
||||||
|
OrganizationUnit,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
||||||
|
from govoplan_postbox.backend.db.models import (
|
||||||
|
Postbox,
|
||||||
|
PostboxAccessEvent,
|
||||||
|
PostboxAddress,
|
||||||
|
PostboxAttachmentReference,
|
||||||
|
PostboxBinding,
|
||||||
|
PostboxDelivery,
|
||||||
|
PostboxGrouping,
|
||||||
|
PostboxGroupingSource,
|
||||||
|
PostboxMessage,
|
||||||
|
PostboxMessageReceipt,
|
||||||
|
PostboxParticipant,
|
||||||
|
PostboxRoute,
|
||||||
|
PostboxTemplate,
|
||||||
|
PostboxTemplateRevision,
|
||||||
|
)
|
||||||
|
from govoplan_postbox.backend.service import PostboxService
|
||||||
|
|
||||||
|
|
||||||
|
TABLES = (
|
||||||
|
Identity.__table__,
|
||||||
|
IdentityAccountLink.__table__,
|
||||||
|
OrganizationUnit.__table__,
|
||||||
|
OrganizationFunction.__table__,
|
||||||
|
IdmOrganizationFunctionAssignment.__table__,
|
||||||
|
PostboxTemplate.__table__,
|
||||||
|
PostboxTemplateRevision.__table__,
|
||||||
|
PostboxAddress.__table__,
|
||||||
|
Postbox.__table__,
|
||||||
|
PostboxBinding.__table__,
|
||||||
|
PostboxMessage.__table__,
|
||||||
|
PostboxParticipant.__table__,
|
||||||
|
PostboxAttachmentReference.__table__,
|
||||||
|
PostboxDelivery.__table__,
|
||||||
|
PostboxRoute.__table__,
|
||||||
|
PostboxMessageReceipt.__table__,
|
||||||
|
PostboxGrouping.__table__,
|
||||||
|
PostboxGroupingSource.__table__,
|
||||||
|
PostboxAccessEvent.__table__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PostboxRealDirectoryAccessTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
try:
|
||||||
|
self.previous_database = get_database()
|
||||||
|
except RuntimeError:
|
||||||
|
self.previous_database = None
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine, tables=TABLES)
|
||||||
|
self.database = DatabaseHandle("sqlite:///:memory:", engine=self.engine)
|
||||||
|
set_database(self.database)
|
||||||
|
self.organizations = SqlOrganizationDirectory(
|
||||||
|
session_factory=self.database.SessionLocal
|
||||||
|
)
|
||||||
|
self.identities = SqlIdentityDirectory()
|
||||||
|
self.idm = SqlIdmDirectory(
|
||||||
|
identities=self.identities,
|
||||||
|
organizations=self.organizations,
|
||||||
|
)
|
||||||
|
self.service = PostboxService(
|
||||||
|
identities=self.identities,
|
||||||
|
idm=self.idm,
|
||||||
|
incumbencies=self.idm,
|
||||||
|
organizations=self.organizations,
|
||||||
|
)
|
||||||
|
with self.database.SessionLocal() as session:
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
Identity(
|
||||||
|
id="identity-owner",
|
||||||
|
display_name="Owner",
|
||||||
|
source="test",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
),
|
||||||
|
IdentityAccountLink(
|
||||||
|
id="link-owner",
|
||||||
|
identity_id="identity-owner",
|
||||||
|
account_id="account-owner",
|
||||||
|
is_primary=True,
|
||||||
|
source="test",
|
||||||
|
),
|
||||||
|
Identity(
|
||||||
|
id="identity-delegate",
|
||||||
|
display_name="Delegate",
|
||||||
|
source="test",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
),
|
||||||
|
IdentityAccountLink(
|
||||||
|
id="link-delegate",
|
||||||
|
identity_id="identity-delegate",
|
||||||
|
account_id="account-delegate",
|
||||||
|
is_primary=True,
|
||||||
|
source="test",
|
||||||
|
),
|
||||||
|
OrganizationUnit(
|
||||||
|
id="unit-one",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="unit-one",
|
||||||
|
name="Unit One",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
),
|
||||||
|
OrganizationUnit(
|
||||||
|
id="unit-two",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="unit-two",
|
||||||
|
name="Unit Two",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
),
|
||||||
|
OrganizationFunction(
|
||||||
|
id="function-one",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
organization_unit_id="unit-one",
|
||||||
|
slug="clerk",
|
||||||
|
name="Clerk",
|
||||||
|
delegable=True,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
postbox = self.service.create_exact_postbox(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
name="Unit One / Clerk",
|
||||||
|
organization_unit_id="unit-one",
|
||||||
|
function_id="function-one",
|
||||||
|
address_key=None,
|
||||||
|
description=None,
|
||||||
|
classification="internal",
|
||||||
|
actor_id="admin-1",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self.postbox_id = postbox.id
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
if self.previous_database is None:
|
||||||
|
reset_database()
|
||||||
|
else:
|
||||||
|
set_database(self.previous_database)
|
||||||
|
self.database.dispose()
|
||||||
|
|
||||||
|
def _actor(self, account_id: str) -> PostboxActorRef:
|
||||||
|
return PostboxActorRef(
|
||||||
|
account_id=account_id,
|
||||||
|
authorized_actions=frozenset({"discover", "read", "reply"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _decision(self, account_id: str):
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
return self.service.explain_access(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
postbox_id=self.postbox_id,
|
||||||
|
actor=self._actor(account_id),
|
||||||
|
action="read",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add_assignment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
assignment_id: str,
|
||||||
|
identity_id: str,
|
||||||
|
account_id: str,
|
||||||
|
source: str = "direct",
|
||||||
|
delegated_from_assignment_id: str | None = None,
|
||||||
|
valid_until=None,
|
||||||
|
) -> None:
|
||||||
|
with self.database.SessionLocal() as session:
|
||||||
|
session.add(
|
||||||
|
IdmOrganizationFunctionAssignment(
|
||||||
|
id=assignment_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id=identity_id,
|
||||||
|
account_id=account_id,
|
||||||
|
function_id="function-one",
|
||||||
|
organization_unit_id="unit-one",
|
||||||
|
source=source,
|
||||||
|
delegated_from_assignment_id=delegated_from_assignment_id,
|
||||||
|
valid_until=valid_until,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def test_real_directory_reassignment_changes_current_holder_only(self) -> None:
|
||||||
|
self._add_assignment(
|
||||||
|
assignment_id="owner-assignment",
|
||||||
|
identity_id="identity-owner",
|
||||||
|
account_id="account-owner",
|
||||||
|
)
|
||||||
|
self.assertTrue(self._decision("account-owner").allowed)
|
||||||
|
|
||||||
|
with self.database.SessionLocal() as session:
|
||||||
|
assignment = session.get(
|
||||||
|
IdmOrganizationFunctionAssignment,
|
||||||
|
"owner-assignment",
|
||||||
|
)
|
||||||
|
assignment.is_active = False
|
||||||
|
session.commit()
|
||||||
|
self._add_assignment(
|
||||||
|
assignment_id="delegate-assignment",
|
||||||
|
identity_id="identity-delegate",
|
||||||
|
account_id="account-delegate",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(self._decision("account-owner").allowed)
|
||||||
|
replacement = self._decision("account-delegate")
|
||||||
|
self.assertTrue(replacement.allowed)
|
||||||
|
self.assertEqual(replacement.assignment_ids, ("delegate-assignment",))
|
||||||
|
|
||||||
|
def test_real_directory_delegation_expires_with_its_source(self) -> None:
|
||||||
|
self._add_assignment(
|
||||||
|
assignment_id="owner-assignment",
|
||||||
|
identity_id="identity-owner",
|
||||||
|
account_id="account-owner",
|
||||||
|
)
|
||||||
|
self._add_assignment(
|
||||||
|
assignment_id="delegated-assignment",
|
||||||
|
identity_id="identity-delegate",
|
||||||
|
account_id="account-delegate",
|
||||||
|
source="delegated",
|
||||||
|
delegated_from_assignment_id="owner-assignment",
|
||||||
|
valid_until=utc_now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
self.assertTrue(self._decision("account-delegate").allowed)
|
||||||
|
|
||||||
|
with self.database.SessionLocal() as session:
|
||||||
|
delegated = session.get(
|
||||||
|
IdmOrganizationFunctionAssignment,
|
||||||
|
"delegated-assignment",
|
||||||
|
)
|
||||||
|
delegated.valid_until = utc_now() - timedelta(seconds=1)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
expired = self._decision("account-delegate")
|
||||||
|
self.assertFalse(expired.allowed)
|
||||||
|
self.assertEqual(expired.reason_code, "effective_assignment_missing")
|
||||||
|
|
||||||
|
def test_real_organization_state_and_function_move_fail_closed(self) -> None:
|
||||||
|
self._add_assignment(
|
||||||
|
assignment_id="owner-assignment",
|
||||||
|
identity_id="identity-owner",
|
||||||
|
account_id="account-owner",
|
||||||
|
)
|
||||||
|
self.assertTrue(self._decision("account-owner").allowed)
|
||||||
|
|
||||||
|
with self.database.SessionLocal() as session:
|
||||||
|
function = session.get(OrganizationFunction, "function-one")
|
||||||
|
function.is_active = False
|
||||||
|
session.commit()
|
||||||
|
inactive_function = self._decision("account-owner")
|
||||||
|
self.assertEqual(
|
||||||
|
inactive_function.reason_code,
|
||||||
|
"organization_function_inactive",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.database.SessionLocal() as session:
|
||||||
|
function = session.get(OrganizationFunction, "function-one")
|
||||||
|
function.is_active = True
|
||||||
|
unit = session.get(OrganizationUnit, "unit-one")
|
||||||
|
unit.is_active = False
|
||||||
|
session.commit()
|
||||||
|
inactive_unit = self._decision("account-owner")
|
||||||
|
self.assertEqual(inactive_unit.reason_code, "organization_unit_inactive")
|
||||||
|
|
||||||
|
with self.database.SessionLocal() as session:
|
||||||
|
unit = session.get(OrganizationUnit, "unit-one")
|
||||||
|
unit.is_active = True
|
||||||
|
function = session.get(OrganizationFunction, "function-one")
|
||||||
|
function.organization_unit_id = "unit-two"
|
||||||
|
session.commit()
|
||||||
|
moved = self._decision("account-owner")
|
||||||
|
self.assertEqual(moved.reason_code, "organization_function_reassigned")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -735,6 +735,94 @@ class PostboxServiceTests(unittest.TestCase):
|
|||||||
self.assertTrue(allowed.allowed)
|
self.assertTrue(allowed.allowed)
|
||||||
self.assertEqual("effective_acting_for_assignment", allowed.reason_code)
|
self.assertEqual("effective_acting_for_assignment", allowed.reason_code)
|
||||||
|
|
||||||
|
def test_classification_clearance_controls_directory_and_delivery(self) -> None:
|
||||||
|
self.idm.assignments.append(self.assignment)
|
||||||
|
elevated_actor = PostboxActorRef(
|
||||||
|
account_id="account-1",
|
||||||
|
identity_id="identity-1",
|
||||||
|
authorized_actions=frozenset({"discover", "read", "send", "reply"}),
|
||||||
|
authorized_classifications=frozenset(
|
||||||
|
{"public", "internal", "confidential"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
postbox = self.service.create_exact_postbox(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
name="Confidential intake",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
function_id="function-1",
|
||||||
|
address_key=None,
|
||||||
|
description=None,
|
||||||
|
classification="confidential",
|
||||||
|
actor_id="admin-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
denied = self.service.explain_access(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
postbox_id=postbox.id,
|
||||||
|
actor=self.actor,
|
||||||
|
action="read",
|
||||||
|
)
|
||||||
|
visible = self.service.list_visible_postboxes(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor=elevated_actor,
|
||||||
|
)
|
||||||
|
delivered = self.service.deliver(
|
||||||
|
session,
|
||||||
|
PostboxDeliveryRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
producer_resource_type="recipient",
|
||||||
|
producer_resource_id="recipient-1",
|
||||||
|
idempotency_key="confidential-message",
|
||||||
|
subject="Confidential notice",
|
||||||
|
classification="confidential",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
denied.reason_code,
|
||||||
|
"classification_clearance_missing",
|
||||||
|
)
|
||||||
|
self.assertEqual([postbox.id], [item.id for item in visible])
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.service.get_message(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
message_id=delivered.message_id,
|
||||||
|
actor=elevated_actor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.service.get_message(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
message_id=delivered.message_id,
|
||||||
|
actor=self.actor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
PostboxError,
|
||||||
|
"exceeds the target Postbox classification",
|
||||||
|
):
|
||||||
|
self.service.deliver(
|
||||||
|
session,
|
||||||
|
PostboxDeliveryRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||||
|
producer_module="campaigns",
|
||||||
|
producer_resource_type="recipient",
|
||||||
|
producer_resource_id="recipient-2",
|
||||||
|
idempotency_key="restricted-message",
|
||||||
|
subject="Restricted notice",
|
||||||
|
classification="restricted",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def test_template_revision_is_immutable_and_materialization_idempotent(self) -> None:
|
def test_template_revision_is_immutable_and_materialization_idempotent(self) -> None:
|
||||||
with Session(self.engine) as session:
|
with Session(self.engine) as session:
|
||||||
template = self.service.create_template(
|
template = self.service.create_template(
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export type PostboxAccessDecision = {
|
|||||||
selected_assignment_id?: string | null;
|
selected_assignment_id?: string | null;
|
||||||
holder_count: number;
|
holder_count: number;
|
||||||
vacant: boolean;
|
vacant: boolean;
|
||||||
|
classification: string;
|
||||||
|
classification_allowed: boolean;
|
||||||
|
binding_status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PostboxDirectoryItem = {
|
export type PostboxDirectoryItem = {
|
||||||
|
|||||||
Reference in New Issue
Block a user