feat(mail): enforce owned profile self-service
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user