security: enforce actor-aware Mail profile access
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user