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

@@ -14,7 +14,9 @@ from govoplan_mail.backend.mail_profiles import (
MAIL_POLICY_DOC_SCOPES = ("mail:profile:read", "admin:policies:read", "system:settings:read")
MAIL_PROFILE_READ_SCOPE = "mail:profile:read"
MAIL_PROFILE_WRITE_SCOPE = "mail:profile:write"
MAIL_SECRET_MANAGE_SCOPE = "mail:secret:manage"
MAIL_PROFILE_WRITE_OWN_SCOPE = "mail:profile:write_own"
MAIL_SECRET_MANAGE_SCOPE = "mail:secret:manage" # noqa: S105 -- permission identifier, not a credential
MAIL_SECRET_MANAGE_OWN_SCOPE = "mail:secret:manage_own" # noqa: S105 -- permission identifier, not a credential
MAIL_PROFILE_TEST_SCOPE = "mail:profile:test"
MAIL_PROFILE_USE_SCOPE = "mail:profile:use"
_HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts"))
@@ -120,7 +122,9 @@ def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTo
if context.documentation_type != "user":
return None
principal = context.principal
if not _has_all_scopes(principal, (MAIL_PROFILE_READ_SCOPE, MAIL_PROFILE_WRITE_SCOPE)):
if not _has_any_scope(principal, (MAIL_PROFILE_WRITE_SCOPE, MAIL_PROFILE_WRITE_OWN_SCOPE)):
return None
if not _has_all_scopes(principal, (MAIL_PROFILE_READ_SCOPE,)):
return None
tenant_id = str(getattr(principal, "tenant_id", "") or "")
user_id = str(getattr(getattr(principal, "user", None), "id", "") or "")
@@ -143,7 +147,10 @@ def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTo
if not policy.allow_user_profiles:
return None
can_manage_credentials = _has_any_scope(principal, (MAIL_SECRET_MANAGE_SCOPE,))
can_manage_credentials = _has_any_scope(
principal,
(MAIL_SECRET_MANAGE_SCOPE, MAIL_SECRET_MANAGE_OWN_SCOPE),
)
can_test_profile = _has_all_scopes(principal, (MAIL_PROFILE_TEST_SCOPE, MAIL_PROFILE_USE_SCOPE))
can_use_profile = _has_any_scope(principal, (MAIL_PROFILE_USE_SCOPE,))
approval_required = bool(policy.allowed_profile_id_sets)
@@ -175,7 +182,8 @@ def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTo
conditions=(
DocumentationCondition(
required_modules=("mail",),
required_scopes=(MAIL_PROFILE_READ_SCOPE, MAIL_PROFILE_WRITE_SCOPE),
required_scopes=(MAIL_PROFILE_READ_SCOPE,),
any_scopes=(MAIL_PROFILE_WRITE_SCOPE, MAIL_PROFILE_WRITE_OWN_SCOPE),
configuration_keys=("mail_profile_policy",),
),
),

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

View File

@@ -80,7 +80,17 @@ PERMISSIONS = (
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."),
_permission("mail:mailbox:read", "Read mailboxes", "List IMAP folders and inspect messages without mutating mailbox state."),
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
_permission(
"mail:profile:write_own",
"Manage own mail profiles",
"Create, edit, and deactivate only the current account's user-scoped mail profiles within effective policy.",
),
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."),
_permission(
"mail:secret:manage_own",
"Manage own mail secrets",
"Create, replace, and delete credentials only for the current account's user-scoped mail profiles.",
),
)
ROLE_TEMPLATES = (
@@ -103,6 +113,19 @@ ROLE_TEMPLATES = (
description="Use and test approved mail profiles without reading secrets.",
permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test", "mail:mailbox:read"),
),
RoleTemplate(
slug="mail_profile_self_service",
name="Mail profile self-service user",
description="Create and manage only personal Mail profiles and credentials within effective policy.",
permissions=(
"mail:profile:read",
"mail:profile:use",
"mail:profile:test",
"mail:mailbox:read",
"mail:profile:write_own",
"mail:secret:manage_own",
),
),
)
@@ -126,7 +149,7 @@ def _mail_router(context: ModuleContext):
manifest = ModuleManifest(
id="mail",
name="Mail",
version="0.1.9",
version="0.1.10",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "addresses"),
provides_interfaces=(
@@ -231,7 +254,12 @@ manifest = ModuleManifest(
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=("mail:profile:read", "mail:profile:use", "mail:profile:write"),
any_scopes=(
"mail:profile:read",
"mail:profile:use",
"mail:profile:write",
"mail:profile:write_own",
),
),
),
links=(

View File

@@ -127,18 +127,133 @@ def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes))
def _require_profile_write_scope(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_scope(principal, "system:settings:write")
else:
_require_scope(principal, "mail:profile:write")
def _is_own_user_scope(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -> bool:
return scope_type == "user" and scope_id == principal.user.id
def _require_profile_credentials_scope(principal: ApiPrincipal, scope_type: str) -> None:
def _require_profile_write_scope(
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None = None,
) -> None:
if scope_type == "system":
_require_scope(principal, "system:settings:write")
else:
_require_scope(principal, "mail:secret:manage")
return
if has_scope(principal, "mail:profile:write"):
return
if _is_own_user_scope(principal, scope_type, scope_id) and has_scope(
principal, "mail:profile:write_own"
):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing scope: mail:profile:write",
)
def _require_profile_credentials_scope(
principal: ApiPrincipal,
scope_type: str,
scope_id: str | None = None,
) -> None:
if scope_type == "system":
_require_scope(principal, "system:settings:write")
return
if has_scope(principal, "mail:secret:manage"):
return
if _is_own_user_scope(principal, scope_type, scope_id) and has_scope(
principal, "mail:secret:manage_own"
):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing scope: mail:secret:manage",
)
def _profile_for_mutation(
session: Session,
*,
principal: ApiPrincipal,
profile_id: str,
):
"""Resolve a profile through the actor's mutation boundary.
Mutation routes deliberately do not apply effective transport policy here:
an owner must still be able to repair or deactivate a profile after policy
becomes more restrictive. Missing and non-owned identifiers are kept
indistinguishable for self-service actors.
"""
try:
profile = get_mail_server_profile(
session,
tenant_id=principal.tenant_id,
profile_id=profile_id,
for_update=True,
mutation_owner_user_id=(
principal.user.id
if has_scope(principal, "mail:profile:write_own")
else None
),
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
)
_require_profile_write_scope(
principal,
profile.scope_type or "tenant",
profile.scope_id,
)
except (MailProfileError, HTTPException) as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Mail-server profile not found",
) from exc
return profile
_TRANSPORT_ENDPOINT_FIELDS = ("host", "port", "security")
def _transport_endpoint_changed(
current: dict[str, Any] | None,
updated: SmtpConfig | ImapConfig,
) -> bool:
current_payload = current or {}
updated_payload = updated.model_dump(mode="json")
return any(
current_payload.get(field) != updated_payload.get(field)
for field in _TRANSPORT_ENDPOINT_FIELDS
)
def _profile_update_requires_credentials_scope(
profile,
*,
payload: MailServerProfileUpdateRequest,
smtp: SmtpConfig | None,
imap: ImapConfig | None,
) -> bool:
if payload.credentials_supplied() or payload.clear_imap:
return True
if (
smtp is not None
and getattr(profile, "smtp_password_encrypted", None)
and _transport_endpoint_changed(profile.smtp_config, smtp)
):
return True
return bool(
imap is not None
and getattr(profile, "imap_password_encrypted", None)
and _transport_endpoint_changed(profile.imap_config, imap)
)
def _profile_has_stored_credentials(profile) -> bool:
return bool(
getattr(profile, "smtp_password_encrypted", None)
or getattr(profile, "imap_password_encrypted", None)
)
def _require_mailbox_read_scope(principal: ApiPrincipal) -> None:
@@ -154,6 +269,10 @@ def _principal_can_manage_tenant_profiles(principal: ApiPrincipal) -> bool:
return has_scope(principal, "mail:profile:write")
def _principal_can_manage_own_profiles(principal: ApiPrincipal) -> bool:
return has_scope(principal, "mail:profile:write_own")
def _principal_can_manage_system_profiles(principal: ApiPrincipal) -> bool:
return has_scope(principal, "system:settings:write")
@@ -166,6 +285,7 @@ def _profile_actor_kwargs(
return {
"actor_user_id": principal.user.id,
"actor_group_ids": principal.group_ids,
"actor_can_manage_own_profiles": _principal_can_manage_own_profiles(principal),
"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),
@@ -187,6 +307,7 @@ def _get_profile_for_principal(
profile_id=profile_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_manage_own_profiles=_principal_can_manage_own_profiles(principal),
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),
@@ -444,6 +565,7 @@ def _profile_change_visible_to_principal(
tenant_id=principal.tenant_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_manage_own_profiles=_principal_can_manage_own_profiles(principal),
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),
@@ -812,7 +934,12 @@ def create_profile(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_profile_write_scope(principal, payload.scope_type)
authorization_scope_id = (
payload.scope_id or principal.user.id
if payload.scope_type == "user"
else payload.scope_id
)
_require_profile_write_scope(principal, payload.scope_type, authorization_scope_id)
_require_campaign_profile_mutation_access(
session,
principal=principal,
@@ -820,7 +947,11 @@ def create_profile(
scope_id=payload.scope_id,
)
if payload.credentials_supplied():
_require_profile_credentials_scope(principal, payload.scope_type)
_require_profile_credentials_scope(
principal,
payload.scope_type,
authorization_scope_id,
)
try:
profile = create_mail_server_profile(
session,
@@ -881,16 +1012,17 @@ def update_profile(
session: Session = Depends(get_session),
):
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")
profile = _profile_for_mutation(
session,
principal=principal,
profile_id=profile_id,
)
_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()
if payload.smtp is None and "smtp" in payload.credentials.model_fields_set:
smtp_data = dict(profile.smtp_config or {})
@@ -901,6 +1033,17 @@ def update_profile(
imap_data = dict(profile.imap_config or {})
imap_data.update(payload.credentials.imap.model_dump(mode="json", exclude_unset=True))
imap_config = ImapConfig.model_validate(imap_data)
if _profile_update_requires_credentials_scope(
profile,
payload=payload,
smtp=smtp_config,
imap=imap_config,
):
_require_profile_credentials_scope(
principal,
profile.scope_type or "tenant",
profile.scope_id,
)
update_mail_server_profile(
session,
profile,
@@ -943,15 +1086,23 @@ def deactivate_profile(
session: Session = Depends(get_session),
):
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")
profile = _profile_for_mutation(
session,
principal=principal,
profile_id=profile_id,
)
_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")
if _profile_has_stored_credentials(profile):
_require_profile_credentials_scope(
principal,
profile.scope_type or "tenant",
profile.scope_id,
)
was_active = bool(profile.is_active)
profile.is_active = False
deleted_protocols = delete_mail_profile_credentials(