diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 8e5867d..7791f76 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -19,6 +19,7 @@ from govoplan_scheduling.backend.schemas import ( SchedulingDecisionRequest, SchedulingInvitationActionRequest, SchedulingInvitationActionResponse, + SchedulingInvitationRevokeRequest, SchedulingNotificationCreateRequest, SchedulingNotificationListResponse, SchedulingNotificationResponse, @@ -482,6 +483,7 @@ def api_issue_scheduling_participant_invitation( tenant_id=principal.tenant_id, request_id=request_id, participant_id=participant_id, + participant_revision=payload.participant_revision, action=payload.action, ) except SchedulingError as exc: @@ -543,6 +545,7 @@ def api_issue_scheduling_participant_invitation( def api_revoke_scheduling_participant_invitation( request_id: str, participant_id: str, + payload: SchedulingInvitationRevokeRequest, response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), @@ -558,6 +561,7 @@ def api_revoke_scheduling_participant_invitation( tenant_id=principal.tenant_id, request_id=request_id, participant_id=participant_id, + participant_revision=payload.participant_revision, ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index 525c289..e6de55a 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -523,6 +523,31 @@ class SchedulingInvitationActionRequest(BaseModel): model_config = ConfigDict(extra="forbid") action: Literal["copy", "send"] + participant_revision: str = Field( + min_length=64, + max_length=64, + pattern=r"^[0-9a-f]{64}$", + description=( + "Semantic revision from the participant management projection; " + "stale actions are rejected before rotating or delivering a link." + ), + ) + + +class SchedulingInvitationRevokeRequest(BaseModel): + """Revoke the link represented by one current participant projection.""" + + model_config = ConfigDict(extra="forbid") + + participant_revision: str = Field( + min_length=64, + max_length=64, + pattern=r"^[0-9a-f]{64}$", + description=( + "Semantic revision from the participant management projection; " + "stale revocations are rejected before changing access." + ), + ) class SchedulingInvitationActionResponse(BaseModel): diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 54d035b..1b8f889 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -2866,6 +2866,19 @@ def _participant_for_invitation_action( return participant +def _require_current_invitation_participant_revision( + participant: SchedulingParticipant, + *, + participant_revision: str, +) -> None: + """Reject a stale invitation command while the participant row is locked.""" + + if participant_revision != scheduling_participant_revision(participant): + raise SchedulingConflictError( + "Scheduling participant invitation changed; reload and try again" + ) + + def _new_public_invitation_expiry( request: SchedulingRequest, ) -> datetime | None: @@ -2904,6 +2917,7 @@ def issue_scheduling_participant_invitation( tenant_id: str, request_id: str, participant_id: str, + participant_revision: str, action: str, ) -> SchedulingInvitationActionResult: """Rotate one bearer link for explicit copy or immediate delivery. @@ -2925,6 +2939,10 @@ def issue_scheduling_participant_invitation( request, participant_id=participant_id, ) + _require_current_invitation_participant_revision( + participant, + participant_revision=participant_revision, + ) if request.poll_id is None: raise SchedulingError("Scheduling invitation action is unavailable") participation_provider = _poll_participation_provider() @@ -3016,8 +3034,9 @@ def revoke_scheduling_participant_invitation( tenant_id: str, request_id: str, participant_id: str, + participant_revision: str, ) -> SchedulingInvitationActionResult: - """Immediately revoke the current link; exact repeats are safe no-ops.""" + """Revoke the current link; refreshed no-link projections replay safely.""" request = _lock_scheduling_request( session, @@ -3029,6 +3048,10 @@ def revoke_scheduling_participant_invitation( request, participant_id=participant_id, ) + _require_current_invitation_participant_revision( + participant, + participant_revision=participant_revision, + ) invitation_id = participant.poll_invitation_id replayed = invitation_id is None if invitation_id is not None: diff --git a/tests/test_response_editing.py b/tests/test_response_editing.py index 2d95615..9ff7e27 100644 --- a/tests/test_response_editing.py +++ b/tests/test_response_editing.py @@ -214,6 +214,7 @@ class SchedulingResponseEditingTests(unittest.TestCase): tenant_id=request.tenant_id, request_id=request.id, participant_id=participant.id, + participant_revision=scheduling_participant_revision(participant), action="copy", ) self.assertIsNotNone(result.action_url) @@ -1753,6 +1754,7 @@ class SchedulingResponseEditingTests(unittest.TestCase): tenant_id=request.tenant_id, request_id=request.id, participant_id=participant.id, + participant_revision=scheduling_participant_revision(participant), action="copy", ) diff --git a/tests/test_service.py b/tests/test_service.py index 628e98e..c231f1f 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -43,6 +43,7 @@ from govoplan_scheduling.backend.schemas import ( SchedulingCandidateSlotInput, SchedulingDecisionRequest, SchedulingInvitationActionRequest, + SchedulingInvitationRevokeRequest, SchedulingParticipantInput, SchedulingPublicParticipationAccessRequest, SchedulingPublicParticipationSubmitRequest, @@ -82,6 +83,7 @@ from govoplan_scheduling.backend.service import ( open_scheduling_request, require_visible_scheduling_results, scheduling_request_summary, + scheduling_participant_revision, scheduling_slot_revision, submit_public_scheduling_participation, update_scheduling_request, @@ -222,6 +224,7 @@ class SchedulingServiceTests(unittest.TestCase): tenant_id=request.tenant_id, request_id=request.id, participant_id=participant.id, + participant_revision=scheduling_participant_revision(participant), action="copy", ) self.assertIsNotNone(result.action_url) @@ -1517,6 +1520,7 @@ class SchedulingServiceTests(unittest.TestCase): tenant_id=request.tenant_id, request_id=request.id, participant_id=participant.id, + participant_revision=scheduling_participant_revision(participant), action="send", ) for participant in request.participants @@ -1576,6 +1580,7 @@ class SchedulingServiceTests(unittest.TestCase): tenant_id=request.tenant_id, request_id=request.id, participant_id=participant.id, + participant_revision=scheduling_participant_revision(participant), action="send", ) @@ -1625,6 +1630,7 @@ class SchedulingServiceTests(unittest.TestCase): tenant_id=request.tenant_id, request_id=request.id, participant_id=participant.id, + participant_revision=scheduling_participant_revision(participant), action="send", ) @@ -1644,6 +1650,142 @@ class SchedulingServiceTests(unittest.TestCase): request.id, ) + def test_stale_copy_is_rejected_before_rotating_invitation(self) -> None: + request, _automatic_tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy( + update={"calendar": SchedulingCalendarPreferences()} + ), + ) + participant = request.participants[0] + stale_revision = scheduling_participant_revision(participant) + self._issue_copy(request, participant) + invitation_id = participant.poll_invitation_id + organizer = self._principal("user-1", scopes={SCHEDULING_WRITE_SCOPE}) + + with ( + patch("govoplan_scheduling.backend.router.audit_event") as audit, + self.assertRaises(HTTPException) as conflict, + ): + api_issue_scheduling_participant_invitation( + request.id, + participant.id, + SchedulingInvitationActionRequest( + action="copy", + participant_revision=stale_revision, + ), + Response(), + session=self.session, + principal=organizer, + ) + + self.assertEqual(conflict.exception.status_code, 409) + self.assertEqual(participant.poll_invitation_id, invitation_id) + invitation = self.session.query(PollInvitation).filter( + PollInvitation.id == invitation_id + ).one() + self.assertIsNone(invitation.revoked_at) + audit.assert_not_called() + + def test_stale_send_is_rejected_before_delivery_or_rotation(self) -> None: + class CapturingNotificationProvider: + def __init__(self) -> None: + self.requests = [] + + def enqueue_notification(self, _session, request, *, enqueue_delivery): + self.requests.append(request) + return {"id": "notification-1", "status": "queued"} + + provider = CapturingNotificationProvider() + request, _automatic_tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy( + update={"calendar": SchedulingCalendarPreferences()} + ), + ) + participant = request.participants[0] + stale_revision = scheduling_participant_revision(participant) + self._issue_copy(request, participant) + invitation_id = participant.poll_invitation_id + organizer = self._principal("user-1", scopes={SCHEDULING_WRITE_SCOPE}) + + with ( + patch( + "govoplan_scheduling.backend.service.notification_dispatch_provider", + return_value=provider, + ), + patch("govoplan_scheduling.backend.router.audit_event") as audit, + self.assertRaises(HTTPException) as conflict, + ): + api_issue_scheduling_participant_invitation( + request.id, + participant.id, + SchedulingInvitationActionRequest( + action="send", + participant_revision=stale_revision, + ), + Response(), + session=self.session, + principal=organizer, + ) + + self.assertEqual(conflict.exception.status_code, 409) + self.assertEqual(participant.poll_invitation_id, invitation_id) + invitation = self.session.query(PollInvitation).filter( + PollInvitation.id == invitation_id + ).one() + self.assertIsNone(invitation.revoked_at) + self.assertEqual(provider.requests, []) + self.assertEqual( + self.session.query(SchedulingNotification).filter( + SchedulingNotification.request_id == request.id + ).count(), + 0, + ) + audit.assert_not_called() + + def test_stale_revoke_is_rejected_before_changing_access(self) -> None: + request, _automatic_tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy( + update={"calendar": SchedulingCalendarPreferences()} + ), + ) + participant = request.participants[0] + stale_revision = scheduling_participant_revision(participant) + self._issue_copy(request, participant) + invitation_id = participant.poll_invitation_id + organizer = self._principal("user-1", scopes={SCHEDULING_WRITE_SCOPE}) + + with ( + patch("govoplan_scheduling.backend.router.audit_event") as audit, + self.assertRaises(HTTPException) as conflict, + ): + api_revoke_scheduling_participant_invitation( + request.id, + participant.id, + SchedulingInvitationRevokeRequest( + participant_revision=stale_revision, + ), + Response(), + session=self.session, + principal=organizer, + ) + + self.assertEqual(conflict.exception.status_code, 409) + self.assertEqual(participant.poll_invitation_id, invitation_id) + invitation = self.session.query(PollInvitation).filter( + PollInvitation.id == invitation_id + ).one() + self.assertIsNone(invitation.revoked_at) + audit.assert_not_called() + def test_invitation_router_rotates_revokes_and_enforces_management_policy(self) -> None: request, automatic_tokens = create_scheduling_request( self.session, @@ -1663,7 +1805,10 @@ class SchedulingServiceTests(unittest.TestCase): first = api_issue_scheduling_participant_invitation( request.id, participant.id, - SchedulingInvitationActionRequest(action="copy"), + SchedulingInvitationActionRequest( + action="copy", + participant_revision=scheduling_participant_revision(participant), + ), response_headers, session=self.session, principal=organizer, @@ -1688,7 +1833,10 @@ class SchedulingServiceTests(unittest.TestCase): api_issue_scheduling_participant_invitation( request.id, participant.id, - SchedulingInvitationActionRequest(action="copy"), + SchedulingInvitationActionRequest( + action="copy", + participant_revision=scheduling_participant_revision(participant), + ), Response(), session=self.session, principal=unrelated_writer, @@ -1713,7 +1861,10 @@ class SchedulingServiceTests(unittest.TestCase): rotated = api_issue_scheduling_participant_invitation( request.id, participant.id, - SchedulingInvitationActionRequest(action="copy"), + SchedulingInvitationActionRequest( + action="copy", + participant_revision=scheduling_participant_revision(participant), + ), Response(), session=self.session, principal=administrator, @@ -1737,6 +1888,9 @@ class SchedulingServiceTests(unittest.TestCase): revoked = api_revoke_scheduling_participant_invitation( request.id, participant.id, + SchedulingInvitationRevokeRequest( + participant_revision=scheduling_participant_revision(participant), + ), Response(), session=self.session, principal=organizer, @@ -1744,6 +1898,9 @@ class SchedulingServiceTests(unittest.TestCase): replayed = api_revoke_scheduling_participant_invitation( request.id, participant.id, + SchedulingInvitationRevokeRequest( + participant_revision=scheduling_participant_revision(participant), + ), Response(), session=self.session, principal=organizer, @@ -1793,7 +1950,12 @@ class SchedulingServiceTests(unittest.TestCase): issued = api_issue_scheduling_participant_invitation( request.id, request.participants[0].id, - SchedulingInvitationActionRequest(action="copy"), + SchedulingInvitationActionRequest( + action="copy", + participant_revision=scheduling_participant_revision( + request.participants[0] + ), + ), Response(), session=self.session, principal=organizer, @@ -1838,7 +2000,10 @@ class SchedulingServiceTests(unittest.TestCase): result = api_issue_scheduling_participant_invitation( request.id, participant.id, - SchedulingInvitationActionRequest(action="send"), + SchedulingInvitationActionRequest( + action="send", + participant_revision=scheduling_participant_revision(participant), + ), Response(), session=self.session, principal=organizer,