diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index 84541ac..8253427 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -95,6 +95,9 @@ from govoplan_campaign.backend.db.models import ( CampaignVersion, CampaignVersionWorkflowState, ImapAppendAttempt, + JobImapStatus, + JobQueueStatus, + JobSendStatus, RecipientImportMappingProfile, SendAttempt, ) @@ -358,7 +361,11 @@ def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None: def _require_mail_profile_use_if_needed(principal: ApiPrincipal, raw_json: dict[str, object] | None) -> None: if _campaign_mail_profile_id(raw_json) and not has_scope(principal, "mail:profile:use"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:use") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail:profile:use") + + +def _campaign_response_context(principal: ApiPrincipal) -> dict[str, bool]: + return {"include_diagnostics": has_scope(principal, "campaigns:diagnostic:read")} def _campaign_version_detail_response( @@ -458,6 +465,23 @@ def _require_campaign_profile_use_if_needed( _require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {}) +def _require_campaign_versions_profile_use( + session: Session, + principal: ApiPrincipal, + campaign_id: str, + version_ids: set[str], +) -> None: + """Authorize every historical version affected by a campaign-wide action.""" + + for version_id in sorted(version_ids): + _require_campaign_profile_use_if_needed( + session, + principal, + campaign_id, + version_id, + ) + + def _get_version_for_principal( session: Session, version_id: str, @@ -3200,6 +3224,18 @@ def resume_campaign( principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), ): _get_campaign_for_principal(session, campaign_id, principal, write=True) + version_ids = { + row[0] + for row in session.query(CampaignJob.campaign_version_id) + .filter( + CampaignJob.tenant_id == principal.tenant_id, + CampaignJob.campaign_id == campaign_id, + CampaignJob.queue_status == JobQueueStatus.PAUSED.value, + ) + .distinct() + .all() + } + _require_campaign_versions_profile_use(session, principal, campaign_id, version_ids) try: result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True) @@ -3232,7 +3268,23 @@ def append_sent( ): _get_campaign_for_principal(session, campaign_id, principal, write=True) payload = payload or AppendSentRequest() - _require_campaign_profile_use_if_needed(session, principal, campaign_id) + version_ids = { + row[0] + for row in session.query(CampaignJob.campaign_version_id) + .filter( + CampaignJob.tenant_id == principal.tenant_id, + CampaignJob.campaign_id == campaign_id, + CampaignJob.send_status.in_( + [JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value] + ), + CampaignJob.imap_status.in_( + [JobImapStatus.PENDING.value, JobImapStatus.FAILED.value] + ), + ) + .distinct() + .all() + } + _require_campaign_versions_profile_use(session, principal, campaign_id, version_ids) try: result = enqueue_pending_imap_appends( session, diff --git a/tests/test_mail_profile_boundary.py b/tests/test_mail_profile_boundary.py index 4e04b79..b1a1780 100644 --- a/tests/test_mail_profile_boundary.py +++ b/tests/test_mail_profile_boundary.py @@ -1,7 +1,7 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import call, patch import pytest from fastapi import HTTPException @@ -228,3 +228,21 @@ def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None: assert captured.value.status_code == 403 fork.assert_not_called() + + +def test_campaign_wide_effect_authorizes_every_affected_version() -> None: + session = object() + principal = SimpleNamespace(tenant_id="tenant-1") + + with patch.object(router, "_require_campaign_profile_use_if_needed") as require_profile: + router._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test + session, # type: ignore[arg-type] + principal, # type: ignore[arg-type] + "campaign-1", + {"version-2", "version-1"}, + ) + + assert require_profile.call_args_list == [ + call(session, principal, "campaign-1", "version-1"), + call(session, principal, "campaign-1", "version-2"), + ]