feat: integrate files and portal surfaces
This commit is contained in:
@@ -151,6 +151,11 @@ class PostboxTemplateRevision(Base, TimestampMixin):
|
||||
default=True,
|
||||
nullable=False,
|
||||
)
|
||||
portal_visible: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
)
|
||||
encryption_profile: Mapped[str] = mapped_column(
|
||||
String(80),
|
||||
default="plaintext_v1",
|
||||
|
||||
@@ -8,6 +8,7 @@ from govoplan_core.core.access import (
|
||||
)
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY
|
||||
from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
|
||||
from govoplan_core.core.files import CAPABILITY_FILES_POSTBOX_REFERENCES
|
||||
from govoplan_core.core.idm import (
|
||||
CAPABILITY_IDM_DIRECTORY,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
@@ -44,6 +45,7 @@ from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_DIRECTORY,
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
CAPABILITY_POSTBOX_MESSAGES,
|
||||
CAPABILITY_POSTBOX_PORTAL,
|
||||
CAPABILITY_POSTBOX_ROUTING,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
@@ -254,6 +256,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
CAPABILITY_POSTBOX_ROUTING,
|
||||
CAPABILITY_POSTBOX_PORTAL,
|
||||
)
|
||||
),
|
||||
requires_interfaces=(
|
||||
@@ -285,6 +288,12 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_FILES_POSTBOX_REFERENCES,
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
@@ -426,8 +435,44 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_POSTBOX_DELIVERY: _configure,
|
||||
CAPABILITY_POSTBOX_EVIDENCE: _configure,
|
||||
CAPABILITY_POSTBOX_ROUTING: _configure,
|
||||
CAPABILITY_POSTBOX_PORTAL: lambda context: __import__(
|
||||
"govoplan_postbox.backend.portal_projection",
|
||||
fromlist=["create_portal_projection"],
|
||||
).create_portal_projection(context),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="postbox.files.evidence-references",
|
||||
title="Open permitted Files evidence from Postbox",
|
||||
summary="Resolve exact Files versions from a Postbox message without merging Postbox and Files permissions.",
|
||||
body=(
|
||||
"A Postbox message retains typed attachment references even when Files is absent or the current user cannot read the payload. "
|
||||
"When Files is installed, Postbox asks its public capability to resolve the exact referenced version. The download is exposed only "
|
||||
"when the user also has Files download permission and resource access; Postbox access never silently grants Files access. Missing, "
|
||||
"deleted, forbidden, and provider-unavailable payloads remain visible as explained evidence references."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("administrator", "user", "campaign_manager"),
|
||||
related_modules=("files", "audit", "campaigns"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Zulässige Files-Nachweise aus dem Postfach öffnen",
|
||||
"summary": "Exakte Files-Versionen aus einer Postfachnachricht auflösen, ohne Postfach- und Dateiberechtigungen zu vermischen.",
|
||||
"body": (
|
||||
"Eine Postfachnachricht bewahrt typisierte Anlagenverweise auch dann, wenn Files fehlt oder die aktuelle Person die Nutzdaten nicht lesen darf. "
|
||||
"Ist Files installiert, lässt Postbox die exakt referenzierte Version über dessen öffentliche Capability auflösen. Ein Download wird nur angeboten, "
|
||||
"wenn zusätzlich die Files-Downloadberechtigung und der Ressourcenzugriff bestehen; Postfachzugriff erteilt niemals stillschweigend Dateizugriff. "
|
||||
"Fehlende, gelöschte, nicht erlaubte oder mangels Anbieter nicht auflösbare Nutzdaten bleiben als erklärte Nachweisverweise sichtbar."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "guide",
|
||||
"help_contexts": ["postbox.message.attachments"],
|
||||
},
|
||||
order=32,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.quick-access-and-product-area",
|
||||
title="Postbox in Communication and Messages",
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
"""Add explicit portal visibility to Postbox template revisions.
|
||||
|
||||
Revision ID: f2a5c8e1b4d7
|
||||
Revises: e9f4a7b2c5d8
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f2a5c8e1b4d7"
|
||||
down_revision = "e9f4a7b2c5d8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("postbox_template_revisions") as batch:
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
"portal_visible",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("postbox_template_revisions") as batch:
|
||||
batch.drop_column("portal_visible")
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxPortalEntryRef,
|
||||
PostboxPortalProjectionProvider,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_postbox.backend.db.models import Postbox, PostboxMessage
|
||||
from govoplan_postbox.backend.principals import actor_from_principal
|
||||
from govoplan_postbox.backend.runtime import configure_runtime, get_service
|
||||
|
||||
|
||||
class PortalProjection(PostboxPortalProjectionProvider):
|
||||
"""Read-only Portal projection; Postbox remains the access authority."""
|
||||
|
||||
def list_portal_entries(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
limit: int = 100,
|
||||
) -> tuple[PostboxPortalEntryRef, ...]:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Postbox Portal projection requires a SQLAlchemy session.")
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise TypeError("Postbox Portal projection requires an API principal.")
|
||||
if principal.tenant_id != tenant_id:
|
||||
return ()
|
||||
actor = actor_from_principal(principal)
|
||||
visible = tuple(
|
||||
get_service().list_visible_postboxes(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
if not visible:
|
||||
return ()
|
||||
visible_by_id = {entry.id: entry for entry in visible}
|
||||
rows = (
|
||||
session.query(Postbox)
|
||||
.filter(
|
||||
Postbox.tenant_id == tenant_id,
|
||||
Postbox.id.in_(tuple(visible_by_id)),
|
||||
Postbox.status == "active",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
enabled_ids = {
|
||||
row.id
|
||||
for row in rows
|
||||
if bool((row.settings or {}).get("portal_visible"))
|
||||
}
|
||||
if not enabled_ids:
|
||||
return ()
|
||||
ordered_ids = tuple(
|
||||
entry.id
|
||||
for entry in sorted(
|
||||
(visible_by_id[item_id] for item_id in enabled_ids),
|
||||
key=lambda item: (item.name.casefold(), item.id),
|
||||
)[: max(1, min(limit, 500))]
|
||||
)
|
||||
counts = get_service().message_counts_by_postbox(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
postbox_ids=ordered_ids,
|
||||
actor=actor,
|
||||
)
|
||||
latest_rows = (
|
||||
session.query(
|
||||
PostboxMessage.postbox_id,
|
||||
func.max(PostboxMessage.delivered_at),
|
||||
)
|
||||
.filter(
|
||||
PostboxMessage.tenant_id == tenant_id,
|
||||
PostboxMessage.postbox_id.in_(ordered_ids),
|
||||
PostboxMessage.classification.in_(
|
||||
tuple(actor.authorized_classifications)
|
||||
),
|
||||
)
|
||||
.group_by(PostboxMessage.postbox_id)
|
||||
.all()
|
||||
)
|
||||
latest = {str(postbox_id): delivered_at for postbox_id, delivered_at in latest_rows}
|
||||
return tuple(
|
||||
PostboxPortalEntryRef(
|
||||
postbox=visible_by_id[postbox_id],
|
||||
unread_count=int(counts.get(postbox_id, {}).get("unread", 0)),
|
||||
latest_message_at=latest.get(postbox_id),
|
||||
route_path=f"/postbox?postbox={postbox_id}",
|
||||
)
|
||||
for postbox_id in ordered_ids
|
||||
)
|
||||
|
||||
|
||||
def create_portal_projection(context: ModuleContext) -> PortalProjection:
|
||||
configure_runtime(registry=context.registry)
|
||||
return PortalProjection()
|
||||
|
||||
|
||||
__all__ = ["PortalProjection", "create_portal_projection"]
|
||||
@@ -24,6 +24,10 @@ from govoplan_core.core.postbox import (
|
||||
PostboxTargetRef,
|
||||
PostboxWrappedKeyRef,
|
||||
)
|
||||
from govoplan_core.core.files import (
|
||||
PostboxFileReferenceRequest,
|
||||
postbox_file_reference_provider,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_postbox.backend.manifest import (
|
||||
ACKNOWLEDGE_SCOPE,
|
||||
@@ -34,9 +38,11 @@ from govoplan_postbox.backend.manifest import (
|
||||
SEND_SCOPE,
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
)
|
||||
from govoplan_postbox.backend.runtime import get_service
|
||||
from govoplan_postbox.backend.runtime import get_registry, get_service
|
||||
from govoplan_postbox.backend.schemas import (
|
||||
PostboxAccessDecisionResponse,
|
||||
PostboxAttachmentResolutionItem,
|
||||
PostboxAttachmentResolutionResponse,
|
||||
PostboxDeliveryCreateRequest,
|
||||
PostboxDeliveryResponse,
|
||||
PostboxDirectoryItem,
|
||||
@@ -237,6 +243,7 @@ def _template_item(template) -> PostboxTemplateItem:
|
||||
"address_pattern": revision.address_pattern,
|
||||
"classification": revision.classification,
|
||||
"allow_vacant_delivery": revision.allow_vacant_delivery,
|
||||
"portal_visible": revision.portal_visible,
|
||||
"encryption_profile": revision.encryption_profile,
|
||||
"encryption_vault_id": revision.encryption_vault_id,
|
||||
"history_policy": dict(revision.history_policy or {}),
|
||||
@@ -486,6 +493,114 @@ def api_get_postbox_message(
|
||||
return _message_item(message)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/messages/{message_id}/attachment-resolutions",
|
||||
response_model=PostboxAttachmentResolutionResponse,
|
||||
)
|
||||
def api_resolve_postbox_message_attachments(
|
||||
message_id: str,
|
||||
assignment_context_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> PostboxAttachmentResolutionResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
message = get_service().get_message(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
message_id=message_id,
|
||||
actor=_actor(
|
||||
principal,
|
||||
assignment_context_id=assignment_context_id,
|
||||
),
|
||||
)
|
||||
except PostboxError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
if message is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Postbox message not found.",
|
||||
)
|
||||
|
||||
provider = postbox_file_reference_provider(get_registry())
|
||||
file_types = {"file", "file_asset", "files:file", "file_version", "files:file_version"}
|
||||
requests = tuple(
|
||||
PostboxFileReferenceRequest(
|
||||
reference_type=attachment.reference_type,
|
||||
reference_id=attachment.reference_id,
|
||||
postbox_id=message.postbox_id,
|
||||
message_id=message.id,
|
||||
)
|
||||
for attachment in message.attachments
|
||||
if attachment.reference_type.strip().casefold() in file_types
|
||||
)
|
||||
resolved = (
|
||||
provider.resolve_postbox_references(
|
||||
session,
|
||||
principal,
|
||||
tenant_id=principal.tenant_id,
|
||||
requests=requests,
|
||||
)
|
||||
if provider is not None and requests
|
||||
else ()
|
||||
)
|
||||
by_reference = {
|
||||
(item.reference_type, item.reference_id): item for item in resolved
|
||||
}
|
||||
items: list[PostboxAttachmentResolutionItem] = []
|
||||
for attachment in message.attachments:
|
||||
resolution = by_reference.get(
|
||||
(attachment.reference_type, attachment.reference_id)
|
||||
)
|
||||
is_file = attachment.reference_type.strip().casefold() in file_types
|
||||
attachment_payload = asdict(attachment)
|
||||
attachment_payload.update(
|
||||
{
|
||||
"name": (
|
||||
resolution.filename
|
||||
if resolution and resolution.filename
|
||||
else attachment.name
|
||||
),
|
||||
"media_type": (
|
||||
resolution.content_type
|
||||
if resolution and resolution.content_type
|
||||
else attachment.media_type
|
||||
),
|
||||
"size_bytes": (
|
||||
resolution.size_bytes
|
||||
if resolution and resolution.size_bytes is not None
|
||||
else attachment.size_bytes
|
||||
),
|
||||
"digest": (
|
||||
resolution.sha256
|
||||
if resolution and resolution.sha256
|
||||
else attachment.digest
|
||||
),
|
||||
}
|
||||
)
|
||||
items.append(
|
||||
PostboxAttachmentResolutionItem(
|
||||
**attachment_payload,
|
||||
available=bool(resolution and resolution.available),
|
||||
reason_code=(
|
||||
resolution.reason_code
|
||||
if resolution is not None
|
||||
else (
|
||||
"files_provider_unavailable"
|
||||
if is_file
|
||||
else "reference_provider_unavailable"
|
||||
)
|
||||
),
|
||||
file_asset_id=resolution.file_asset_id if resolution else None,
|
||||
file_version_id=resolution.file_version_id if resolution else None,
|
||||
download_path=resolution.download_path if resolution else None,
|
||||
provenance=dict(resolution.provenance) if resolution else {},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return PostboxAttachmentResolutionResponse(attachments=items)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/messages/{message_id}/state",
|
||||
response_model=PostboxMessageItem,
|
||||
|
||||
@@ -75,6 +75,19 @@ class PostboxAttachmentPayload(BaseModel):
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxAttachmentResolutionItem(PostboxAttachmentPayload):
|
||||
available: bool = False
|
||||
reason_code: str
|
||||
file_asset_id: str | None = None
|
||||
file_version_id: str | None = None
|
||||
download_path: str | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxAttachmentResolutionResponse(BaseModel):
|
||||
attachments: list[PostboxAttachmentResolutionItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PostboxWrappedKeyPayload(BaseModel):
|
||||
recipient_type: str = Field(min_length=1, max_length=50)
|
||||
recipient_id: str = Field(min_length=1, max_length=255)
|
||||
@@ -355,6 +368,7 @@ class PostboxExactCreateRequest(BaseModel):
|
||||
function_id: str = Field(min_length=1, max_length=36)
|
||||
address_key: str | None = Field(default=None, max_length=120)
|
||||
classification: PostboxClassification = "internal"
|
||||
portal_visible: bool = False
|
||||
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
|
||||
"plaintext_v1"
|
||||
)
|
||||
@@ -392,6 +406,7 @@ class PostboxTemplateRevisionPayload(BaseModel):
|
||||
)
|
||||
classification: PostboxClassification = "internal"
|
||||
allow_vacant_delivery: bool = True
|
||||
portal_visible: bool = False
|
||||
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
|
||||
"plaintext_v1"
|
||||
)
|
||||
|
||||
@@ -2869,6 +2869,7 @@ class PostboxService:
|
||||
address_key: str | None,
|
||||
description: str | None,
|
||||
classification: str,
|
||||
portal_visible: bool = False,
|
||||
actor_id: str | None,
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
@@ -2916,6 +2917,7 @@ class PostboxService:
|
||||
actor_id=actor_id,
|
||||
encryption_profile=encryption_profile,
|
||||
encryption_vault_id=encryption_vault_id,
|
||||
portal_visible=portal_visible,
|
||||
)
|
||||
|
||||
def archive_postbox(
|
||||
@@ -3005,6 +3007,7 @@ class PostboxService:
|
||||
address_pattern: str,
|
||||
classification: str,
|
||||
allow_vacant_delivery: bool,
|
||||
portal_visible: bool = False,
|
||||
routing_policy: Mapping[str, object] | None,
|
||||
encryption_profile: str,
|
||||
encryption_vault_id: str | None,
|
||||
@@ -3012,7 +3015,7 @@ class PostboxService:
|
||||
context_key: str | None,
|
||||
limit: int,
|
||||
) -> dict[str, object]:
|
||||
del description
|
||||
del description, portal_visible
|
||||
self._validate_classification(classification)
|
||||
_validate_encryption_configuration(
|
||||
encryption_profile,
|
||||
@@ -3205,6 +3208,7 @@ class PostboxService:
|
||||
address_pattern: str,
|
||||
classification: str,
|
||||
allow_vacant_delivery: bool,
|
||||
portal_visible: bool = False,
|
||||
actor_id: str | None,
|
||||
scope_structure_id: str | None = None,
|
||||
scope_relation_type_ids: Sequence[str] = (),
|
||||
@@ -3260,6 +3264,7 @@ class PostboxService:
|
||||
address_pattern=address_pattern,
|
||||
classification=classification,
|
||||
allow_vacant_delivery=allow_vacant_delivery,
|
||||
portal_visible=portal_visible,
|
||||
encryption_profile=encryption_profile,
|
||||
encryption_vault_id=encryption_vault_id,
|
||||
history_policy={},
|
||||
@@ -3295,6 +3300,7 @@ class PostboxService:
|
||||
address_pattern: str,
|
||||
classification: str,
|
||||
allow_vacant_delivery: bool,
|
||||
portal_visible: bool = False,
|
||||
actor_id: str | None,
|
||||
expected_revision: int,
|
||||
scope_structure_id: str | None = None,
|
||||
@@ -3351,6 +3357,7 @@ class PostboxService:
|
||||
address_pattern=address_pattern,
|
||||
classification=classification,
|
||||
allow_vacant_delivery=allow_vacant_delivery,
|
||||
portal_visible=portal_visible,
|
||||
encryption_profile=encryption_profile,
|
||||
encryption_vault_id=encryption_vault_id,
|
||||
history_policy={},
|
||||
@@ -4492,6 +4499,7 @@ class PostboxService:
|
||||
actor_id: str | None,
|
||||
encryption_profile: str | None = None,
|
||||
encryption_vault_id: str | None = None,
|
||||
portal_visible: bool = False,
|
||||
) -> Postbox:
|
||||
effective_profile = (
|
||||
revision.encryption_profile
|
||||
@@ -4503,6 +4511,11 @@ class PostboxService:
|
||||
if revision is not None
|
||||
else encryption_vault_id
|
||||
)
|
||||
effective_portal_visible = (
|
||||
bool(revision.portal_visible)
|
||||
if revision is not None
|
||||
else bool(portal_visible)
|
||||
)
|
||||
if effective_profile == "server_envelope_v1" and not str(
|
||||
effective_vault_id or ""
|
||||
).strip():
|
||||
@@ -4538,11 +4551,14 @@ class PostboxService:
|
||||
classification=classification,
|
||||
encryption_profile=effective_profile,
|
||||
key_epoch=1,
|
||||
settings=(
|
||||
{"encryption_vault_id": str(effective_vault_id)}
|
||||
if effective_vault_id
|
||||
else {}
|
||||
),
|
||||
settings={
|
||||
**(
|
||||
{"encryption_vault_id": str(effective_vault_id)}
|
||||
if effective_vault_id
|
||||
else {}
|
||||
),
|
||||
"portal_visible": effective_portal_visible,
|
||||
},
|
||||
)
|
||||
postbox.bindings.append(
|
||||
PostboxBinding(
|
||||
|
||||
Reference in New Issue
Block a user