feat(scheduling): enforce participant roster privacy

This commit is contained in:
2026-07-20 18:44:28 +02:00
parent d0b95eee58
commit 6141f39bf8
9 changed files with 430 additions and 4 deletions

View File

@@ -36,6 +36,7 @@ class SchedulingRequest(Base, TimestampMixin):
allow_external_participants: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
participant_visibility: Mapped[str] = mapped_column(String(32), default="aggregates_only", nullable=False)
calendar_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View File

@@ -19,6 +19,7 @@ from govoplan_core.core.modules import (
)
from govoplan_core.db.base import Base
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
MODULE_ID = "scheduling"
@@ -129,11 +130,12 @@ manifest = ModuleManifest(
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("poll",),
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "policy", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
optional_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_CALENDAR_SCHEDULING,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
),
required_capabilities=(CAPABILITY_POLL_SCHEDULING,),
provides_interfaces=(

View File

@@ -0,0 +1,32 @@
"""v0.1.9 scheduling participant visibility
Revision ID: 5e6f7a8b9c0d
Revises: 4d5e6f7a8b9c
Create Date: 2026-07-20 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "5e6f7a8b9c0d"
down_revision = "4d5e6f7a8b9c"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"scheduling_requests",
sa.Column(
"participant_visibility",
sa.String(length=32),
nullable=False,
server_default="aggregates_only",
),
)
def downgrade() -> None:
op.drop_column("scheduling_requests", "participant_visibility")

View File

@@ -138,6 +138,7 @@ def _request_response(
request,
invitation_tokens=invitation_tokens,
actor_ids=_principal_actor_ids(principal),
actor_user_id=principal.account_id,
can_manage=_can_manage_scheduling(principal),
)
)

View File

@@ -12,6 +12,7 @@ SchedulingParticipantType = Literal["internal", "external", "resource"]
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
def _known_timezone(value: str | None) -> str | None:
@@ -103,6 +104,7 @@ class SchedulingRequestCreateRequest(BaseModel):
allow_external_participants: bool = True
allow_participant_updates: bool = True
result_visibility: SchedulingResultVisibility = "after_close"
participant_visibility: SchedulingParticipantVisibility = "aggregates_only"
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
@@ -122,6 +124,7 @@ class SchedulingRequestUpdateRequest(BaseModel):
allow_external_participants: bool | None = None
allow_participant_updates: bool | None = None
result_visibility: SchedulingResultVisibility | None = None
participant_visibility: SchedulingParticipantVisibility | None = None
calendar: SchedulingCalendarPreferences | None = None
metadata: dict[str, Any] | None = None
@@ -159,6 +162,20 @@ class SchedulingParticipantResponse(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingParticipantAggregateResponse(BaseModel):
total: int = 0
status_counts: dict[str, int] = Field(default_factory=dict)
class SchedulingParticipantVisibilityDecisionResponse(BaseModel):
requested_visibility: SchedulingParticipantVisibility
effective_visibility: SchedulingParticipantVisibility
policy_applied: bool = False
reason: str | None = None
source_path: list[dict[str, Any]] = Field(default_factory=list)
details: dict[str, Any] = Field(default_factory=dict)
class SchedulingRequestResponse(BaseModel):
id: str
tenant_id: str
@@ -174,6 +191,10 @@ class SchedulingRequestResponse(BaseModel):
allow_external_participants: bool
allow_participant_updates: bool
result_visibility: str
participant_visibility: SchedulingParticipantVisibility
effective_participant_visibility: SchedulingParticipantVisibility
participant_aggregate: SchedulingParticipantAggregateResponse
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
calendar_integration_enabled: bool
calendar_id: str | None = None
calendar_freebusy_enabled: bool

View File

@@ -5,7 +5,7 @@ import json
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, object_session
from govoplan_core.core.calendar import (
CalendarCapabilityError,
@@ -29,6 +29,11 @@ from govoplan_core.core.poll import (
poll_response_submission_provider,
poll_scheduling_provider,
)
from govoplan_core.core.policy import (
SchedulingParticipantPrivacyDecision,
SchedulingParticipantPrivacyRequest,
scheduling_participant_privacy_policy,
)
from govoplan_core.db.base import utcnow
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.schemas import (
@@ -903,6 +908,7 @@ def create_scheduling_request(
allow_external_participants=payload.allow_external_participants,
allow_participant_updates=payload.allow_participant_updates,
result_visibility=payload.result_visibility,
participant_visibility=payload.participant_visibility,
metadata_=payload.metadata,
)
_set_calendar_preferences(request, payload)
@@ -1319,7 +1325,16 @@ def update_scheduling_request(
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.status in {"decided", "handed_off", "cancelled", "archived"}:
raise SchedulingError("Decided, handed off, cancelled, or archived scheduling requests cannot be edited")
for field in ("title", "description", "location", "deadline_at", "allow_external_participants", "allow_participant_updates", "result_visibility"):
for field in (
"title",
"description",
"location",
"deadline_at",
"allow_external_participants",
"allow_participant_updates",
"result_visibility",
"participant_visibility",
):
value = getattr(payload, field)
if value is not None:
setattr(request, field, value)
@@ -1663,16 +1678,119 @@ def scheduling_participant_response(
}
_PARTICIPANT_STATUSES = ("draft", "invited", "responded", "declined", "removed")
def _participant_aggregate(participants: list[SchedulingParticipant]) -> dict[str, Any]:
status_counts = {status: 0 for status in _PARTICIPANT_STATUSES}
for participant in participants:
status_counts[participant.status] = status_counts.get(participant.status, 0) + 1
return {"total": len(participants), "status_counts": status_counts}
def _participant_visibility_decision(
request: SchedulingRequest,
*,
participant: SchedulingParticipant | None,
actor_user_id: str | None,
) -> dict[str, Any]:
requested = request.participant_visibility
payload: dict[str, Any] = {
"requested_visibility": requested,
"effective_visibility": requested,
"policy_applied": False,
"reason": None,
"source_path": [],
"details": {},
}
if participant is None:
payload["effective_visibility"] = "aggregates_only"
payload["reason"] = "A unique participant context is required for roster visibility."
return payload
provider = scheduling_participant_privacy_policy(get_registry())
if provider is None:
return payload
payload["policy_applied"] = True
try:
decision = provider.resolve_scheduling_participant_visibility(
object_session(request),
request=SchedulingParticipantPrivacyRequest(
tenant_id=request.tenant_id,
scheduling_request_id=request.id,
participant_id=participant.id,
actor_user_id=actor_user_id,
requested_visibility=requested,
context={
"request_status": request.status,
"organizer_user_id": request.organizer_user_id,
},
),
)
except Exception:
payload["effective_visibility"] = "aggregates_only"
payload["reason"] = "The participant privacy policy could not be evaluated."
payload["details"] = {"policy_error": True}
return payload
if not isinstance(decision, SchedulingParticipantPrivacyDecision):
payload["effective_visibility"] = "aggregates_only"
payload["reason"] = "The participant privacy policy returned an invalid decision."
payload["details"] = {"invalid_policy_decision": True}
return payload
decision_payload = decision.to_dict()
resolved = decision.effective_visibility
if requested == "aggregates_only" or resolved not in {"aggregates_only", "names_and_statuses"}:
resolved = "aggregates_only"
payload.update(
effective_visibility=resolved,
reason=decision.reason,
source_path=decision_payload["source_path"],
details=decision_payload["details"],
)
return payload
def scheduling_request_response(
request: SchedulingRequest,
*,
invitation_tokens: dict[str, str] | None = None,
actor_ids: tuple[str, ...] | None = None,
actor_user_id: str | None = None,
can_manage: bool = False,
) -> dict[str, Any]:
invitation_tokens = invitation_tokens or {}
ids = _actor_ids(actor_ids or ())
full_roster = actor_ids is None or can_manage or request.organizer_user_id in ids
active_participants = _active_participants(request)
own_participants = [
participant
for participant in active_participants
if participant.respondent_id in ids or participant.email in ids
]
if full_roster:
visibility_decision = {
"requested_visibility": request.participant_visibility,
"effective_visibility": "names_and_statuses",
"policy_applied": False,
"reason": "Full participant roster access is granted to organizers and scheduling managers.",
"source_path": [],
"details": {"management_access": True},
}
projected_participants = active_participants
else:
visibility_decision = _participant_visibility_decision(
request,
participant=own_participants[0] if len(own_participants) == 1 else None,
actor_user_id=actor_user_id,
)
projected_participants = (
active_participants
if visibility_decision["effective_visibility"] == "names_and_statuses"
else own_participants
)
if not full_roster:
invitation_tokens = {}
return {
@@ -1690,6 +1808,10 @@ def scheduling_request_response(
"allow_external_participants": request.allow_external_participants,
"allow_participant_updates": request.allow_participant_updates,
"result_visibility": request.result_visibility,
"participant_visibility": request.participant_visibility,
"effective_participant_visibility": visibility_decision["effective_visibility"],
"participant_aggregate": _participant_aggregate(active_participants),
"participant_visibility_decision": visibility_decision,
"calendar_integration_enabled": request.calendar_integration_enabled,
"calendar_id": request.calendar_id,
"calendar_freebusy_enabled": request.calendar_freebusy_enabled,
@@ -1710,7 +1832,7 @@ def scheduling_request_response(
and participant.respondent_id not in ids
and participant.email not in ids,
)
for participant in _active_participants(request)
for participant in projected_participants
],
}