security: authorize every Campaign effect version

This commit is contained in:
2026-07-21 17:56:47 +02:00
parent 9a709b1264
commit 60776803c5
2 changed files with 73 additions and 3 deletions

View File

@@ -95,6 +95,9 @@ from govoplan_campaign.backend.db.models import (
CampaignVersion, CampaignVersion,
CampaignVersionWorkflowState, CampaignVersionWorkflowState,
ImapAppendAttempt, ImapAppendAttempt,
JobImapStatus,
JobQueueStatus,
JobSendStatus,
RecipientImportMappingProfile, RecipientImportMappingProfile,
SendAttempt, 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: 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"): 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( 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 {}) _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( def _get_version_for_principal(
session: Session, session: Session,
version_id: str, version_id: str,
@@ -3200,6 +3224,18 @@ def resume_campaign(
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
): ):
_get_campaign_for_principal(session, campaign_id, principal, write=True) _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: try:
result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) 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) 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) _get_campaign_for_principal(session, campaign_id, principal, write=True)
payload = payload or AppendSentRequest() 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: try:
result = enqueue_pending_imap_appends( result = enqueue_pending_imap_appends(
session, session,

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import call, patch
import pytest import pytest
from fastapi import HTTPException 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 assert captured.value.status_code == 403
fork.assert_not_called() 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"),
]