feat: enforce Mail-owned campaign transport boundary

This commit is contained in:
2026-07-21 17:11:48 +02:00
parent a86a779d5b
commit 88d5012dea
18 changed files with 1548 additions and 260 deletions

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import copy
import fnmatch
import json
import re
@@ -11,6 +10,7 @@ from sqlalchemy import and_, or_, text
from sqlalchemy.orm import Session
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
@@ -19,7 +19,7 @@ from govoplan_core.core.campaigns import (
)
from govoplan_core.core.policy import policy_source_step
from govoplan_core.core.runtime import get_registry
from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerProfile
from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerProfile, new_uuid
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
@@ -961,89 +961,44 @@ def _credential_policy_for_protocol(policy: EffectiveMailProfilePolicy, protocol
raise MailProfileError("Credential policy protocol must be smtp or imap")
def _legacy_profile_credentials_inherited(server: dict[str, Any], protocol: str) -> bool:
return server.get(f"inherit_{protocol}_credentials") is not False
def _local_credential_inheritance_override_allowed(policy: EffectiveMailProfilePolicy, protocol: str) -> bool:
credential_policy = _credential_policy_for_protocol(policy, protocol)
return (
credential_policy.inherit_source != "campaign"
and policy.allow_lower_level_limits.get(f"{protocol}_credentials.inherit", True)
)
def profile_credentials_inherited_for_server(policy: EffectiveMailProfilePolicy, server: dict[str, Any], protocol: str) -> bool:
credential_policy = _credential_policy_for_protocol(policy, protocol)
legacy_key = f"inherit_{protocol}_credentials"
if legacy_key in server and _local_credential_inheritance_override_allowed(policy, protocol):
return _legacy_profile_credentials_inherited(server, protocol)
return credential_policy.inherit
def effective_profile_credentials_inherited(
session: Session,
*,
tenant_id: str,
server: dict[str, Any],
protocol: str,
campaign_id: str | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
) -> bool:
policy = effective_mail_profile_policy(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
return profile_credentials_inherited_for_server(policy, server, protocol)
def _profile_has_password(profile: MailServerProfile, protocol: str) -> bool:
if protocol == "smtp":
return bool(profile.smtp_password_encrypted)
if protocol == "imap":
return bool(profile.imap_password_encrypted)
return False
def _profile_has_transport(profile: MailServerProfile, protocol: str) -> bool:
if protocol == "smtp":
return True
return bool(profile.imap_config)
def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, policy: EffectiveMailProfilePolicy, server: dict[str, Any]) -> None:
_CAMPAIGN_MAIL_REFERENCE_KEYS = frozenset({"mail_profile_id"})
def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
unexpected = sorted(key for key in server if key not in _CAMPAIGN_MAIL_REFERENCE_KEYS)
if unexpected:
paths = ", ".join(f"server.{key}" for key in unexpected)
raise MailProfileError(
"Campaign JSON may only reference a Mail-owned profile through server.mail_profile_id; "
f"remove campaign-local SMTP/IMAP settings ({paths}), select a Mail profile, and save a new campaign version."
)
value = server.get("mail_profile_id")
if value is None:
return None
if not isinstance(value, str) or not value.strip():
raise MailProfileError("server.mail_profile_id must be a non-empty Mail profile identifier")
return value.strip()
def _assert_campaign_inherits_profile_credentials(
profile: MailServerProfile,
policy: EffectiveMailProfilePolicy,
) -> None:
for protocol in ("smtp", "imap"):
if not _profile_has_transport(profile, protocol):
continue
credential_policy = _credential_policy_for_protocol(policy, protocol)
legacy_key = f"inherit_{protocol}_credentials"
if legacy_key in server:
legacy_inherit = _legacy_profile_credentials_inherited(server, protocol)
if not _local_credential_inheritance_override_allowed(policy, protocol) and legacy_inherit != credential_policy.inherit:
raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by the effective mail profile policy")
inherited = profile_credentials_inherited_for_server(policy, server, protocol)
username, password = _transport_credentials_from_server(server, protocol)
if inherited and (username or password):
raise MailProfileError(f"{protocol.upper()} credentials must not be stored on the campaign while inherited profile credentials are enforced")
if not inherited and _profile_has_password(profile, protocol) and not password:
raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy")
def _inline_transport_configured(server: dict[str, Any], protocol: str) -> bool:
value = server.get(protocol) if isinstance(server.get(protocol), dict) else {}
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
credential_value = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
for key in ("host",):
if str(value.get(key) or "").strip():
return True
for key in ("username", "password"):
if str(credential_value.get(key) or value.get(key) or "").strip():
return True
return False
if not _credential_policy_for_protocol(policy, protocol).inherit:
raise MailProfileError(
f"Campaign delivery cannot use the selected profile because the effective {protocol.upper()} "
"credential policy requires campaign-local credentials. Store the credentials on a Mail profile "
"and change the policy to inherit them."
)
def assert_campaign_mail_policy_allows_json(
@@ -1057,7 +1012,7 @@ def assert_campaign_mail_policy_allows_json(
) -> None:
data = raw_json if isinstance(raw_json, dict) else {}
server = data.get("server") if isinstance(data.get("server"), dict) else {}
profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
profile_id = _campaign_mail_profile_reference_id(server)
if profile_id:
if campaign_id:
profile = ensure_mail_profile_allowed_for_campaign(
@@ -1068,7 +1023,7 @@ def assert_campaign_mail_policy_allows_json(
require_active=True,
)
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
_assert_profile_credentials_allowed_for_server(profile, policy, server)
_assert_campaign_inherits_profile_credentials(profile, policy)
return
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
policy = effective_mail_profile_policy(
@@ -1085,25 +1040,9 @@ def assert_campaign_mail_policy_allows_json(
owner_group_id=owner_group_id,
):
raise MailProfileError("Mail-server profile is not allowed by the effective policy")
_assert_profile_credentials_allowed_for_server(profile, policy, server)
_assert_campaign_inherits_profile_credentials(profile, policy)
return
smtp = server.get("smtp") if isinstance(server, dict) and isinstance(server.get("smtp"), dict) else None
imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None
if campaign_id:
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
if not policy.allow_campaign_profiles and (_inline_transport_configured(server, "smtp") or _inline_transport_configured(server, "imap")):
raise MailProfileError("Campaign-local inline mail settings are disabled by policy")
_assert_transport_values_allowed(policy, smtp, imap)
return
assert_mail_policy_allows_transport(
session,
tenant_id=tenant_id,
smtp=smtp,
imap=imap,
campaign_id=campaign_id,
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
return
def create_mail_server_profile(
@@ -1174,6 +1113,7 @@ def update_mail_server_profile(
profile: MailServerProfile,
*,
user_id: str | None,
api_key_id: str | None = None,
tenant_id: str,
name: str | None = None,
slug: str | None = None,
@@ -1205,7 +1145,15 @@ def update_mail_server_profile(
next_smtp, next_imap = _next_profile_transport_state(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
_assert_profile_transport_allowed(session, tenant_id=tenant_id, profile=profile, smtp=next_smtp, imap=next_imap)
_apply_profile_transport_update(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
_apply_profile_transport_update(
session,
profile,
user_id=user_id,
api_key_id=api_key_id,
smtp=smtp,
imap=imap,
clear_imap=clear_imap,
)
profile.updated_by_user_id = user_id
session.add(profile)
session.flush()
@@ -1248,36 +1196,148 @@ def _assert_profile_transport_allowed(
def _apply_profile_transport_update(
session: Session,
profile: MailServerProfile,
*,
user_id: str | None,
api_key_id: str | None,
smtp: SmtpConfig | None,
imap: ImapConfig | None,
clear_imap: bool,
) -> None:
if smtp is not None:
_apply_profile_transport_payload(profile, "smtp", smtp)
_apply_profile_transport_payload(
session,
profile,
"smtp",
smtp,
user_id=user_id,
api_key_id=api_key_id,
)
if clear_imap:
had_imap_identity = bool(
profile.imap_config
or profile.imap_username
or profile.imap_password_encrypted
)
_audit_profile_credential_mutation(
session,
profile=profile,
protocol="imap",
password_supplied=True,
next_password=None,
user_id=user_id,
api_key_id=api_key_id,
)
profile.imap_config = None
profile.imap_username = None
profile.imap_password_encrypted = None
if had_imap_identity:
profile.imap_transport_revision = new_uuid()
elif imap is not None:
_apply_profile_transport_payload(profile, "imap", imap)
_apply_profile_transport_payload(
session,
profile,
"imap",
imap,
user_id=user_id,
api_key_id=api_key_id,
)
def _apply_profile_transport_payload(profile: MailServerProfile, protocol: str, config: SmtpConfig | ImapConfig) -> None:
def _apply_profile_transport_payload(
session: Session,
profile: MailServerProfile,
protocol: str,
config: SmtpConfig | ImapConfig,
*,
user_id: str | None,
api_key_id: str | None,
) -> None:
payload, username, password, username_supplied, password_supplied = _transport_payload(config)
current_payload = dict(profile.smtp_config or {}) if protocol == "smtp" else dict(profile.imap_config or {})
for field_name in ("username", "password", "enabled"):
current_payload.pop(field_name, None)
current_username = _profile_username(profile, protocol)
current_password_encrypted = (
profile.smtp_password_encrypted
if protocol == "smtp"
else profile.imap_password_encrypted
)
current_password = decrypt_secret(current_password_encrypted) if password_supplied else None
identity_changed = (
current_payload != payload
or (username_supplied and current_username != username)
)
_audit_profile_credential_mutation(
session,
profile=profile,
protocol=protocol,
password_supplied=password_supplied and current_password != password,
next_password=password,
user_id=user_id,
api_key_id=api_key_id,
)
if protocol == "smtp":
profile.smtp_config = payload
if username_supplied:
profile.smtp_username = username
if password_supplied:
if password_supplied and current_password != password:
profile.smtp_password_encrypted = encrypt_secret(password)
if identity_changed:
profile.smtp_transport_revision = new_uuid()
return
profile.imap_config = payload
if username_supplied:
profile.imap_username = username
if password_supplied:
if password_supplied and current_password != password:
profile.imap_password_encrypted = encrypt_secret(password)
if identity_changed:
profile.imap_transport_revision = new_uuid()
def _audit_profile_credential_mutation(
session: Session,
*,
profile: MailServerProfile,
protocol: str,
password_supplied: bool,
next_password: str | None,
user_id: str | None,
api_key_id: str | None,
) -> None:
encrypted = (
profile.smtp_password_encrypted
if protocol == "smtp"
else profile.imap_password_encrypted
)
if not password_supplied or not encrypted:
return
replacing = next_password not in (None, "")
audit_event(
session,
tenant_id=profile.tenant_id,
scope="system" if profile.scope_type == "system" else "tenant",
user_id=user_id,
api_key_id=api_key_id,
action=(
"mail.profile_credentials_replaced"
if replacing
else "mail.profile_credentials_deleted"
),
object_type="mail_server_profile",
object_id=profile.id,
details={
"scope_type": profile.scope_type,
"scope_id": profile.scope_id,
"protocol": protocol,
"reason": "profile_updated",
},
)
# Force the non-secret audit record to durable storage before replacing or
# clearing the in-memory ciphertext. The surrounding transaction keeps the
# two changes atomic at commit time.
session.flush()
def _profile_username(profile: MailServerProfile, protocol: str) -> str | None:
@@ -1307,28 +1367,100 @@ def imap_config_from_profile(profile: MailServerProfile) -> ImapConfig | None:
return ImapConfig.model_validate(payload)
def inherit_profile_credentials(server: dict[str, Any], protocol: str) -> bool:
return _legacy_profile_credentials_inherited(server, protocol)
def campaign_profile_transport_revisions(profile: MailServerProfile) -> dict[str, str | None]:
"""Return Mail-owned opaque revisions without exposing transport identity.
Revisions are random persisted values, backfilled by migration and rotated
whenever the corresponding transport or account identity changes.
They cannot be dictionary-tested to recover a host or account name.
"""
smtp_revision = str(getattr(profile, "smtp_transport_revision", "") or "")
if not smtp_revision:
raise MailProfileError("Mail profile has no SMTP transport revision; run the Mail database migrations")
imap_revision = None
if profile.imap_config:
imap_revision = str(getattr(profile, "imap_transport_revision", "") or "")
if not imap_revision:
raise MailProfileError("Mail profile has no IMAP transport revision; run the Mail database migrations")
return {"smtp": smtp_revision, "imap": imap_revision}
def _transport_credentials_from_server(server: dict[str, Any], protocol: str) -> tuple[str | None, str | None]:
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
config = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else None
legacy_config = server.get(protocol) if isinstance(server.get(protocol), dict) else None
username = config.get("username") if config is not None else None
password = config.get("password") if config is not None else None
if username in (None, "") and legacy_config is not None:
username = legacy_config.get("username")
if password in (None, "") and legacy_config is not None:
password = legacy_config.get("password")
return (str(username) if username not in (None, "") else None, str(password) if password not in (None, "") else None)
def delete_mail_profile_credentials(
session: Session,
*,
profile: MailServerProfile,
deletion_reason: str,
user_id: str | None = None,
api_key_id: str | None = None,
) -> tuple[str, ...]:
"""Immediately scrub Mail-owned profile secrets and record the deletion.
The audit record contains only protocol names and non-secret profile scope
metadata. Any audit/storage failure aborts the caller's transaction, so a
profile cannot appear deleted while its encrypted passwords remain.
"""
deleted_protocols = tuple(
protocol
for protocol, encrypted in (
("smtp", profile.smtp_password_encrypted),
("imap", profile.imap_password_encrypted),
)
if encrypted
)
if not deleted_protocols:
return ()
audit_event(
session,
tenant_id=profile.tenant_id,
scope="system" if profile.scope_type == "system" else "tenant",
user_id=user_id,
api_key_id=api_key_id,
action="mail.profile_credentials_deleted",
object_type="mail_server_profile",
object_id=profile.id,
details={
"scope_type": profile.scope_type,
"scope_id": profile.scope_id,
"deleted_protocols": list(deleted_protocols),
"deletion_reason": deletion_reason,
},
)
# Fail before mutating the ORM object if the audit record cannot be stored.
session.flush()
profile.smtp_password_encrypted = None
profile.imap_password_encrypted = None
session.add(profile)
session.flush()
return deleted_protocols
def apply_campaign_credentials(payload: dict[str, Any], server: dict[str, Any], protocol: str) -> dict[str, Any]:
username, password = _transport_credentials_from_server(server, protocol)
payload["username"] = username
payload["password"] = password
return payload
def delete_mail_profile_credentials_for_retirement(session: Session) -> int:
"""Scrub and audit every remaining Mail-owned secret before table drop."""
profiles = (
session.query(MailServerProfile)
.filter(
or_(
MailServerProfile.smtp_password_encrypted.is_not(None),
MailServerProfile.imap_password_encrypted.is_not(None),
)
)
.order_by(MailServerProfile.id.asc())
.all()
)
deleted = 0
for profile in profiles:
deleted += len(
delete_mail_profile_credentials(
session,
profile=profile,
deletion_reason="module_data_retired",
)
)
session.flush()
return deleted
def _server_config_payload(value: dict[str, Any] | None) -> dict[str, Any]:
@@ -1530,87 +1662,9 @@ def set_mail_profile_policy(
return normalized
def _write_transport_to_server(server: dict[str, Any], protocol: str, payload: dict[str, Any]) -> None:
server_payload = dict(payload)
username = server_payload.pop("username", None)
password = server_payload.pop("password", None)
server_payload.pop("enabled", None)
server[protocol] = server_payload
if username not in (None, "") or password not in (None, ""):
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
protocol_credentials = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
if username not in (None, ""):
protocol_credentials["username"] = username
if password not in (None, ""):
protocol_credentials["password"] = password
credentials[protocol] = protocol_credentials
server["credentials"] = credentials
def materialize_campaign_mail_profile_config(
session: Session,
*,
tenant_id: str,
raw_json: dict[str, Any],
campaign_id: str | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
) -> dict[str, Any]:
data = copy.deepcopy(raw_json if isinstance(raw_json, dict) else {})
server = data.get("server")
if not isinstance(server, dict):
return data
profile_id = server.get("mail_profile_id")
if not profile_id:
assert_campaign_mail_policy_allows_json(
session,
tenant_id=tenant_id,
raw_json=data,
campaign_id=campaign_id,
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
return data
if campaign_id:
profile = ensure_mail_profile_allowed_for_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=str(profile_id), require_active=True)
else:
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
assert_campaign_mail_policy_allows_json(
session,
tenant_id=tenant_id,
raw_json=data,
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
policy = effective_mail_profile_policy(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
_assert_profile_credentials_allowed_for_server(profile, policy, server)
resolved_server = dict(server)
smtp_payload = smtp_config_from_profile(profile).model_dump(mode="json")
if not profile_credentials_inherited_for_server(policy, server, "smtp"):
smtp_payload = apply_campaign_credentials(smtp_payload, server, "smtp")
_write_transport_to_server(resolved_server, "smtp", smtp_payload)
imap = imap_config_from_profile(profile)
if imap is not None:
imap_payload = imap.model_dump(mode="json")
if not profile_credentials_inherited_for_server(policy, server, "imap"):
imap_payload = apply_campaign_credentials(imap_payload, server, "imap")
_write_transport_to_server(resolved_server, "imap", imap_payload)
else:
resolved_server.pop("imap", None)
data["server"] = resolved_server
return data
def mail_profile_id_from_campaign_json(raw_json: dict[str, Any] | None) -> str | None:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
value = server.get("mail_profile_id") if isinstance(server, dict) else None
return str(value) if value else None
return _campaign_mail_profile_reference_id(server) if isinstance(server, dict) else None
def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> list[str]: