security: harden Campaign delivery effects and reconciliation
This commit is contained in:
@@ -27,10 +27,14 @@ from govoplan_campaign.backend.db.models import (
|
||||
JobValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json, validate_against_schema
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
assert_campaign_uses_mail_profile_reference,
|
||||
campaign_mail_profile_id,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
||||
from govoplan_campaign.backend.sending.execution import create_execution_snapshot
|
||||
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_transport_revisions
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||
@@ -61,15 +65,26 @@ def load_campaign_config_from_json(
|
||||
owner_user_id: str | None = None,
|
||||
owner_group_id: str | None = None,
|
||||
) -> CampaignConfig:
|
||||
materialized = mail_integration().materialize_campaign_mail_profile_config(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json=raw_json,
|
||||
campaign_id=campaign_id,
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
validate_against_schema(materialized)
|
||||
# Validate the persisted Campaign-to-Mail contract before asking Mail for a
|
||||
# non-secret capability summary. Campaign never receives resolved transport
|
||||
# settings, account identities, or credentials.
|
||||
assert_campaign_uses_mail_profile_reference(raw_json)
|
||||
validate_against_schema(raw_json)
|
||||
materialized = copy.deepcopy(raw_json)
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
if profile_id:
|
||||
summary = mail_integration().campaign_profile_delivery_summary(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
materialized.setdefault("server", {})["profile_capabilities"] = {
|
||||
"smtp_available": bool(summary.get("smtp_available")),
|
||||
"imap_available": bool(summary.get("imap_available")),
|
||||
}
|
||||
return CampaignConfig.model_validate(materialized)
|
||||
|
||||
|
||||
@@ -485,13 +500,19 @@ def build_campaign_version(
|
||||
[job for job, _message in job_build_pairs],
|
||||
stage="built",
|
||||
)
|
||||
runtime_smtp = managed_config.server.runtime_smtp_config()
|
||||
if not runtime_smtp:
|
||||
raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created")
|
||||
if not managed_config.server.profile_capabilities.smtp_available:
|
||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created")
|
||||
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
if profile_id is None:
|
||||
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages")
|
||||
revisions = profile_transport_revisions(session, version)
|
||||
if not revisions["smtp"]:
|
||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision")
|
||||
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
|
||||
version,
|
||||
smtp=runtime_smtp,
|
||||
imap=managed_config.server.runtime_imap_config(),
|
||||
mail_profile_id=profile_id,
|
||||
smtp_transport_revision=revisions["smtp"],
|
||||
imap_transport_revision=revisions["imap"],
|
||||
delivery=managed_config.delivery,
|
||||
jobs=[job for job, _message in job_build_pairs],
|
||||
build_summary=report_json,
|
||||
|
||||
@@ -19,6 +19,12 @@ from govoplan_campaign.backend.db.models import (
|
||||
JobSendStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
campaign_mail_profile_boundary_violations,
|
||||
campaign_mail_profile_id,
|
||||
assert_campaign_uses_mail_profile_reference,
|
||||
public_campaign_mail_server,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
CampaignPersistenceError,
|
||||
@@ -106,25 +112,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
|
||||
},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"inherit_smtp_credentials": True,
|
||||
"inherit_imap_credentials": True,
|
||||
"smtp": {
|
||||
"host": "",
|
||||
"port": 587,
|
||||
"security": "starttls",
|
||||
},
|
||||
"imap": {
|
||||
"host": "",
|
||||
"port": 993,
|
||||
"security": "tls",
|
||||
"sent_folder": "auto",
|
||||
},
|
||||
"credentials": {
|
||||
"smtp": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
|
||||
"imap": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
|
||||
},
|
||||
},
|
||||
"server": {},
|
||||
"recipients": {
|
||||
"from": [],
|
||||
"allow_individual_from": False,
|
||||
@@ -347,6 +335,7 @@ def fork_campaign_version_for_edit(
|
||||
source_filename: str | None = None,
|
||||
source_base_path: str | None = None,
|
||||
autosave: bool = True,
|
||||
migrate_legacy_mail_settings: bool = False,
|
||||
) -> CampaignVersion:
|
||||
"""Create the next sole working version from immutable campaign history.
|
||||
|
||||
@@ -371,7 +360,16 @@ def fork_campaign_version_for_edit(
|
||||
"Create the next working copy from the campaign's current immutable version."
|
||||
)
|
||||
|
||||
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
|
||||
source_json = source.raw_json if isinstance(source.raw_json, dict) else {}
|
||||
source_requires_mail_migration = bool(campaign_mail_profile_boundary_violations(source_json))
|
||||
if source_requires_mail_migration and not migrate_legacy_mail_settings:
|
||||
raise CampaignPersistenceError(
|
||||
"This version contains legacy campaign-local SMTP/IMAP settings. Create the editable copy from "
|
||||
"the Mail settings migration action so the audit record is preserved and the copy uses a Mail profile."
|
||||
)
|
||||
base_json = raw_json if raw_json is not None else copy.deepcopy(source_json)
|
||||
if source_requires_mail_migration and raw_json is None:
|
||||
base_json["server"] = public_campaign_mail_server(source_json)
|
||||
assert_server_safe_campaign_paths(
|
||||
base_json,
|
||||
source_filename=source_filename,
|
||||
@@ -379,6 +377,7 @@ def fork_campaign_version_for_edit(
|
||||
managed_files_available=files_integration().available,
|
||||
)
|
||||
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
|
||||
assert_campaign_uses_mail_profile_reference(runtime_json)
|
||||
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
||||
|
||||
new_version = CampaignVersion(
|
||||
@@ -528,6 +527,7 @@ def update_campaign_version(
|
||||
source_filename: str | None = None,
|
||||
source_base_path: str | None = None,
|
||||
autosave: bool = False,
|
||||
migrate_legacy_mail_settings: bool = False,
|
||||
) -> CampaignVersion:
|
||||
if raw_json is not None or source_filename is not None or source_base_path is not None:
|
||||
assert_server_safe_campaign_paths(
|
||||
@@ -547,6 +547,18 @@ def update_campaign_version(
|
||||
|
||||
if raw_json is not None:
|
||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
||||
if campaign_mail_profile_boundary_violations(version.raw_json) and not migrate_legacy_mail_settings:
|
||||
raise CampaignPersistenceError(
|
||||
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
|
||||
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
|
||||
"will not be changed automatically."
|
||||
)
|
||||
assert_campaign_uses_mail_profile_reference(runtime_json)
|
||||
if campaign_mail_profile_boundary_violations(version.raw_json) and campaign_mail_profile_id(runtime_json) is None:
|
||||
raise CampaignPersistenceError(
|
||||
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
|
||||
"Select a Mail profile before saving."
|
||||
)
|
||||
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
||||
version.raw_json = runtime_json
|
||||
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
||||
@@ -839,6 +851,7 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
|
||||
_validate_partial_basics(collector, _dict_value(raw_json, "campaign"))
|
||||
recipients = _dict_value(raw_json, "recipients")
|
||||
_validate_partial_sender(collector, recipients)
|
||||
_validate_partial_mail_profile(collector, raw_json)
|
||||
_validate_partial_recipients(collector, _dict_value(raw_json, "entries"))
|
||||
_validate_partial_template(collector, _dict_value(raw_json, "template"))
|
||||
_validate_partial_attachments(collector, _dict_value(raw_json, "attachments"))
|
||||
@@ -864,6 +877,26 @@ def _validate_partial_sender(collector: _PartialValidationCollector, recipients:
|
||||
collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
|
||||
|
||||
|
||||
def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_json: dict[str, Any]) -> None:
|
||||
violations = campaign_mail_profile_boundary_violations(raw_json)
|
||||
if violations:
|
||||
collector.issue(
|
||||
"error",
|
||||
"sender",
|
||||
"server",
|
||||
"campaign_local_mail_transport_forbidden",
|
||||
"Campaign-local SMTP/IMAP settings are not supported. Select or migrate to an authorized Mail-module profile.",
|
||||
)
|
||||
elif campaign_mail_profile_id(raw_json) is None:
|
||||
collector.issue(
|
||||
"warning",
|
||||
"sender",
|
||||
"server.mail_profile_id",
|
||||
"missing_mail_profile",
|
||||
"Select an authorized Mail-module profile before validating or delivering this campaign.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
|
||||
has_inline = bool(entries.get("inline"))
|
||||
has_source = isinstance(entries.get("source"), dict)
|
||||
|
||||
Reference in New Issue
Block a user