feat(scheduling): enforce participant roster privacy
This commit is contained in:
@@ -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
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user