security: enforce actor-aware Mail profile access

This commit is contained in:
2026-07-21 17:51:25 +02:00
parent 9d1d9bfb58
commit 06e0fbd3c3
7 changed files with 1336 additions and 158 deletions

View File

@@ -13,7 +13,9 @@ 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_ACCESS,
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
CampaignAccessProvider,
CampaignMailPolicyContext,
CampaignMailPolicyContextProvider,
)
@@ -21,6 +23,7 @@ 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, new_uuid
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_mail.backend.mailbox_index import clear_mailbox_index
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
@@ -270,6 +273,16 @@ def _campaign_policy_provider() -> CampaignMailPolicyContextProvider | None:
return capability
def _campaign_access_provider() -> CampaignAccessProvider | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
return None
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
if not isinstance(capability, CampaignAccessProvider):
raise MailProfileError("Campaign access capability is invalid")
return capability
def _campaign_policy_context(session: Session, *, tenant_id: str, campaign_id: str) -> CampaignMailPolicyContext:
provider = _campaign_policy_provider()
if provider is None:
@@ -855,10 +868,11 @@ def _profile_allowed_for_context(
campaign: CampaignMailPolicyContext | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
require_active: bool = True,
) -> bool:
scope_type = _profile_scope_type(profile)
scope_id = _profile_scope_id(profile)
if not profile.is_active:
if require_active and not profile.is_active:
return False
if not policy.profile_id_allowed(profile.id):
return False
@@ -881,15 +895,229 @@ def _profile_allowed_for_context(
return _profile_transport_allowed(profile, policy)
def campaign_mail_context_visible_to_actor(
session: Session,
*,
tenant_id: str,
campaign_id: str,
user_id: str,
group_ids: Iterable[str] = (),
tenant_admin: bool = False,
) -> bool:
provider = _campaign_access_provider()
if provider is None:
return False
return bool(
provider.can_read_campaign(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
user_id=user_id,
group_ids=group_ids,
tenant_admin=tenant_admin,
)
)
def mail_profile_scope_visible_to_actor(
session: Session,
*,
scope_type: str,
scope_id: str | None,
profile_tenant_id: str | None,
tenant_id: str,
user_id: str,
group_ids: Iterable[str] = (),
can_manage_tenant_profiles: bool = False,
tenant_admin: bool = False,
) -> bool:
"""Apply stable ownership/ACL discovery without activity or policy state."""
clean_scope_type = _normalize_scope_type(scope_type)
if clean_scope_type == "system":
return True
if profile_tenant_id != tenant_id:
return False
if clean_scope_type == "tenant":
return True
if clean_scope_type == "user":
return bool(can_manage_tenant_profiles or (scope_id and scope_id == user_id))
if clean_scope_type == "group":
return bool(
can_manage_tenant_profiles
or (scope_id and scope_id in {str(group_id) for group_id in group_ids})
)
if clean_scope_type != "campaign" or not scope_id:
return False
# Mail administration is not a Campaign ACL override. Only Campaign's own
# access capability decides this, including its exact tenant-admin rule.
return campaign_mail_context_visible_to_actor(
session,
tenant_id=tenant_id,
campaign_id=scope_id,
user_id=user_id,
group_ids=group_ids,
tenant_admin=tenant_admin,
)
def mail_profile_visible_to_actor(
session: Session,
*,
profile: MailServerProfile,
tenant_id: str,
user_id: str,
group_ids: Iterable[str] = (),
can_manage_tenant_profiles: bool = False,
can_manage_system_profiles: bool = False,
tenant_admin: bool = False,
administrative_visibility: bool = False,
require_active: bool = True,
) -> bool:
"""Return whether an actor may discover or directly use a profile.
System and tenant profiles are shared definitions. Narrower profiles are
visible only to their owner context, unless the actor is a tenant-wide Mail
profile administrator. Campaign visibility is delegated to Campaign's ACL
capability so Mail never guesses another module's object permissions.
"""
normalized_group_ids = tuple(sorted({str(group_id) for group_id in group_ids}))
scope_type = _profile_scope_type(profile)
scope_id = _profile_scope_id(profile)
if not mail_profile_scope_visible_to_actor(
session,
scope_type=scope_type,
scope_id=scope_id,
profile_tenant_id=profile.tenant_id,
tenant_id=tenant_id,
user_id=user_id,
group_ids=normalized_group_ids,
can_manage_tenant_profiles=(
administrative_visibility and can_manage_tenant_profiles
),
tenant_admin=tenant_admin,
):
return False
if administrative_visibility and scope_type == "system" and can_manage_system_profiles:
return True
if administrative_visibility and scope_type != "system" and can_manage_tenant_profiles:
return True
if scope_type in {"system", "tenant"}:
return _profile_allowed_for_actor_policy(
session,
profile=profile,
tenant_id=tenant_id,
user_id=user_id,
group_ids=normalized_group_ids,
require_active=require_active,
)
if scope_type == "user":
return _profile_allowed_for_actor_policy(
session,
profile=profile,
tenant_id=tenant_id,
user_id=user_id,
group_ids=(),
require_active=require_active,
)
if scope_type == "group":
return _profile_allowed_for_actor_policy(
session,
profile=profile,
tenant_id=tenant_id,
user_id=user_id,
group_ids=(scope_id,),
require_active=require_active,
)
if scope_type != "campaign" or not scope_id:
return False
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id)
return _profile_allowed_for_context(
profile,
tenant_id=tenant_id,
policy=policy,
campaign=campaign,
owner_user_id=campaign.owner_user_id,
owner_group_id=campaign.owner_group_id,
require_active=require_active,
)
def _profile_allowed_for_actor_policy(
session: Session,
*,
profile: MailServerProfile,
tenant_id: str,
user_id: str,
group_ids: Iterable[str],
require_active: bool = True,
) -> bool:
"""Apply every unambiguous actor policy context conservatively.
Direct Mail screens have no selected task/group context. The actor's user
policy always applies; for shared system/tenant profiles each group policy
also has to allow the profile. A caller that needs campaign-specific policy
must use the explicit campaign context path instead.
"""
normalized_group_ids = tuple(sorted({str(group_id) for group_id in group_ids}))
scope_type = _profile_scope_type(profile)
if scope_type == "group":
contexts: tuple[str | None, ...] = normalized_group_ids
elif scope_type == "user":
contexts = (None,)
else:
contexts = (None, *normalized_group_ids)
if not contexts:
return False
for group_id in contexts:
policy = effective_mail_profile_policy(
session,
tenant_id=tenant_id,
owner_user_id=user_id,
owner_group_id=group_id,
)
if not _profile_allowed_for_context(
profile,
tenant_id=tenant_id,
policy=policy,
owner_user_id=user_id,
owner_group_id=group_id,
require_active=require_active,
):
return False
return True
def list_mail_server_profiles(
session: Session,
*,
tenant_id: str,
include_inactive: bool = False,
campaign_id: str | None = None,
actor_user_id: str | None = None,
actor_group_ids: Iterable[str] = (),
actor_can_manage_tenant_profiles: bool = False,
actor_can_manage_system_profiles: bool = False,
actor_tenant_admin: bool = False,
actor_administrative_visibility: bool = False,
) -> list[MailServerProfile]:
normalized_actor_group_ids = tuple(
sorted({str(group_id) for group_id in actor_group_ids})
)
if campaign_id:
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if actor_user_id and not campaign_mail_context_visible_to_actor(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
user_id=actor_user_id,
group_ids=normalized_actor_group_ids,
tenant_admin=actor_tenant_admin,
):
raise MailProfileError("Campaign not found for mail-server profile policy")
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id)
query = session.query(MailServerProfile).filter(or_(*_context_profile_clauses(campaign, policy)))
if not include_inactive:
@@ -905,6 +1133,7 @@ def list_mail_server_profiles(
campaign=campaign,
owner_user_id=campaign.owner_user_id,
owner_group_id=campaign.owner_group_id,
require_active=not include_inactive,
)
]
else:
@@ -912,9 +1141,63 @@ def list_mail_server_profiles(
if not include_inactive:
query = query.filter(MailServerProfile.is_active.is_(True))
profiles = query.all()
if actor_user_id:
profiles = [
profile
for profile in profiles
if mail_profile_visible_to_actor(
session,
profile=profile,
tenant_id=tenant_id,
user_id=actor_user_id,
group_ids=normalized_actor_group_ids,
can_manage_tenant_profiles=actor_can_manage_tenant_profiles,
can_manage_system_profiles=actor_can_manage_system_profiles,
tenant_admin=actor_tenant_admin,
administrative_visibility=actor_administrative_visibility,
require_active=not include_inactive,
)
]
return sorted(profiles, key=lambda item: (PROFILE_SCOPE_ORDER.get(_profile_scope_type(item), 99), (item.name or "").casefold()))
def get_mail_server_profile_for_actor(
session: Session,
*,
tenant_id: str,
profile_id: str,
user_id: str,
group_ids: Iterable[str] = (),
can_manage_tenant_profiles: bool = False,
can_manage_system_profiles: bool = False,
tenant_admin: bool = False,
administrative_visibility: bool = False,
require_active: bool = False,
) -> MailServerProfile:
profile = get_mail_server_profile(
session,
tenant_id=tenant_id,
profile_id=profile_id,
require_active=require_active,
)
if not mail_profile_visible_to_actor(
session,
profile=profile,
tenant_id=tenant_id,
user_id=user_id,
group_ids=group_ids,
can_manage_tenant_profiles=can_manage_tenant_profiles,
can_manage_system_profiles=can_manage_system_profiles,
tenant_admin=tenant_admin,
administrative_visibility=administrative_visibility,
require_active=require_active,
):
# Deliberately indistinguishable from an absent profile to prevent id
# probing across user, group, and campaign scopes.
raise MailProfileError("Mail-server profile not found")
return profile
def get_mail_server_profile(
session: Session,
*,
@@ -948,6 +1231,7 @@ def ensure_mail_profile_allowed_for_campaign(
campaign=campaign,
owner_user_id=campaign.owner_user_id,
owner_group_id=campaign.owner_group_id,
require_active=require_active,
):
raise MailProfileError("Mail-server profile is not allowed for this campaign owner or policy")
return profile
@@ -1086,6 +1370,10 @@ def create_mail_server_profile(
imap_password = None
if imap is not None:
imap_payload, imap_username, imap_password, _, _ = _transport_payload(imap)
if not is_active and (smtp_password not in (None, "") or imap_password not in (None, "")):
raise MailProfileError(
"Inactive mail-server profiles cannot retain credentials; create the active profile or omit credentials"
)
profile = MailServerProfile(
tenant_id=profile_tenant_id,
scope_type=clean_scope_type,
@@ -1123,6 +1411,30 @@ def update_mail_server_profile(
imap: ImapConfig | None = None,
clear_imap: bool = False,
) -> MailServerProfile:
if is_active is False:
raise MailProfileError(
"Deactivate profiles through DELETE so credentials are scrubbed and the deletion is audited"
)
if not profile.is_active and (
profile.smtp_password_encrypted or profile.imap_password_encrypted
):
raise MailProfileError(
"Inactive profile still contains legacy credentials; use DELETE to scrub them before editing or reactivating"
)
inactive_password_update = (
not profile.is_active
and is_active is not True
and any(
config is not None
and "password" in config.model_fields_set
and config.password not in (None, "")
for config in (smtp, imap)
)
)
if inactive_password_update:
raise MailProfileError(
"Credentials may only be added to an inactive profile when it is activated in the same update"
)
if name is not None:
clean_name = name.strip()
if not clean_name:
@@ -1166,13 +1478,17 @@ def _next_profile_transport_state(
smtp: SmtpConfig | None,
imap: ImapConfig | None,
clear_imap: bool,
) -> tuple[SmtpConfig, ImapConfig | None]:
next_smtp = smtp or smtp_config_from_profile(profile)
) -> tuple[SmtpConfig | dict[str, Any], ImapConfig | dict[str, Any] | None]:
# Policy validation only needs non-secret transport metadata. Never decrypt
# an unchanged protocol merely because profile metadata or the other
# protocol is being updated; corrupt/retired ciphertext must not block the
# credential replacement and deletion recovery paths.
next_smtp: SmtpConfig | dict[str, Any] = smtp or dict(profile.smtp_config or {})
if clear_imap:
return next_smtp, None
if imap is not None:
return next_smtp, imap
return next_smtp, imap_config_from_profile(profile)
return next_smtp, dict(profile.imap_config or {}) if profile.imap_config else None
def _assert_profile_transport_allowed(
@@ -1180,8 +1496,8 @@ def _assert_profile_transport_allowed(
*,
tenant_id: str,
profile: MailServerProfile,
smtp: SmtpConfig,
imap: ImapConfig | None,
smtp: SmtpConfig | dict[str, Any],
imap: ImapConfig | dict[str, Any] | None,
) -> None:
scope_type = _profile_scope_type(profile)
scope_id = _profile_scope_id(profile)
@@ -1229,6 +1545,7 @@ def _apply_profile_transport_update(
user_id=user_id,
api_key_id=api_key_id,
)
clear_mailbox_index(session, profile_id=profile.id)
profile.imap_config = None
profile.imap_username = None
profile.imap_password_encrypted = None
@@ -1259,12 +1576,6 @@ def _apply_profile_transport_payload(
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)
@@ -1273,7 +1584,7 @@ def _apply_profile_transport_payload(
session,
profile=profile,
protocol=protocol,
password_supplied=password_supplied and current_password != password,
password_supplied=password_supplied,
next_password=password,
user_id=user_id,
api_key_id=api_key_id,
@@ -1282,15 +1593,20 @@ def _apply_profile_transport_payload(
profile.smtp_config = payload
if username_supplied:
profile.smtp_username = username
if password_supplied and current_password != password:
if password_supplied:
profile.smtp_password_encrypted = encrypt_secret(password)
if identity_changed:
profile.smtp_transport_revision = new_uuid()
return
# Cached mailbox metadata and message summaries belong to the exact IMAP
# account identity. Clear them in the same transaction before changing an
# identity or credential so another account can never inherit them.
if identity_changed or password_supplied:
clear_mailbox_index(session, profile_id=profile.id)
profile.imap_config = payload
if username_supplied:
profile.imap_username = username
if password_supplied and current_password != password:
if password_supplied:
profile.imap_password_encrypted = encrypt_secret(password)
if identity_changed:
profile.imap_transport_revision = new_uuid()