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()

View File

@@ -36,6 +36,27 @@ class CachedMessagePage:
stale: bool
def clear_mailbox_index(session: Session, *, profile_id: str) -> tuple[int, int]:
"""Remove every cached mailbox row for a profile in the caller's transaction.
System profiles can be used by more than one tenant, so invalidation is
intentionally profile-wide rather than limited to the tenant making the
transport change. Message rows are removed before their folder metadata.
"""
deleted_messages = (
session.query(MailMailboxMessageIndex)
.filter(MailMailboxMessageIndex.profile_id == profile_id)
.delete(synchronize_session=False)
)
deleted_folders = (
session.query(MailMailboxFolderIndex)
.filter(MailMailboxFolderIndex.profile_id == profile_id)
.delete(synchronize_session=False)
)
return int(deleted_folders or 0), int(deleted_messages or 0)
def begin_mailbox_refresh(tenant_id: str, profile_id: str, folder: str) -> bool:
key = (tenant_id, profile_id, folder)
with _refresh_lock:

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
import dataclasses
import logging
from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
@@ -41,24 +40,27 @@ from govoplan_core.core.change_sequence import (
sequence_watermark_is_expired,
)
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
from govoplan_core.db.session import get_database, get_session
from govoplan_core.db.session import get_session
from govoplan_mail.backend.mailbox_index import (
begin_mailbox_refresh,
cache_mailbox_folders,
cache_mailbox_messages,
cached_mailbox_folders,
cached_mailbox_message_page,
finish_mailbox_refresh,
clear_mailbox_index,
)
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
campaign_mail_context_visible_to_actor,
create_mail_server_profile,
delete_mail_profile_credentials,
effective_mail_profile_policy_for_scope,
get_mail_profile_policy,
get_mail_server_profile,
get_mail_server_profile_for_actor,
imap_config_from_profile,
list_mail_server_profiles,
mail_profile_visible_to_actor,
mail_profile_scope_visible_to_actor,
parent_mail_profile_policy,
profile_response_payload,
set_mail_profile_policy,
@@ -71,7 +73,6 @@ from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfiguratio
from govoplan_mail.backend.sending.smtp import test_smtp_login
router = APIRouter(prefix="/mail", tags=["mail"])
logger = logging.getLogger(__name__)
MAIL_MODULE_ID = "mail"
MAIL_PROFILES_COLLECTION = "mail.profiles"
@@ -145,6 +146,55 @@ def _require_mailbox_read_scope(principal: ApiPrincipal) -> None:
_require_scope(principal, "mail:profile:use")
def _principal_is_tenant_admin(principal: ApiPrincipal) -> bool:
return has_scope(principal, "tenant:*")
def _principal_can_manage_tenant_profiles(principal: ApiPrincipal) -> bool:
return has_scope(principal, "mail:profile:write")
def _principal_can_manage_system_profiles(principal: ApiPrincipal) -> bool:
return has_scope(principal, "system:settings:write")
def _profile_actor_kwargs(
principal: ApiPrincipal,
*,
administrative_visibility: bool,
) -> dict[str, object]:
return {
"actor_user_id": principal.user.id,
"actor_group_ids": principal.group_ids,
"actor_can_manage_tenant_profiles": _principal_can_manage_tenant_profiles(principal),
"actor_can_manage_system_profiles": _principal_can_manage_system_profiles(principal),
"actor_tenant_admin": _principal_is_tenant_admin(principal),
"actor_administrative_visibility": administrative_visibility,
}
def _get_profile_for_principal(
session: Session,
*,
principal: ApiPrincipal,
profile_id: str,
require_active: bool = False,
administrative_visibility: bool = False,
):
return get_mail_server_profile_for_actor(
session,
tenant_id=principal.tenant_id,
profile_id=profile_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
tenant_admin=_principal_is_tenant_admin(principal),
administrative_visibility=administrative_visibility,
require_active=require_active,
)
def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_scope(principal, "system:settings:read")
@@ -163,6 +213,86 @@ def _require_policy_write_scope(principal: ApiPrincipal, scope_type: str) -> Non
_require_any_scope(principal, "admin:policies:write", "mail:profile:write")
def _require_policy_scope_visibility(
session: Session,
*,
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None,
) -> None:
if scope_type in {"system", "tenant"}:
return
if has_scope(principal, "admin:policies:read"):
return
if not mail_profile_scope_visible_to_actor(
session,
scope_type=scope_type,
scope_id=scope_id,
profile_tenant_id=principal.tenant_id,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
tenant_admin=_principal_is_tenant_admin(principal),
):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mail profile policy not found")
def _require_campaign_context_visibility(
session: Session,
*,
principal: ApiPrincipal,
campaign_id: str | None,
) -> None:
if not campaign_id:
return
if not campaign_mail_context_visible_to_actor(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
tenant_admin=_principal_is_tenant_admin(principal),
):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
def _require_policy_campaign_context(
session: Session,
*,
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None,
campaign_id: str | None,
) -> None:
if scope_type == "campaign" and campaign_id and campaign_id != scope_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="campaign_id must match the campaign policy scope_id",
)
_require_campaign_context_visibility(
session,
principal=principal,
campaign_id=campaign_id or (scope_id if scope_type == "campaign" else None),
)
def _require_campaign_profile_mutation_access(
session: Session,
*,
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None,
) -> None:
if scope_type != "campaign":
return
_require_campaign_context_visibility(
session,
principal=principal,
campaign_id=scope_id,
)
def _profile_response(profile) -> MailServerProfileResponse:
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
@@ -283,6 +413,45 @@ def _mail_deleted_item(entry) -> DeltaDeletedItem:
)
def _profile_change_visible_to_principal(
session: Session,
*,
entry: ChangeSequenceEntry,
principal: ApiPrincipal,
) -> bool:
try:
profile = get_mail_server_profile(
session,
tenant_id=principal.tenant_id,
profile_id=entry.resource_id,
)
except MailProfileError:
payload = entry.payload if isinstance(entry.payload, dict) else {}
return mail_profile_scope_visible_to_actor(
session,
scope_type=str(payload.get("scope_type") or "tenant"),
scope_id=str(payload["scope_id"]) if payload.get("scope_id") else None,
profile_tenant_id=entry.tenant_id,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
tenant_admin=_principal_is_tenant_admin(principal),
)
return mail_profile_visible_to_actor(
session,
profile=profile,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
tenant_admin=_principal_is_tenant_admin(principal),
administrative_visibility=True,
require_active=False,
)
def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse:
return MailMailboxMessageSummaryResponse(
uid=message.uid,
@@ -451,65 +620,6 @@ def _mailbox_messages_response(
)
def _schedule_mailbox_refresh(
background_tasks: BackgroundTasks,
*,
tenant_id: str,
profile_id: str,
folder: str,
limit: int,
offset: int = 0,
include_folder_status: bool = False,
) -> bool:
if not begin_mailbox_refresh(tenant_id, profile_id, folder):
return False
background_tasks.add_task(
_refresh_mailbox_index_task,
tenant_id=tenant_id,
profile_id=profile_id,
folder=folder,
limit=limit,
offset=offset,
include_folder_status=include_folder_status,
)
return True
def _refresh_mailbox_index_task(
*,
tenant_id: str,
profile_id: str,
folder: str,
limit: int,
offset: int,
include_folder_status: bool,
) -> None:
try:
with get_database().session() as session:
imap = _imap_config_for_profile(session, tenant_id=tenant_id, profile_id=profile_id)
result = load_imap_mailbox_bootstrap(
imap_config=imap,
folder=folder,
limit=limit,
offset=offset,
include_folder_status=include_folder_status,
)
cache_mailbox_folders(session, tenant_id=tenant_id, profile_id=profile_id, result=result.folders)
cache_mailbox_messages(session, tenant_id=tenant_id, profile_id=profile_id, result=result.messages)
session.commit()
except Exception:
# Background refresh is opportunistic. Foreground requests will surface
# concrete IMAP errors when no usable cache is available.
logger.debug(
"Background mailbox index refresh failed (profile_id=%r, folder=%r)",
profile_id,
folder,
exc_info=True,
)
finally:
finish_mailbox_refresh(tenant_id, profile_id, folder)
def _cached_folder_selection(folders, requested: str, detected_sent_folder: str | None = None) -> str:
names = {folder.name for folder in folders}
if requested in names:
@@ -521,8 +631,13 @@ def _cached_folder_selection(folders, requested: str, detected_sent_folder: str
return folders[0].name if folders else requested
def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: str):
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True)
def _imap_config_for_principal(session: Session, *, principal: ApiPrincipal, profile_id: str):
profile = _get_profile_for_principal(
session,
principal=principal,
profile_id=profile_id,
require_active=True,
)
imap = imap_config_from_profile(profile)
if imap is None:
raise MailProfileError("Mail-server profile has no IMAP configuration")
@@ -538,7 +653,7 @@ def _parent_mail_profile_policy_payload(session: Session, *, tenant_id: str, sco
def _full_mail_settings_delta_response(
session: Session,
*,
tenant_id: str,
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None,
include_inactive: bool,
@@ -546,16 +661,17 @@ def _full_mail_settings_delta_response(
) -> MailSettingsDeltaResponse:
profiles = list_mail_server_profiles(
session,
tenant_id=tenant_id,
tenant_id=principal.tenant_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
**_profile_actor_kwargs(principal, administrative_visibility=True),
)
return MailSettingsDeltaResponse(
profiles=[_profile_response(profile) for profile in profiles],
policy=_policy_response(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id),
policy=_policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id),
changed_sections=["profiles", "policy"],
deleted=[],
watermark=_mail_settings_watermark(session, tenant_id=tenant_id),
watermark=_mail_settings_watermark(session, tenant_id=principal.tenant_id),
has_more=False,
full=True,
)
@@ -593,10 +709,23 @@ def mail_settings_delta(
clean_scope_type = scope_type.strip().casefold()
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
_require_policy_read_scope(principal, clean_scope_type)
_require_policy_scope_visibility(
session,
principal=principal,
scope_type=clean_scope_type,
scope_id=scope_id,
)
_require_policy_campaign_context(
session,
principal=principal,
scope_type=clean_scope_type,
scope_id=scope_id,
campaign_id=campaign_id,
)
if since is None:
return _full_mail_settings_delta_response(
session,
tenant_id=principal.tenant_id,
principal=principal,
scope_type=clean_scope_type,
scope_id=scope_id,
include_inactive=include_inactive,
@@ -606,16 +735,22 @@ def mail_settings_delta(
if entries is None:
return _full_mail_settings_delta_response(
session,
tenant_id=principal.tenant_id,
principal=principal,
scope_type=clean_scope_type,
scope_id=scope_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
visible_profile_entries = [
entry
for entry in entries
if entry.collection == MAIL_PROFILES_COLLECTION
and entry.resource_type == MAIL_PROFILE_RESOURCE
and _profile_change_visible_to_principal(session, entry=entry, principal=principal)
]
changed_profile_ids = {
entry.resource_id
for entry in entries
if entry.collection == MAIL_PROFILES_COLLECTION and entry.resource_type == MAIL_PROFILE_RESOURCE
for entry in visible_profile_entries
}
visible_profiles = {
profile.id: profile
@@ -624,6 +759,7 @@ def mail_settings_delta(
tenant_id=principal.tenant_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
**_profile_actor_kwargs(principal, administrative_visibility=True),
)
if profile.id in changed_profile_ids
}
@@ -635,10 +771,8 @@ def mail_settings_delta(
changed_sections.append("policy")
deleted = [
_mail_deleted_item(entry)
for entry in entries
if entry.collection == MAIL_PROFILES_COLLECTION
and entry.resource_type == MAIL_PROFILE_RESOURCE
and entry.resource_id not in visible_profiles
for entry in visible_profile_entries
if entry.resource_id not in visible_profiles
]
return MailSettingsDeltaResponse(
profiles=[_profile_response(profile) for profile in visible_profiles.values()],
@@ -665,6 +799,7 @@ def list_profiles(
tenant_id=principal.tenant_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
**_profile_actor_kwargs(principal, administrative_visibility=True),
)
return MailServerProfileListResponse(profiles=[_profile_response(profile) for profile in profiles])
except MailProfileError as exc:
@@ -678,6 +813,12 @@ def create_profile(
session: Session = Depends(get_session),
):
_require_profile_write_scope(principal, payload.scope_type)
_require_campaign_profile_mutation_access(
session,
principal=principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
if payload.credentials_supplied():
_require_profile_credentials_scope(principal, payload.scope_type)
try:
@@ -720,7 +861,14 @@ def get_profile(
):
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
try:
return _profile_response(get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id))
return _profile_response(
_get_profile_for_principal(
session,
principal=principal,
profile_id=profile_id,
administrative_visibility=True,
)
)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -735,6 +883,12 @@ def update_profile(
try:
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
_require_profile_write_scope(principal, profile.scope_type or "tenant")
_require_campaign_profile_mutation_access(
session,
principal=principal,
scope_type=profile.scope_type or "tenant",
scope_id=profile.scope_id,
)
if payload.credentials_supplied() or payload.clear_imap:
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
smtp_config = payload.smtp_config()
@@ -791,6 +945,12 @@ def deactivate_profile(
try:
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
_require_profile_write_scope(principal, profile.scope_type or "tenant")
_require_campaign_profile_mutation_access(
session,
principal=principal,
scope_type=profile.scope_type or "tenant",
scope_id=profile.scope_id,
)
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
was_active = bool(profile.is_active)
profile.is_active = False
@@ -801,6 +961,10 @@ def deactivate_profile(
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
# Deactivation invalidates unauthenticated IMAP profiles too. Keep the
# cache deletion in this transaction so a failed audit/change record
# cannot leave profile state and cached mailbox data out of sync.
clear_mailbox_index(session, profile_id=profile.id)
session.add(profile)
if was_active or deleted_protocols:
_record_mail_change(
@@ -839,6 +1003,19 @@ def read_mail_profile_policy(
):
scope_type = scope_type.strip().casefold()
_require_policy_read_scope(principal, scope_type)
_require_policy_scope_visibility(
session,
principal=principal,
scope_type=scope_type,
scope_id=scope_id,
)
_require_policy_campaign_context(
session,
principal=principal,
scope_type=scope_type,
scope_id=scope_id,
campaign_id=campaign_id,
)
try:
return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id)
except MailProfileError as exc:
@@ -855,6 +1032,19 @@ def write_mail_profile_policy(
):
scope_type = scope_type.strip().casefold()
_require_policy_write_scope(principal, scope_type)
_require_policy_scope_visibility(
session,
principal=principal,
scope_type=scope_type,
scope_id=scope_id,
)
_require_policy_campaign_context(
session,
principal=principal,
scope_type=scope_type,
scope_id=scope_id,
campaign_id=None,
)
try:
set_mail_profile_policy(
session,
@@ -889,7 +1079,12 @@ def test_profile_smtp(
_require_scope(principal, "mail:profile:test")
_require_scope(principal, "mail:profile:use")
try:
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
profile = _get_profile_for_principal(
session,
principal=principal,
profile_id=profile_id,
require_active=True,
)
smtp = smtp_config_from_profile(profile)
result = test_smtp_login(smtp_config=smtp)
return MailConnectionTestResponse(ok=True, protocol="smtp", host=result.host, port=result.port, security=result.security, message="SMTP connection successful.", details={"authenticated": result.authenticated})
@@ -908,7 +1103,12 @@ def test_profile_imap(
_require_scope(principal, "mail:profile:test")
_require_scope(principal, "mail:profile:use")
try:
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
profile = _get_profile_for_principal(
session,
principal=principal,
profile_id=profile_id,
require_active=True,
)
imap = imap_config_from_profile(profile)
if imap is None:
raise MailProfileError("Mail-server profile has no IMAP configuration")
@@ -929,7 +1129,12 @@ def list_profile_imap_folders(
_require_scope(principal, "mail:profile:test")
_require_scope(principal, "mail:profile:use")
try:
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
profile = _get_profile_for_principal(
session,
principal=principal,
profile_id=profile_id,
require_active=True,
)
imap = imap_config_from_profile(profile)
if imap is None:
raise MailProfileError("Mail-server profile has no IMAP configuration")
@@ -944,7 +1149,6 @@ def list_profile_imap_folders(
@router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse)
def list_profile_mailbox_folders(
profile_id: str,
background_tasks: BackgroundTasks,
include_status: bool = Query(default=False),
refresh: bool = False,
principal: ApiPrincipal = Depends(get_api_principal),
@@ -952,18 +1156,10 @@ def list_profile_mailbox_folders(
):
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
if not include_status and not refresh:
cached = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
if cached is not None:
refreshing = cached.stale and _schedule_mailbox_refresh(
background_tasks,
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder="INBOX",
limit=DEFAULT_MAILBOX_MESSAGE_LIMIT,
include_folder_status=include_status,
)
if cached is not None and not cached.stale:
result = SimpleNamespace(
host=imap.host,
port=imap.port,
@@ -971,7 +1167,7 @@ def list_profile_mailbox_folders(
folders=cached.folders,
detected_sent_folder=None,
)
return _mailbox_folder_response(result, from_cache=True, refreshing=refreshing, indexed_at=cached.indexed_at)
return _mailbox_folder_response(result, from_cache=True, refreshing=False, indexed_at=cached.indexed_at)
result = list_imap_folders(imap_config=imap, include_status=include_status)
cache_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result)
session.commit()
@@ -987,7 +1183,6 @@ def list_profile_mailbox_folders(
@router.get("/profiles/{profile_id}/mailbox/bootstrap", response_model=MailMailboxBootstrapResponse)
def bootstrap_profile_mailbox(
profile_id: str,
background_tasks: BackgroundTasks,
folder: str = Query(default="INBOX", min_length=1, max_length=255),
limit: int = Query(default=DEFAULT_MAILBOX_MESSAGE_LIMIT, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=100000),
@@ -998,10 +1193,10 @@ def bootstrap_profile_mailbox(
) -> MailMailboxBootstrapResponse:
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
if not refresh:
cached_folders = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
if cached_folders is not None:
if cached_folders is not None and not cached_folders.stale:
selected_folder = _cached_folder_selection(cached_folders.folders, folder)
cached_messages = cached_mailbox_message_page(
session,
@@ -1011,16 +1206,7 @@ def bootstrap_profile_mailbox(
limit=limit,
offset=offset,
)
if cached_messages is not None:
refreshing = (cached_folders.stale or cached_messages.stale) and _schedule_mailbox_refresh(
background_tasks,
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=selected_folder,
limit=limit,
offset=offset,
include_folder_status=include_status,
)
if cached_messages is not None and not cached_messages.stale:
folder_result = SimpleNamespace(
host=imap.host,
port=imap.port,
@@ -1044,7 +1230,7 @@ def bootstrap_profile_mailbox(
folders=_mailbox_folder_response(
folder_result,
from_cache=True,
refreshing=refreshing,
refreshing=False,
indexed_at=cached_folders.indexed_at,
),
messages=_mailbox_messages_response(
@@ -1062,7 +1248,7 @@ def bootstrap_profile_mailbox(
full=not cursor_stable,
messages=cached_messages.messages,
from_cache=True,
refreshing=refreshing,
refreshing=False,
indexed_at=cached_messages.indexed_at,
),
)
@@ -1118,7 +1304,6 @@ def bootstrap_profile_mailbox(
@router.get("/profiles/{profile_id}/mailbox/messages", response_model=MailMailboxMessageListResponse)
def list_profile_mailbox_messages(
profile_id: str,
background_tasks: BackgroundTasks,
folder: str = Query(default="INBOX", min_length=1, max_length=255),
limit: int | None = Query(default=None, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=100000),
@@ -1129,7 +1314,7 @@ def list_profile_mailbox_messages(
):
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
effective_limit = _mailbox_cursor_limit(cursor, limit)
fingerprint = _mailbox_cursor_fingerprint(tenant_id=principal.tenant_id, profile_id=profile_id, folder=folder, limit=effective_limit)
effective_offset, after_uid, expected_uidvalidity, cursor_values = _mailbox_cursor_position(cursor, fingerprint=fingerprint, offset=offset)
@@ -1145,15 +1330,7 @@ def list_profile_mailbox_messages(
cache_matches_cursor = cached is not None and (
cursor is None or (expected_uidvalidity is not None and cached.uidvalidity == expected_uidvalidity)
)
if cached is not None and cache_matches_cursor:
refreshing = cached.stale and _schedule_mailbox_refresh(
background_tasks,
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=folder,
limit=effective_limit,
offset=effective_offset,
)
if cached is not None and cache_matches_cursor and not cached.stale:
next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id,
profile_id=profile_id,
@@ -1179,7 +1356,7 @@ def list_profile_mailbox_messages(
full=not cursor_stable,
messages=cached.messages,
from_cache=True,
refreshing=refreshing,
refreshing=False,
indexed_at=cached.indexed_at,
)
result = list_imap_messages(
@@ -1236,7 +1413,7 @@ def get_profile_mailbox_message(
):
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
result = get_imap_message(imap_config=imap, folder=folder, uid=message_uid)
return MailMailboxMessageResponse(
profile_id=profile_id,

View File

@@ -14,15 +14,75 @@ from govoplan_mail.backend.mail_profiles import (
_campaign_mail_profile_reference_id,
_apply_profile_transport_update,
campaign_profile_transport_revisions,
create_mail_server_profile,
_merge_policy,
_next_profile_transport_state,
_policy_parent_lock_message,
_policy_parent_lock_violations,
delete_mail_profile_credentials,
update_mail_server_profile,
)
class MailProfileTransportHelperTests(unittest.TestCase):
def test_inactive_profile_creation_rejects_dormant_credentials(self):
session = SimpleNamespace()
with (
patch(
"govoplan_mail.backend.mail_profiles._scope_tuple_for_create",
return_value=("tenant-1", "tenant", "tenant-1"),
),
patch("govoplan_mail.backend.mail_profiles._ensure_scope_allows_profile_creation"),
patch("govoplan_mail.backend.mail_profiles.assert_mail_policy_allows_transport"),
patch("govoplan_mail.backend.mail_profiles._ensure_unique_slug"),
self.assertRaisesRegex(MailProfileError, "cannot retain credentials"),
):
create_mail_server_profile(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
name="Dormant secret",
slug=None,
description=None,
smtp=SmtpConfig(host="smtp.example.test", password="secret"),
imap=None,
is_active=False,
)
def test_inactive_profile_cannot_retain_or_recreate_dormant_credentials(self):
session = SimpleNamespace()
legacy = SimpleNamespace(
is_active=False,
smtp_password_encrypted="legacy-ciphertext",
imap_password_encrypted=None,
)
with self.assertRaisesRegex(MailProfileError, "use DELETE to scrub"):
update_mail_server_profile(
session, # type: ignore[arg-type]
legacy, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
is_active=True,
)
self.assertFalse(legacy.is_active)
self.assertEqual(legacy.smtp_password_encrypted, "legacy-ciphertext")
scrubbed = SimpleNamespace(
is_active=False,
smtp_password_encrypted=None,
imap_password_encrypted=None,
)
with self.assertRaisesRegex(MailProfileError, "activated in the same update"):
update_mail_server_profile(
session, # type: ignore[arg-type]
scrubbed, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
smtp=SmtpConfig(host="smtp.example.test", password="new-secret"),
)
self.assertFalse(scrubbed.is_active)
self.assertIsNone(scrubbed.smtp_password_encrypted)
def test_campaign_transport_revisions_are_random_opaque_values(self):
profile = SimpleNamespace(
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
@@ -140,7 +200,7 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertEqual(profile.smtp_password_encrypted, "smtp-ciphertext")
self.assertEqual(profile.imap_password_encrypted, "imap-ciphertext")
def test_next_transport_state_uses_saved_profile_credentials(self):
def test_next_transport_state_uses_only_saved_non_secret_metadata(self):
profile = SimpleNamespace(
tenant_id="tenant-1",
scope_type="tenant",
@@ -153,12 +213,19 @@ class MailProfileTransportHelperTests(unittest.TestCase):
imap_password_encrypted=encrypt_secret("imap-secret"),
)
with patch(
"govoplan_mail.backend.mail_profiles.decrypt_secret",
side_effect=AssertionError("policy validation must not decrypt credentials"),
):
smtp, imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=False)
self.assertEqual(smtp.username, "saved-smtp")
self.assertEqual(smtp.password, "smtp-secret")
self.assertEqual(smtp["host"], "smtp.example.org")
self.assertNotIn("username", smtp)
self.assertNotIn("password", smtp)
self.assertIsNotNone(imap)
self.assertEqual(imap.username, "saved-imap")
self.assertEqual(imap.password, "imap-secret")
assert imap is not None
self.assertEqual(imap["host"], "imap.example.org")
self.assertNotIn("username", imap)
self.assertNotIn("password", imap)
_, cleared_imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=True)
self.assertIsNone(cleared_imap)
@@ -180,6 +247,7 @@ class MailProfileTransportHelperTests(unittest.TestCase):
)
session = SimpleNamespace(flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index") as clear_index:
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
@@ -189,6 +257,7 @@ class MailProfileTransportHelperTests(unittest.TestCase):
imap=ImapConfig(host="imap2.example.org", username="new-imap"),
clear_imap=False,
)
clear_index.assert_called_once_with(session, profile_id="profile-1")
self.assertEqual(profile.smtp_config["host"], "smtp2.example.org")
self.assertEqual(profile.smtp_username, "new-smtp")
@@ -199,7 +268,10 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertNotEqual(profile.smtp_transport_revision, "smtp-before")
self.assertNotEqual(profile.imap_transport_revision, "imap-before")
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
with (
patch("govoplan_mail.backend.mail_profiles.audit_event") as audit,
patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index") as clear_index,
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
@@ -209,6 +281,7 @@ class MailProfileTransportHelperTests(unittest.TestCase):
imap=None,
clear_imap=True,
)
clear_index.assert_called_once_with(session, profile_id="profile-1")
self.assertIsNone(profile.imap_config)
self.assertIsNone(profile.imap_username)
self.assertIsNone(profile.imap_password_encrypted)
@@ -216,6 +289,53 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org"},
smtp_username=None,
smtp_password_encrypted=None,
smtp_transport_revision="smtp-before",
imap_config={
"host": "imap.example.org",
"port": 993,
"security": "tls",
"sent_folder": "auto",
"timeout_seconds": 30,
},
imap_username="saved-imap",
imap_password_encrypted=encrypt_secret("old-secret"),
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with (
patch("govoplan_mail.backend.mail_profiles.audit_event"),
patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index") as clear_index,
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=None,
imap=ImapConfig(
host="imap.example.org",
port=993,
security="tls",
timeout_seconds=30,
password="new-secret",
),
clear_imap=False,
)
clear_index.assert_called_once_with(session, profile_id="profile-1")
self.assertEqual(profile.imap_transport_revision, "imap-before")
self.assertEqual(decrypt_secret(profile.imap_password_encrypted), "new-secret")
def test_password_replacement_preserves_revision_and_is_audited_without_secret(self):
profile = SimpleNamespace(
id="profile-1",
@@ -295,7 +415,7 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertEqual(profile.smtp_password_encrypted, encrypted)
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
def test_exact_password_update_is_an_idempotent_no_op(self):
def test_supplied_password_is_replaced_without_decrypting_old_ciphertext(self):
encrypted = encrypt_secret("same-secret")
profile = SimpleNamespace(
id="profile-1",
@@ -313,7 +433,13 @@ class MailProfileTransportHelperTests(unittest.TestCase):
)
session = SimpleNamespace(flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
with (
patch("govoplan_mail.backend.mail_profiles.audit_event") as audit,
patch(
"govoplan_mail.backend.mail_profiles.decrypt_secret",
side_effect=AssertionError("replacement must not decrypt old ciphertext"),
),
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
@@ -324,9 +450,49 @@ class MailProfileTransportHelperTests(unittest.TestCase):
clear_imap=False,
)
self.assertEqual(profile.smtp_password_encrypted, encrypted)
self.assertNotEqual(profile.smtp_password_encrypted, encrypted)
self.assertEqual(decrypt_secret(profile.smtp_password_encrypted), "same-secret")
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
audit.assert_not_called()
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_replaced")
def test_metadata_update_does_not_decrypt_unrelated_transport_credentials(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted="corrupt-smtp-ciphertext",
smtp_transport_revision="smtp-before",
imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30},
imap_username="saved-imap",
imap_password_encrypted="corrupt-imap-ciphertext",
imap_transport_revision="imap-before",
name="Profile",
slug="profile",
description=None,
is_active=True,
updated_by_user_id=None,
)
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
with (
patch("govoplan_mail.backend.mail_profiles.decrypt_secret", side_effect=AssertionError("must not decrypt")),
patch("govoplan_mail.backend.mail_profiles._assert_profile_transport_allowed") as policy_check,
):
update_mail_server_profile(
session, # type: ignore[arg-type]
profile,
tenant_id="tenant-1",
user_id="user-1",
description="Updated",
)
policy_check.assert_called_once()
self.assertEqual(profile.description, "Updated")
self.assertEqual(profile.smtp_password_encrypted, "corrupt-smtp-ciphertext")
self.assertEqual(profile.imap_password_encrypted, "corrupt-imap-ciphertext")
class MailProfilePolicyHelperTests(unittest.TestCase):

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import unittest
from govoplan_mail.backend.db.models import MailMailboxFolderIndex, MailMailboxMessageIndex
from govoplan_mail.backend.mailbox_index import clear_mailbox_index
class _DeleteQuery:
def __init__(self, model: type, deleted_models: list[type]) -> None:
self.model = model
self.deleted_models = deleted_models
def filter(self, *_criteria):
return self
def delete(self, *, synchronize_session: bool) -> int:
if synchronize_session is not False:
raise AssertionError("bulk invalidation must not synchronize loaded cache rows")
self.deleted_models.append(self.model)
return 2 if self.model is MailMailboxMessageIndex else 1
class _Session:
def __init__(self) -> None:
self.deleted_models: list[type] = []
def query(self, model: type) -> _DeleteQuery:
return _DeleteQuery(model, self.deleted_models)
class MailboxIndexInvalidationTests(unittest.TestCase):
def test_profile_wide_invalidation_deletes_messages_before_folders(self) -> None:
session = _Session()
deleted_folders, deleted_messages = clear_mailbox_index(
session, # type: ignore[arg-type]
profile_id="profile-1",
)
self.assertEqual(
session.deleted_models,
[MailMailboxMessageIndex, MailMailboxFolderIndex],
)
self.assertEqual((deleted_folders, deleted_messages), (1, 2))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,419 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from govoplan_mail.backend import router
from govoplan_mail.backend.mail_profiles import (
EffectiveMailProfilePolicy,
MailProfileError,
list_mail_server_profiles,
mail_profile_visible_to_actor,
)
from govoplan_mail.backend.schemas import (
MailServerProfileCreateRequest,
MailServerProfileUpdateRequest,
)
def _profile(
profile_id: str,
*,
scope_type: str,
scope_id: str | None,
tenant_id: str | None = "tenant-1",
):
return SimpleNamespace(
id=profile_id,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
is_active=True,
name=profile_id,
smtp_config={"host": "smtp.example.test"},
imap_config={"host": "imap.example.test"},
)
class _Query:
def __init__(self, profiles):
self._profiles = profiles
def filter(self, *_args):
return self
def all(self):
return list(self._profiles)
class _Session:
def __init__(self, profiles=()):
self.profiles = list(profiles)
self.commits = 0
self.rollbacks = 0
def query(self, _model):
return _Query(self.profiles)
def get(self, _model, profile_id):
return next((profile for profile in self.profiles if profile.id == profile_id), None)
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
class _Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="user-1")
group_ids = frozenset({"group-1"})
def __init__(self, scopes):
self._scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self._scopes
class ProfileActorAuthorizationTests(unittest.TestCase):
def test_general_profile_list_hides_other_owner_contexts(self) -> None:
profiles = [
_profile("system", scope_type="system", scope_id=None, tenant_id=None),
_profile("tenant", scope_type="tenant", scope_id="tenant-1"),
_profile("own-user", scope_type="user", scope_id="user-1"),
_profile("other-user", scope_type="user", scope_id="user-2"),
_profile("own-group", scope_type="group", scope_id="group-1"),
_profile("other-group", scope_type="group", scope_id="group-2"),
]
session = _Session(profiles)
with patch(
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
return_value=EffectiveMailProfilePolicy(),
):
visible = list_mail_server_profiles(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
actor_user_id="user-1",
actor_group_ids=(group_id for group_id in ("group-1",)),
)
self.assertEqual(
{profile.id for profile in visible},
{"system", "tenant", "own-user", "own-group"},
)
def test_general_access_denies_shared_profile_excluded_by_effective_policy(self) -> None:
denied = EffectiveMailProfilePolicy(allowed_profile_id_sets=[{"approved"}])
cases = (("system", None), ("tenant", "tenant-1"))
for scope_type, tenant_id in cases:
with self.subTest(scope_type=scope_type):
profile = _profile("denied", scope_type=scope_type, scope_id=tenant_id, tenant_id=tenant_id)
with patch("govoplan_mail.backend.mail_profiles.effective_mail_profile_policy", return_value=denied):
visible = mail_profile_visible_to_actor(
_Session(), # type: ignore[arg-type]
profile=profile,
tenant_id="tenant-1",
user_id="user-1",
group_ids={"group-1"},
)
self.assertFalse(visible)
def test_administrative_list_can_include_inactive_visible_profiles(self) -> None:
profile = _profile("inactive", scope_type="tenant", scope_id="tenant-1")
profile.is_active = False
session = _Session([profile])
with patch(
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
return_value=EffectiveMailProfilePolicy(),
):
visible = list_mail_server_profiles(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
include_inactive=True,
actor_user_id="user-1",
actor_can_manage_tenant_profiles=True,
actor_administrative_visibility=True,
)
self.assertEqual([item.id for item in visible], ["inactive"])
def test_profile_admin_can_list_but_not_use_a_policy_denied_profile(self) -> None:
profile = _profile("denied", scope_type="tenant", scope_id="tenant-1")
session = _Session([profile])
denied = EffectiveMailProfilePolicy(allowed_profile_id_sets=[{"approved"}])
with patch("govoplan_mail.backend.mail_profiles.effective_mail_profile_policy", return_value=denied):
listed = list_mail_server_profiles(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
actor_user_id="user-1",
actor_group_ids={"group-1"},
actor_can_manage_tenant_profiles=True,
actor_administrative_visibility=True,
)
self.assertEqual([item.id for item in listed], ["denied"])
principal = _Principal(
{"mail:profile:write", "mail:profile:test", "mail:profile:use"}
)
with (
patch("govoplan_mail.backend.router.smtp_config_from_profile") as materialize,
patch("govoplan_mail.backend.router.test_smtp_login") as provider,
self.assertRaises(HTTPException) as captured,
):
router.test_profile_smtp(
"denied",
principal=principal, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
)
self.assertEqual(captured.exception.status_code, 404)
materialize.assert_not_called()
provider.assert_not_called()
def test_campaign_scoped_profile_requires_campaign_acl_before_policy_resolution(self) -> None:
profile = _profile("campaign-profile", scope_type="campaign", scope_id="campaign-1")
access = SimpleNamespace(can_read_campaign=lambda *_args, **_kwargs: False)
with (
patch("govoplan_mail.backend.mail_profiles._campaign_access_provider", return_value=access),
patch("govoplan_mail.backend.mail_profiles._campaign_policy_context") as policy_context,
):
visible = mail_profile_visible_to_actor(
_Session(), # type: ignore[arg-type]
profile=profile,
tenant_id="tenant-1",
user_id="user-1",
)
self.assertFalse(visible)
policy_context.assert_not_called()
def test_mail_profile_admin_does_not_bypass_campaign_acl_but_tenant_admin_does(self) -> None:
profile = _profile("campaign-profile", scope_type="campaign", scope_id="campaign-1")
tenant_admin_values: list[bool] = []
def can_read(*_args, **kwargs):
tenant_admin_values.append(bool(kwargs["tenant_admin"]))
return bool(kwargs["tenant_admin"])
access = SimpleNamespace(can_read_campaign=can_read)
with (
patch("govoplan_mail.backend.mail_profiles._campaign_access_provider", return_value=access),
patch("govoplan_mail.backend.mail_profiles._campaign_policy_context") as policy_context,
):
mail_admin_visible = mail_profile_visible_to_actor(
_Session(), # type: ignore[arg-type]
profile=profile,
tenant_id="tenant-1",
user_id="user-1",
can_manage_tenant_profiles=True,
administrative_visibility=True,
)
tenant_admin_visible = mail_profile_visible_to_actor(
_Session(), # type: ignore[arg-type]
profile=profile,
tenant_id="tenant-1",
user_id="user-1",
can_manage_tenant_profiles=True,
tenant_admin=True,
administrative_visibility=True,
)
self.assertFalse(mail_admin_visible)
self.assertTrue(tenant_admin_visible)
self.assertEqual(tenant_admin_values, [False, True])
policy_context.assert_not_called()
def test_policy_read_rejects_unshared_and_mismatched_campaign_contexts(self) -> None:
principal = _Principal({"mail:profile:read"})
with (
patch(
"govoplan_mail.backend.router.campaign_mail_context_visible_to_actor",
return_value=False,
),
patch("govoplan_mail.backend.router._policy_response") as policy_response,
self.assertRaises(HTTPException) as unshared,
):
router.read_mail_profile_policy(
"tenant",
scope_id="tenant-1",
campaign_id="campaign-1",
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(unshared.exception.status_code, 404)
policy_response.assert_not_called()
with self.assertRaises(HTTPException) as mismatched:
router._require_policy_campaign_context( # noqa: SLF001 - authorization seam regression
_Session(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
scope_type="campaign",
scope_id="campaign-1",
campaign_id="campaign-2",
)
self.assertEqual(mismatched.exception.status_code, 422)
def test_campaign_profile_crud_requires_campaign_acl(self) -> None:
principal = _Principal({"mail:profile:write", "mail:secret:manage"})
profile = _profile("campaign-profile", scope_type="campaign", scope_id="campaign-1")
create_payload = MailServerProfileCreateRequest.model_validate(
{
"name": "Campaign profile",
"scope_type": "campaign",
"scope_id": "campaign-1",
"smtp": {"host": "smtp.example.test"},
}
)
with (
patch(
"govoplan_mail.backend.router.campaign_mail_context_visible_to_actor",
return_value=False,
),
patch("govoplan_mail.backend.router.create_mail_server_profile") as create,
self.assertRaises(HTTPException) as create_denied,
):
router.create_profile(
create_payload,
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(create_denied.exception.status_code, 404)
create.assert_not_called()
for operation in ("update", "delete"):
with (
self.subTest(operation=operation),
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=profile),
patch(
"govoplan_mail.backend.router.campaign_mail_context_visible_to_actor",
return_value=False,
),
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
patch("govoplan_mail.backend.router.delete_mail_profile_credentials") as delete,
self.assertRaises(HTTPException) as denied,
):
if operation == "update":
router.update_profile(
profile.id,
MailServerProfileUpdateRequest(name="Renamed"),
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
else:
router.deactivate_profile(
profile.id,
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(denied.exception.status_code, 404)
update.assert_not_called()
delete.assert_not_called()
def test_inactive_visible_profile_change_remains_a_tombstone_candidate(self) -> None:
profile = _profile("deactivated", scope_type="tenant", scope_id="tenant-1")
profile.is_active = False
entry = SimpleNamespace(
resource_id=profile.id,
tenant_id="tenant-1",
payload={"scope_type": "tenant", "scope_id": "tenant-1"},
)
principal = _Principal({"mail:profile:read"})
with patch(
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
return_value=EffectiveMailProfilePolicy(),
):
visible = router._profile_change_visible_to_principal( # noqa: SLF001
_Session([profile]), # type: ignore[arg-type]
entry=entry, # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
self.assertTrue(visible)
def test_stale_mailbox_cache_refreshes_synchronously_under_request_authorization(self) -> None:
principal = _Principal({"mail:mailbox:read", "mail:profile:use"})
session = _Session()
imap = SimpleNamespace(
host="imap.example.test",
port=993,
security=SimpleNamespace(value="tls"),
)
stale = SimpleNamespace(stale=True)
provider_result = SimpleNamespace(
host="imap.example.test",
port=993,
security="tls",
folders=[
SimpleNamespace(
name="INBOX",
flags=[],
message_count=0,
unseen_count=0,
)
],
detected_sent_folder=None,
)
with (
patch("govoplan_mail.backend.router._imap_config_for_principal", return_value=imap),
patch("govoplan_mail.backend.router.cached_mailbox_folders", return_value=stale),
patch("govoplan_mail.backend.router.list_imap_folders", return_value=provider_result) as provider,
patch("govoplan_mail.backend.router.cache_mailbox_folders") as cache,
):
response = router.list_profile_mailbox_folders(
"profile-1",
include_status=False,
refresh=False,
principal=principal, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
)
provider.assert_called_once_with(imap_config=imap, include_status=False)
cache.assert_called_once()
self.assertFalse(response.from_cache)
self.assertFalse(response.refreshing)
self.assertEqual(session.commits, 1)
def test_unauthorized_profile_test_fails_before_credentials_or_provider_effect(self) -> None:
principal = _Principal({"mail:profile:test", "mail:profile:use"})
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile_for_actor",
side_effect=MailProfileError("Mail-server profile not found"),
),
patch("govoplan_mail.backend.router.smtp_config_from_profile") as materialize,
patch("govoplan_mail.backend.router.test_smtp_login") as provider,
):
with self.assertRaises(HTTPException) as captured:
router.test_profile_smtp(
"other-user-profile",
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(captured.exception.status_code, 404)
materialize.assert_not_called()
provider.assert_not_called()
def test_unauthorized_mailbox_read_fails_before_credential_decryption(self) -> None:
principal = _Principal({"mail:mailbox:read", "mail:profile:use"})
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile_for_actor",
side_effect=MailProfileError("Mail-server profile not found"),
),
patch("govoplan_mail.backend.router.imap_config_from_profile") as materialize,
):
with self.assertRaisesRegex(MailProfileError, "not found"):
router._imap_config_for_principal( # noqa: SLF001 - authorization seam regression
_Session(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
profile_id="other-group-profile",
)
materialize.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -4,6 +4,8 @@ import unittest
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from govoplan_mail.backend.router import create_profile, deactivate_profile, update_profile
from govoplan_mail.backend.schemas import MailServerProfileCreateRequest, MailServerProfileUpdateRequest
@@ -48,6 +50,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
patch("govoplan_mail.backend.router._require_profile_write_scope"),
patch("govoplan_mail.backend.router._require_profile_credentials_scope"),
patch("govoplan_mail.backend.router.delete_mail_profile_credentials", return_value=()),
patch("govoplan_mail.backend.router.clear_mailbox_index") as clear_index,
patch("govoplan_mail.backend.router._record_mail_change") as record_change,
patch("govoplan_mail.backend.router._profile_response", return_value=self.profile),
):
@@ -56,6 +59,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
self.assertIs(result, self.profile)
self.assertEqual(session.commits, 1)
self.assertEqual(session.rollbacks, 0)
clear_index.assert_called_once_with(session, profile_id="profile-1")
record_change.assert_not_called()
def test_change_feed_reports_whether_credentials_were_deleted(self) -> None:
@@ -66,6 +70,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
patch("govoplan_mail.backend.router._require_profile_write_scope"),
patch("govoplan_mail.backend.router._require_profile_credentials_scope"),
patch("govoplan_mail.backend.router.delete_mail_profile_credentials", return_value=()),
patch("govoplan_mail.backend.router.clear_mailbox_index") as clear_index,
patch("govoplan_mail.backend.router._record_mail_change") as record_change,
patch("govoplan_mail.backend.router._profile_response", return_value=self.profile),
):
@@ -73,6 +78,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
self.assertFalse(record_change.call_args.kwargs["payload"]["credentials_deleted"])
self.assertEqual(record_change.call_args.kwargs["payload"]["deleted_credential_protocols"], [])
clear_index.assert_called_once_with(session, profile_id="profile-1")
def test_generic_secret_deletion_failure_rolls_back(self) -> None:
self.profile.is_active = True
@@ -89,6 +95,29 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
self.assertEqual(session.commits, 0)
self.assertEqual(session.rollbacks, 1)
def test_patch_deactivation_is_rejected_in_favor_of_audited_delete(self) -> None:
self.profile.is_active = True
self.profile.smtp_password_encrypted = "existing-ciphertext"
session = _Session()
payload = MailServerProfileUpdateRequest(is_active=False)
with (
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
patch("govoplan_mail.backend.router._require_profile_write_scope"),
self.assertRaises(HTTPException) as captured,
):
update_profile(
self.profile.id,
payload,
principal=self.principal,
session=session,
)
self.assertEqual(captured.exception.status_code, 422)
self.assertTrue(self.profile.is_active)
self.assertEqual(self.profile.smtp_password_encrypted, "existing-ciphertext")
self.assertEqual(session.commits, 0)
self.assertEqual(session.rollbacks, 1)
def test_system_profile_changes_are_instance_wide_in_the_change_feed(self) -> None:
system_profile = SimpleNamespace(
id="profile-system",
@@ -132,6 +161,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
patch("govoplan_mail.backend.router._require_profile_write_scope"),
patch("govoplan_mail.backend.router._require_profile_credentials_scope"),
patch("govoplan_mail.backend.router.delete_mail_profile_credentials", return_value=()),
patch("govoplan_mail.backend.router.clear_mailbox_index"),
patch("govoplan_mail.backend.router._record_mail_change") as delete_change,
patch("govoplan_mail.backend.router._profile_response", return_value=system_profile),
):