diff --git a/src/govoplan_scheduling/backend/db/models.py b/src/govoplan_scheduling/backend/db/models.py index 8142f14..987cd55 100644 --- a/src/govoplan_scheduling/backend/db/models.py +++ b/src/govoplan_scheduling/backend/db/models.py @@ -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) diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index fa56f88..9e910ab 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -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=( diff --git a/src/govoplan_scheduling/backend/migrations/versions/5e6f7a8b9c0d_v019_scheduling_participant_visibility.py b/src/govoplan_scheduling/backend/migrations/versions/5e6f7a8b9c0d_v019_scheduling_participant_visibility.py new file mode 100644 index 0000000..77f5bb1 --- /dev/null +++ b/src/govoplan_scheduling/backend/migrations/versions/5e6f7a8b9c0d_v019_scheduling_participant_visibility.py @@ -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") diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 92d966a..0ee4c33 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -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), ) ) diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index c58e0aa..82067c8 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -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 diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index b723fe7..b1fbbe0 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -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 ], } diff --git a/tests/test_manifest.py b/tests/test_manifest.py index c56dbbf..fc54f33 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -16,10 +16,12 @@ class SchedulingManifestTests(unittest.TestCase): self.assertNotIn("access", manifest.dependencies) self.assertIn("access", manifest.optional_dependencies) self.assertIn("addresses", manifest.optional_dependencies) + self.assertIn("policy", manifest.optional_dependencies) self.assertEqual(("poll.scheduling",), manifest.required_capabilities) self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIn("poll.scheduling", manifest.required_capabilities) self.assertIn("calendar.scheduling", manifest.optional_capabilities) + self.assertIn("policy.schedulingParticipantPrivacy", manifest.optional_capabilities) self.assertIn("evaluation", manifest.optional_dependencies) self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.migration_spec) diff --git a/tests/test_participant_privacy.py b/tests/test_participant_privacy.py new file mode 100644 index 0000000..cbf87a2 --- /dev/null +++ b/tests/test_participant_privacy.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_core.core.modules import ModuleContext, ModuleManifest +from govoplan_core.core.policy import ( + CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY, + PolicySourceStep, + SchedulingParticipantPrivacyDecision, + SchedulingParticipantPrivacyRequest, +) +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.db.base import Base +from govoplan_scheduling.backend.db.models import ( + SchedulingCandidateSlot, + SchedulingParticipant, + SchedulingRequest, +) +from govoplan_scheduling.backend.runtime import configure_runtime +from govoplan_scheduling.backend.schemas import ( + SchedulingCandidateSlotInput, + SchedulingRequestCreateRequest, + SchedulingRequestResponse, + SchedulingRequestUpdateRequest, +) +from govoplan_scheduling.backend.service import scheduling_request_response + + +class _PrivacyPolicy: + def __init__(self, effective_visibility: str) -> None: + self.effective_visibility = effective_visibility + self.requests: list[SchedulingParticipantPrivacyRequest] = [] + + def resolve_scheduling_participant_visibility( + self, + session: object, + *, + request: SchedulingParticipantPrivacyRequest, + ) -> SchedulingParticipantPrivacyDecision: + self.requests.append(request) + return SchedulingParticipantPrivacyDecision( + effective_visibility=self.effective_visibility, # type: ignore[arg-type] + reason="Tenant participant privacy policy", + source_path=( + PolicySourceStep( + scope_type="tenant", + scope_id=request.tenant_id, + label="Tenant", + applied_fields=("scheduling_participant_visibility",), + ), + ), + details={"provider_session_available": session is not None}, + ) + + +class SchedulingParticipantPrivacyTests(unittest.TestCase): + def setUp(self) -> None: + configure_runtime(registry=PlatformRegistry()) + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[ + SchedulingRequest.__table__, + SchedulingCandidateSlot.__table__, + SchedulingParticipant.__table__, + ], + ) + self.Session = sessionmaker(bind=self.engine) + self.session: Session = self.Session() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all( + self.engine, + tables=[ + SchedulingParticipant.__table__, + SchedulingCandidateSlot.__table__, + SchedulingRequest.__table__, + ], + ) + self.engine.dispose() + configure_runtime(registry=PlatformRegistry()) + + def _request(self, *, participant_visibility: str | None = None) -> SchedulingRequest: + values = { + "tenant_id": "tenant-1", + "title": "Privacy review", + "status": "collecting", + "organizer_user_id": "organizer-1", + } + if participant_visibility is not None: + values["participant_visibility"] = participant_visibility + request = SchedulingRequest(**values) + request.participants = [ + SchedulingParticipant( + tenant_id="tenant-1", + respondent_id="alice-id", + display_name="Alice", + email="alice@example.test", + participant_type="internal", + status="responded", + metadata_={"private": "alice"}, + ), + SchedulingParticipant( + tenant_id="tenant-1", + respondent_id="bob-id", + display_name="Bob", + email="bob@example.test", + participant_type="external", + status="invited", + metadata_={"private": "bob"}, + ), + ] + self.session.add(request) + self.session.flush() + return request + + @staticmethod + def _participant_projection(request: SchedulingRequest) -> dict[str, object]: + return scheduling_request_response( + request, + actor_ids=("alice-id", "alice@example.test"), + actor_user_id="alice-account", + ) + + @staticmethod + def _configure_policy(provider: _PrivacyPolicy) -> None: + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="policy_test", + name="Policy test", + version="test", + capability_factories={ + CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: lambda context: provider, + }, + ) + ) + registry.configure_capability_context(ModuleContext(registry=registry, settings=object())) + configure_runtime(registry=registry) + + def test_secure_default_returns_own_row_and_aggregate_counts(self) -> None: + request = self._request() + + payload = self._participant_projection(request) + response = SchedulingRequestResponse.model_validate(payload) + + self.assertEqual(request.participant_visibility, "aggregates_only") + self.assertEqual(response.participant_visibility, "aggregates_only") + self.assertEqual(response.effective_participant_visibility, "aggregates_only") + self.assertEqual([participant.display_name for participant in response.participants], ["Alice"]) + self.assertEqual(response.participants[0].email, "alice@example.test") + self.assertEqual(response.participant_aggregate.total, 2) + self.assertEqual(response.participant_aggregate.status_counts["responded"], 1) + self.assertEqual(response.participant_aggregate.status_counts["invited"], 1) + + def test_configured_roster_returns_other_names_and_statuses_with_sensitive_fields_redacted(self) -> None: + request = self._request(participant_visibility="names_and_statuses") + + response = SchedulingRequestResponse.model_validate(self._participant_projection(request)) + + self.assertEqual(response.effective_participant_visibility, "names_and_statuses") + own = next(participant for participant in response.participants if participant.display_name == "Alice") + other = next(participant for participant in response.participants if participant.display_name == "Bob") + self.assertEqual(own.respondent_id, "alice-id") + self.assertEqual(own.email, "alice@example.test") + self.assertEqual(other.status, "invited") + self.assertIsNone(other.respondent_id) + self.assertIsNone(other.email) + self.assertIsNone(other.poll_invitation_id) + self.assertEqual(other.metadata, {}) + + def test_manager_and_organizer_bypass_participant_roster_restriction(self) -> None: + request = self._request() + + manager = SchedulingRequestResponse.model_validate( + scheduling_request_response( + request, + actor_ids=("manager-1",), + actor_user_id="manager-1", + can_manage=True, + ) + ) + organizer = SchedulingRequestResponse.model_validate( + scheduling_request_response( + request, + actor_ids=("organizer-1",), + actor_user_id="organizer-1", + ) + ) + + for response in (manager, organizer): + self.assertEqual(response.effective_participant_visibility, "names_and_statuses") + self.assertEqual({participant.email for participant in response.participants}, { + "alice@example.test", + "bob@example.test", + }) + self.assertTrue(response.participant_visibility_decision.details["management_access"]) + + def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None: + restricting_policy = _PrivacyPolicy("aggregates_only") + self._configure_policy(restricting_policy) + visible_request = self._request(participant_visibility="names_and_statuses") + + reduced = SchedulingRequestResponse.model_validate(self._participant_projection(visible_request)) + + self.assertEqual(reduced.effective_participant_visibility, "aggregates_only") + self.assertEqual([participant.display_name for participant in reduced.participants], ["Alice"]) + self.assertTrue(reduced.participant_visibility_decision.policy_applied) + self.assertEqual(reduced.participant_visibility_decision.reason, "Tenant participant privacy policy") + self.assertEqual(reduced.participant_visibility_decision.source_path[0]["path"], "tenant:tenant-1") + self.assertTrue(reduced.participant_visibility_decision.details["provider_session_available"]) + self.assertEqual(restricting_policy.requests[0].actor_user_id, "alice-account") + + widening_policy = _PrivacyPolicy("names_and_statuses") + self._configure_policy(widening_policy) + private_request = self._request() + + clamped = SchedulingRequestResponse.model_validate(self._participant_projection(private_request)) + + self.assertEqual(clamped.effective_participant_visibility, "aggregates_only") + self.assertEqual([participant.display_name for participant in clamped.participants], ["Alice"]) + self.assertEqual(widening_policy.requests[0].requested_visibility, "aggregates_only") + + def test_create_and_update_schemas_expose_the_configuration(self) -> None: + slot = SchedulingCandidateSlotInput.model_validate( + { + "start_at": "2026-07-20T09:00:00Z", + "end_at": "2026-07-20T10:00:00Z", + } + ) + + created = SchedulingRequestCreateRequest(title="Privacy", slots=[slot]) + updated = SchedulingRequestUpdateRequest(participant_visibility="names_and_statuses") + + self.assertEqual(created.participant_visibility, "aggregates_only") + self.assertEqual(updated.participant_visibility, "names_and_statuses") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service.py b/tests/test_service.py index ed8041c..210c959 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1047,6 +1047,7 @@ class SchedulingServiceTests(unittest.TestCase): "calendar": SchedulingCalendarPreferences(), "participants": participants, "result_visibility": "public", + "participant_visibility": "names_and_statuses", } ) request, _tokens = create_scheduling_request(