feat(mail): enforce owned profile self-service

This commit is contained in:
2026-07-21 20:48:46 +02:00
parent 193898b00d
commit 9f49591a98
10 changed files with 826 additions and 39 deletions

View File

@@ -6,7 +6,7 @@ import re
from dataclasses import dataclass, field
from typing import Any, Iterable
from sqlalchemy import and_, or_, text
from sqlalchemy import and_, or_, select, text
from sqlalchemy.orm import Session
from govoplan_core.admin.settings import get_system_settings
@@ -968,6 +968,7 @@ def mail_profile_visible_to_actor(
tenant_id: str,
user_id: str,
group_ids: Iterable[str] = (),
can_manage_own_profiles: bool = False,
can_manage_tenant_profiles: bool = False,
can_manage_system_profiles: bool = False,
tenant_admin: bool = False,
@@ -978,8 +979,11 @@ def mail_profile_visible_to_actor(
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.
profile administrator. In administrative views, a self-service actor may
also see their exact user-owned profiles after policy makes them unusable,
so they can repair or deactivate them. 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}))
@@ -1003,6 +1007,13 @@ def mail_profile_visible_to_actor(
return True
if administrative_visibility and scope_type != "system" and can_manage_tenant_profiles:
return True
if (
administrative_visibility
and can_manage_own_profiles
and scope_type == "user"
and scope_id == user_id
):
return True
if scope_type in {"system", "tenant"}:
return _profile_allowed_for_actor_policy(
session,
@@ -1099,6 +1110,7 @@ def list_mail_server_profiles(
campaign_id: str | None = None,
actor_user_id: str | None = None,
actor_group_ids: Iterable[str] = (),
actor_can_manage_own_profiles: bool = False,
actor_can_manage_tenant_profiles: bool = False,
actor_can_manage_system_profiles: bool = False,
actor_tenant_admin: bool = False,
@@ -1151,6 +1163,7 @@ def list_mail_server_profiles(
tenant_id=tenant_id,
user_id=actor_user_id,
group_ids=normalized_actor_group_ids,
can_manage_own_profiles=actor_can_manage_own_profiles,
can_manage_tenant_profiles=actor_can_manage_tenant_profiles,
can_manage_system_profiles=actor_can_manage_system_profiles,
tenant_admin=actor_tenant_admin,
@@ -1168,6 +1181,7 @@ def get_mail_server_profile_for_actor(
profile_id: str,
user_id: str,
group_ids: Iterable[str] = (),
can_manage_own_profiles: bool = False,
can_manage_tenant_profiles: bool = False,
can_manage_system_profiles: bool = False,
tenant_admin: bool = False,
@@ -1186,6 +1200,7 @@ def get_mail_server_profile_for_actor(
tenant_id=tenant_id,
user_id=user_id,
group_ids=group_ids,
can_manage_own_profiles=can_manage_own_profiles,
can_manage_tenant_profiles=can_manage_tenant_profiles,
can_manage_system_profiles=can_manage_system_profiles,
tenant_admin=tenant_admin,
@@ -1204,8 +1219,22 @@ def get_mail_server_profile(
tenant_id: str,
profile_id: str,
require_active: bool = False,
for_update: bool = False,
mutation_owner_user_id: str | None = None,
can_manage_tenant_profiles: bool = False,
can_manage_system_profiles: bool = False,
) -> MailServerProfile:
profile = session.get(MailServerProfile, profile_id)
if for_update:
statement = _mail_server_profile_mutation_statement(
tenant_id=tenant_id,
profile_id=profile_id,
owner_user_id=mutation_owner_user_id,
can_manage_tenant_profiles=can_manage_tenant_profiles,
can_manage_system_profiles=can_manage_system_profiles,
)
profile = session.execute(statement).scalar_one_or_none()
else:
profile = session.get(MailServerProfile, profile_id)
if profile is None or (_profile_scope_type(profile) != "system" and profile.tenant_id != tenant_id):
raise MailProfileError("Mail-server profile not found")
if require_active and not profile.is_active:
@@ -1213,6 +1242,61 @@ def get_mail_server_profile(
return profile
def _mail_server_profile_mutation_statement(
*,
tenant_id: str,
profile_id: str,
owner_user_id: str | None,
can_manage_tenant_profiles: bool,
can_manage_system_profiles: bool,
):
"""Build the authorization-bounded row lock used by profile mutations.
The selector prevents a self-service actor from locking another owner's
row merely by guessing its identifier. ``populate_existing`` is essential:
after PostgreSQL waits for a concurrent writer, authorization and secret
checks must observe that writer's committed password and active state, not
an older identity-map snapshot.
"""
allowed_scopes = []
if can_manage_system_profiles:
allowed_scopes.append(
and_(
MailServerProfile.scope_type == "system",
MailServerProfile.tenant_id.is_(None),
)
)
if can_manage_tenant_profiles:
allowed_scopes.append(
and_(
MailServerProfile.tenant_id == tenant_id,
MailServerProfile.scope_type != "system",
)
)
if owner_user_id:
allowed_scopes.append(
and_(
MailServerProfile.tenant_id == tenant_id,
MailServerProfile.scope_type == "user",
MailServerProfile.scope_id == owner_user_id,
)
)
if not allowed_scopes:
# Preserve a non-enumerating not-found result without locking an
# unauthorized row.
allowed_scopes.append(MailServerProfile.id.is_(None))
return (
select(MailServerProfile)
.where(
MailServerProfile.id == profile_id,
or_(*allowed_scopes),
)
.with_for_update()
.execution_options(populate_existing=True)
)
def ensure_mail_profile_allowed_for_campaign(
session: Session,
*,
@@ -1764,6 +1848,8 @@ def delete_mail_profile_credentials_for_retirement(session: Session) -> int:
)
)
.order_by(MailServerProfile.id.asc())
.with_for_update()
.populate_existing()
.all()
)
deleted = 0