Compare commits
3 Commits
193898b00d
...
64009471bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 64009471bb | |||
| 2e5921b3ff | |||
| 9f49591a98 |
12
README.md
12
README.md
@@ -11,7 +11,7 @@ GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile m
|
|||||||
This repository owns:
|
This repository owns:
|
||||||
|
|
||||||
- backend module manifest `mail`
|
- backend module manifest `mail`
|
||||||
- mail permissions such as `mail:profile:read`, `mail:profile:write`, `mail:profile:use`, `mail:profile:test`, and `mail:mailbox:read`
|
- mail permissions such as `mail:profile:read`, `mail:profile:write_own`, `mail:profile:write`, `mail:profile:use`, `mail:profile:test`, and `mail:mailbox:read`
|
||||||
- SMTP/IMAP profile models, policy checks, encrypted credential storage, and profile resolution
|
- SMTP/IMAP profile models, policy checks, encrypted credential storage, and profile resolution
|
||||||
- SMTP send and IMAP append adapters, including mock transports for development
|
- SMTP send and IMAP append adapters, including mock transports for development
|
||||||
- development mock mailbox endpoints used by test-send flows
|
- development mock mailbox endpoints used by test-send flows
|
||||||
@@ -55,6 +55,16 @@ a non-secret audit event. Destructive module retirement applies the same rule
|
|||||||
to every remaining profile before any Mail table is dropped; a scrub or audit
|
to every remaining profile before any Mail table is dropped; a scrub or audit
|
||||||
failure blocks retirement.
|
failure blocks retirement.
|
||||||
|
|
||||||
|
Personal profile self-service is a distinct authorization path. An actor with
|
||||||
|
`mail:profile:write_own` can mutate only a user-scoped profile whose scope id is
|
||||||
|
their current tenant membership id; `mail:secret:manage_own` applies the same
|
||||||
|
ownership check to credentials. Neither scope permits profile-policy changes or
|
||||||
|
management of tenant, group, campaign, system, or another user's profiles.
|
||||||
|
Changing an SMTP/IMAP endpoint while a stored password remains is a secret
|
||||||
|
operation and requires the matching credential permission. Deactivating a
|
||||||
|
credential-free profile needs only profile-write authority; if a password will
|
||||||
|
be scrubbed, credential authority is required and the deletion is audited.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Install through the core environment:
|
Install through the core environment:
|
||||||
|
|||||||
@@ -142,24 +142,38 @@ The supplied templates are:
|
|||||||
|
|
||||||
- **Mail profile user:** read/use/test approved profiles and read permitted
|
- **Mail profile user:** read/use/test approved profiles and read permitted
|
||||||
mailboxes without reading secrets.
|
mailboxes without reading secrets.
|
||||||
|
- **Mail profile self-service user:** additionally create, edit, deactivate,
|
||||||
|
and manage credentials only for the current account's own user-scoped
|
||||||
|
profiles, subject to the effective Mail policy.
|
||||||
- **Mail profile administrator:** additionally create/update profiles and
|
- **Mail profile administrator:** additionally create/update profiles and
|
||||||
create/replace encrypted credentials.
|
create/replace encrypted credentials across tenant-owned scopes.
|
||||||
|
|
||||||
The specific permissions are `mail:profile:read`, `mail:profile:use`,
|
The specific permissions are `mail:profile:read`, `mail:profile:use`,
|
||||||
`mail:profile:test`, `mail:mailbox:read`, `mail:profile:write`, and
|
`mail:profile:test`, `mail:mailbox:read`, `mail:profile:write_own`,
|
||||||
`mail:secret:manage`. System-scoped definitions use the corresponding system
|
`mail:secret:manage_own`, `mail:profile:write`, and `mail:secret:manage`.
|
||||||
settings authority. Keep secret management separate when an institution wants
|
The `_own` permissions are enforced against the authenticated membership id and
|
||||||
profile metadata administrators not to know or replace credentials.
|
never authorize a tenant, group, campaign, system, or another user's profile.
|
||||||
|
They also do not authorize profile-policy changes. System-scoped definitions
|
||||||
|
use the corresponding system settings authority. Keep secret management
|
||||||
|
separate when an institution wants profile metadata administrators not to know
|
||||||
|
or replace credentials.
|
||||||
|
|
||||||
|
That separation is fail-closed for transport rebinding: changing an SMTP or
|
||||||
|
IMAP host, port, or security mode while the profile retains a stored password
|
||||||
|
requires the matching secret-management permission. A credential-free profile
|
||||||
|
can be deactivated with profile-write authority alone; deactivation that
|
||||||
|
scrubs a stored password also requires secret-management authority and records
|
||||||
|
the deletion in the audit log.
|
||||||
|
|
||||||
### Create or change a profile
|
### Create or change a profile
|
||||||
|
|
||||||
The configured Help Center exposes **Create a custom Mail profile** only when
|
The configured Help Center exposes **Create a custom Mail profile** only when
|
||||||
the current actor has profile-write authority and the effective user-scope
|
the current actor has broad or self-service profile-write authority and the
|
||||||
policy permits user profiles. It states the active SMTP/IMAP hostname
|
effective user-scope policy permits user profiles. It states the active SMTP/IMAP hostname
|
||||||
allow-list groups and deny rules, plus the actor's separate credential, test,
|
allow-list groups and deny rules, plus the actor's separate credential, test,
|
||||||
use, and approval requirements. The Settings task creates in the current
|
use, and approval requirements. The Settings task creates in the current
|
||||||
account's user scope; `mail:profile:write` itself remains broad profile
|
account's user scope. Grant `mail:profile:write_own` for self-service;
|
||||||
administration authority, not an API-enforced self-only permission.
|
`mail:profile:write` remains broad profile administration authority.
|
||||||
|
|
||||||
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
|
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
|
||||||
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
|
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.9",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-mail"
|
name = "govoplan-mail"
|
||||||
version = "0.1.9"
|
version = "0.1.10"
|
||||||
description = "GovOPlaN mail module with backend and WebUI integration."
|
description = "GovOPlaN mail module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -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_POLICY_DOC_SCOPES = ("mail:profile:read", "admin:policies:read", "system:settings:read")
|
||||||
MAIL_PROFILE_READ_SCOPE = "mail:profile:read"
|
MAIL_PROFILE_READ_SCOPE = "mail:profile:read"
|
||||||
MAIL_PROFILE_WRITE_SCOPE = "mail:profile:write"
|
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_TEST_SCOPE = "mail:profile:test"
|
||||||
MAIL_PROFILE_USE_SCOPE = "mail:profile:use"
|
MAIL_PROFILE_USE_SCOPE = "mail:profile:use"
|
||||||
_HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts"))
|
_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":
|
if context.documentation_type != "user":
|
||||||
return None
|
return None
|
||||||
principal = context.principal
|
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
|
return None
|
||||||
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||||
user_id = str(getattr(getattr(principal, "user", None), "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:
|
if not policy.allow_user_profiles:
|
||||||
return None
|
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_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,))
|
can_use_profile = _has_any_scope(principal, (MAIL_PROFILE_USE_SCOPE,))
|
||||||
approval_required = bool(policy.allowed_profile_id_sets)
|
approval_required = bool(policy.allowed_profile_id_sets)
|
||||||
@@ -175,7 +182,8 @@ def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTo
|
|||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("mail",),
|
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",),
|
configuration_keys=("mail_profile_policy",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import re
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from sqlalchemy import and_, or_, text
|
from sqlalchemy import and_, or_, select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.admin.settings import get_system_settings
|
from govoplan_core.admin.settings import get_system_settings
|
||||||
@@ -968,6 +968,7 @@ def mail_profile_visible_to_actor(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
group_ids: Iterable[str] = (),
|
group_ids: Iterable[str] = (),
|
||||||
|
can_manage_own_profiles: bool = False,
|
||||||
can_manage_tenant_profiles: bool = False,
|
can_manage_tenant_profiles: bool = False,
|
||||||
can_manage_system_profiles: bool = False,
|
can_manage_system_profiles: bool = False,
|
||||||
tenant_admin: 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
|
System and tenant profiles are shared definitions. Narrower profiles are
|
||||||
visible only to their owner context, unless the actor is a tenant-wide Mail
|
visible only to their owner context, unless the actor is a tenant-wide Mail
|
||||||
profile administrator. Campaign visibility is delegated to Campaign's ACL
|
profile administrator. In administrative views, a self-service actor may
|
||||||
capability so Mail never guesses another module's object permissions.
|
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}))
|
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
|
return True
|
||||||
if administrative_visibility and scope_type != "system" and can_manage_tenant_profiles:
|
if administrative_visibility and scope_type != "system" and can_manage_tenant_profiles:
|
||||||
return True
|
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"}:
|
if scope_type in {"system", "tenant"}:
|
||||||
return _profile_allowed_for_actor_policy(
|
return _profile_allowed_for_actor_policy(
|
||||||
session,
|
session,
|
||||||
@@ -1099,6 +1110,7 @@ def list_mail_server_profiles(
|
|||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
actor_user_id: str | None = None,
|
actor_user_id: str | None = None,
|
||||||
actor_group_ids: Iterable[str] = (),
|
actor_group_ids: Iterable[str] = (),
|
||||||
|
actor_can_manage_own_profiles: bool = False,
|
||||||
actor_can_manage_tenant_profiles: bool = False,
|
actor_can_manage_tenant_profiles: bool = False,
|
||||||
actor_can_manage_system_profiles: bool = False,
|
actor_can_manage_system_profiles: bool = False,
|
||||||
actor_tenant_admin: bool = False,
|
actor_tenant_admin: bool = False,
|
||||||
@@ -1151,6 +1163,7 @@ def list_mail_server_profiles(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
user_id=actor_user_id,
|
user_id=actor_user_id,
|
||||||
group_ids=normalized_actor_group_ids,
|
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_tenant_profiles=actor_can_manage_tenant_profiles,
|
||||||
can_manage_system_profiles=actor_can_manage_system_profiles,
|
can_manage_system_profiles=actor_can_manage_system_profiles,
|
||||||
tenant_admin=actor_tenant_admin,
|
tenant_admin=actor_tenant_admin,
|
||||||
@@ -1168,6 +1181,7 @@ def get_mail_server_profile_for_actor(
|
|||||||
profile_id: str,
|
profile_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
group_ids: Iterable[str] = (),
|
group_ids: Iterable[str] = (),
|
||||||
|
can_manage_own_profiles: bool = False,
|
||||||
can_manage_tenant_profiles: bool = False,
|
can_manage_tenant_profiles: bool = False,
|
||||||
can_manage_system_profiles: bool = False,
|
can_manage_system_profiles: bool = False,
|
||||||
tenant_admin: bool = False,
|
tenant_admin: bool = False,
|
||||||
@@ -1186,6 +1200,7 @@ def get_mail_server_profile_for_actor(
|
|||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
group_ids=group_ids,
|
group_ids=group_ids,
|
||||||
|
can_manage_own_profiles=can_manage_own_profiles,
|
||||||
can_manage_tenant_profiles=can_manage_tenant_profiles,
|
can_manage_tenant_profiles=can_manage_tenant_profiles,
|
||||||
can_manage_system_profiles=can_manage_system_profiles,
|
can_manage_system_profiles=can_manage_system_profiles,
|
||||||
tenant_admin=tenant_admin,
|
tenant_admin=tenant_admin,
|
||||||
@@ -1204,8 +1219,22 @@ def get_mail_server_profile(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
require_active: bool = False,
|
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:
|
) -> 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):
|
if profile is None or (_profile_scope_type(profile) != "system" and profile.tenant_id != tenant_id):
|
||||||
raise MailProfileError("Mail-server profile not found")
|
raise MailProfileError("Mail-server profile not found")
|
||||||
if require_active and not profile.is_active:
|
if require_active and not profile.is_active:
|
||||||
@@ -1213,6 +1242,61 @@ def get_mail_server_profile(
|
|||||||
return 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(
|
def ensure_mail_profile_allowed_for_campaign(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -1764,6 +1848,8 @@ def delete_mail_profile_credentials_for_retirement(session: Session) -> int:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by(MailServerProfile.id.asc())
|
.order_by(MailServerProfile.id.asc())
|
||||||
|
.with_for_update()
|
||||||
|
.populate_existing()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
deleted = 0
|
deleted = 0
|
||||||
|
|||||||
@@ -80,7 +80,17 @@ PERMISSIONS = (
|
|||||||
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."),
|
_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: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", "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", "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 = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -103,6 +113,19 @@ ROLE_TEMPLATES = (
|
|||||||
description="Use and test approved mail profiles without reading secrets.",
|
description="Use and test approved mail profiles without reading secrets.",
|
||||||
permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test", "mail:mailbox:read"),
|
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(
|
manifest = ModuleManifest(
|
||||||
id="mail",
|
id="mail",
|
||||||
name="Mail",
|
name="Mail",
|
||||||
version="0.1.9",
|
version="0.1.10",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("campaigns", "addresses"),
|
optional_dependencies=("campaigns", "addresses"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
@@ -231,7 +254,12 @@ manifest = ModuleManifest(
|
|||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("mail",),
|
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=(
|
links=(
|
||||||
|
|||||||
@@ -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))
|
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:
|
def _is_own_user_scope(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -> bool:
|
||||||
if scope_type == "system":
|
return scope_type == "user" and scope_id == principal.user.id
|
||||||
_require_scope(principal, "system:settings:write")
|
|
||||||
else:
|
|
||||||
_require_scope(principal, "mail:profile:write")
|
|
||||||
|
|
||||||
|
|
||||||
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":
|
if scope_type == "system":
|
||||||
_require_scope(principal, "system:settings:write")
|
_require_scope(principal, "system:settings:write")
|
||||||
else:
|
return
|
||||||
_require_scope(principal, "mail:secret:manage")
|
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:
|
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")
|
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:
|
def _principal_can_manage_system_profiles(principal: ApiPrincipal) -> bool:
|
||||||
return has_scope(principal, "system:settings:write")
|
return has_scope(principal, "system:settings:write")
|
||||||
|
|
||||||
@@ -166,6 +285,7 @@ def _profile_actor_kwargs(
|
|||||||
return {
|
return {
|
||||||
"actor_user_id": principal.user.id,
|
"actor_user_id": principal.user.id,
|
||||||
"actor_group_ids": principal.group_ids,
|
"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_tenant_profiles": _principal_can_manage_tenant_profiles(principal),
|
||||||
"actor_can_manage_system_profiles": _principal_can_manage_system_profiles(principal),
|
"actor_can_manage_system_profiles": _principal_can_manage_system_profiles(principal),
|
||||||
"actor_tenant_admin": _principal_is_tenant_admin(principal),
|
"actor_tenant_admin": _principal_is_tenant_admin(principal),
|
||||||
@@ -187,6 +307,7 @@ def _get_profile_for_principal(
|
|||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
user_id=principal.user.id,
|
user_id=principal.user.id,
|
||||||
group_ids=principal.group_ids,
|
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_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
||||||
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
||||||
tenant_admin=_principal_is_tenant_admin(principal),
|
tenant_admin=_principal_is_tenant_admin(principal),
|
||||||
@@ -444,6 +565,7 @@ def _profile_change_visible_to_principal(
|
|||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
user_id=principal.user.id,
|
user_id=principal.user.id,
|
||||||
group_ids=principal.group_ids,
|
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_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
||||||
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
||||||
tenant_admin=_principal_is_tenant_admin(principal),
|
tenant_admin=_principal_is_tenant_admin(principal),
|
||||||
@@ -812,7 +934,12 @@ def create_profile(
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
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(
|
_require_campaign_profile_mutation_access(
|
||||||
session,
|
session,
|
||||||
principal=principal,
|
principal=principal,
|
||||||
@@ -820,7 +947,11 @@ def create_profile(
|
|||||||
scope_id=payload.scope_id,
|
scope_id=payload.scope_id,
|
||||||
)
|
)
|
||||||
if payload.credentials_supplied():
|
if payload.credentials_supplied():
|
||||||
_require_profile_credentials_scope(principal, payload.scope_type)
|
_require_profile_credentials_scope(
|
||||||
|
principal,
|
||||||
|
payload.scope_type,
|
||||||
|
authorization_scope_id,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
profile = create_mail_server_profile(
|
profile = create_mail_server_profile(
|
||||||
session,
|
session,
|
||||||
@@ -881,16 +1012,17 @@ def update_profile(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
profile = _profile_for_mutation(
|
||||||
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
session,
|
||||||
|
principal=principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
)
|
||||||
_require_campaign_profile_mutation_access(
|
_require_campaign_profile_mutation_access(
|
||||||
session,
|
session,
|
||||||
principal=principal,
|
principal=principal,
|
||||||
scope_type=profile.scope_type or "tenant",
|
scope_type=profile.scope_type or "tenant",
|
||||||
scope_id=profile.scope_id,
|
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()
|
smtp_config = payload.smtp_config()
|
||||||
if payload.smtp is None and "smtp" in payload.credentials.model_fields_set:
|
if payload.smtp is None and "smtp" in payload.credentials.model_fields_set:
|
||||||
smtp_data = dict(profile.smtp_config or {})
|
smtp_data = dict(profile.smtp_config or {})
|
||||||
@@ -901,6 +1033,17 @@ def update_profile(
|
|||||||
imap_data = dict(profile.imap_config or {})
|
imap_data = dict(profile.imap_config or {})
|
||||||
imap_data.update(payload.credentials.imap.model_dump(mode="json", exclude_unset=True))
|
imap_data.update(payload.credentials.imap.model_dump(mode="json", exclude_unset=True))
|
||||||
imap_config = ImapConfig.model_validate(imap_data)
|
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(
|
update_mail_server_profile(
|
||||||
session,
|
session,
|
||||||
profile,
|
profile,
|
||||||
@@ -943,15 +1086,23 @@ def deactivate_profile(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
profile = _profile_for_mutation(
|
||||||
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
session,
|
||||||
|
principal=principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
)
|
||||||
_require_campaign_profile_mutation_access(
|
_require_campaign_profile_mutation_access(
|
||||||
session,
|
session,
|
||||||
principal=principal,
|
principal=principal,
|
||||||
scope_type=profile.scope_type or "tenant",
|
scope_type=profile.scope_type or "tenant",
|
||||||
scope_id=profile.scope_id,
|
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)
|
was_active = bool(profile.is_active)
|
||||||
profile.is_active = False
|
profile.is_active = False
|
||||||
deleted_protocols = delete_mail_profile_credentials(
|
deleted_protocols = delete_mail_profile_credentials(
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ class MailRuntimeDocumentationTests(unittest.TestCase):
|
|||||||
self.assertEqual(task.metadata["help_contexts"], ["mail.profiles", "app.settings"])
|
self.assertEqual(task.metadata["help_contexts"], ["mail.profiles", "app.settings"])
|
||||||
self.assertIn("current account's user scope", task.metadata["prerequisites"][0])
|
self.assertIn("current account's user scope", task.metadata["prerequisites"][0])
|
||||||
|
|
||||||
|
self_service = self.topics(
|
||||||
|
self.context(
|
||||||
|
{"mail:profile:read", "mail:profile:write_own"}
|
||||||
|
),
|
||||||
|
enabled,
|
||||||
|
)
|
||||||
|
self.assertIn("mail.workflow.create-custom-profile", self_service)
|
||||||
|
condition = self_service["mail.workflow.create-custom-profile"].conditions[0]
|
||||||
|
self.assertEqual(condition.required_scopes, ("mail:profile:read",))
|
||||||
|
self.assertEqual(
|
||||||
|
condition.any_scopes,
|
||||||
|
("mail:profile:write", "mail:profile:write_own"),
|
||||||
|
)
|
||||||
|
|
||||||
def test_custom_profile_task_distinguishes_secret_test_and_use_authority(self) -> None:
|
def test_custom_profile_task_distinguishes_secret_test_and_use_authority(self) -> None:
|
||||||
policy = EffectiveMailProfilePolicy()
|
policy = EffectiveMailProfilePolicy()
|
||||||
cases = (
|
cases = (
|
||||||
@@ -79,6 +93,7 @@ class MailRuntimeDocumentationTests(unittest.TestCase):
|
|||||||
({"mail:profile:use"}, False, False, True),
|
({"mail:profile:use"}, False, False, True),
|
||||||
({"mail:profile:test", "mail:profile:use"}, False, True, True),
|
({"mail:profile:test", "mail:profile:use"}, False, True, True),
|
||||||
({"mail:secret:manage", "mail:profile:test", "mail:profile:use"}, True, True, True),
|
({"mail:secret:manage", "mail:profile:test", "mail:profile:use"}, True, True, True),
|
||||||
|
({"mail:secret:manage_own"}, True, False, False),
|
||||||
)
|
)
|
||||||
for extra_scopes, credentials, testing, use in cases:
|
for extra_scopes, credentials, testing, use in cases:
|
||||||
with self.subTest(extra_scopes=extra_scopes):
|
with self.subTest(extra_scopes=extra_scopes):
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ class MailManifestTests(unittest.TestCase):
|
|||||||
for interface in manifest.provides_interfaces
|
for interface in manifest.provides_interfaces
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
permissions = {permission.scope for permission in manifest.permissions}
|
||||||
|
self.assertIn("mail:profile:write_own", permissions)
|
||||||
|
self.assertIn("mail:secret:manage_own", permissions)
|
||||||
|
roles = {template.slug: template for template in manifest.role_templates}
|
||||||
|
self.assertEqual(
|
||||||
|
set(roles["mail_profile_self_service"].permissions),
|
||||||
|
{
|
||||||
|
"mail:profile:read",
|
||||||
|
"mail:profile:use",
|
||||||
|
"mail:profile:test",
|
||||||
|
"mail:mailbox:read",
|
||||||
|
"mail:profile:write_own",
|
||||||
|
"mail:secret:manage_own",
|
||||||
|
},
|
||||||
|
)
|
||||||
topics = {topic.id: topic for topic in manifest.documentation}
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import ANY, patch
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
from govoplan_mail.backend import router
|
from govoplan_mail.backend import router
|
||||||
|
from govoplan_mail.backend import mail_profiles
|
||||||
from govoplan_mail.backend.mail_profiles import (
|
from govoplan_mail.backend.mail_profiles import (
|
||||||
EffectiveMailProfilePolicy,
|
EffectiveMailProfilePolicy,
|
||||||
MailProfileError,
|
MailProfileError,
|
||||||
|
get_mail_server_profile_for_actor,
|
||||||
list_mail_server_profiles,
|
list_mail_server_profiles,
|
||||||
mail_profile_visible_to_actor,
|
mail_profile_visible_to_actor,
|
||||||
)
|
)
|
||||||
@@ -35,6 +38,8 @@ def _profile(
|
|||||||
name=profile_id,
|
name=profile_id,
|
||||||
smtp_config={"host": "smtp.example.test"},
|
smtp_config={"host": "smtp.example.test"},
|
||||||
imap_config={"host": "imap.example.test"},
|
imap_config={"host": "imap.example.test"},
|
||||||
|
smtp_password_encrypted=None,
|
||||||
|
imap_password_encrypted=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,11 +72,15 @@ class _Session:
|
|||||||
def rollback(self):
|
def rollback(self):
|
||||||
self.rollbacks += 1
|
self.rollbacks += 1
|
||||||
|
|
||||||
|
def refresh(self, _value):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class _Principal:
|
class _Principal:
|
||||||
tenant_id = "tenant-1"
|
tenant_id = "tenant-1"
|
||||||
user = SimpleNamespace(id="user-1")
|
user = SimpleNamespace(id="user-1")
|
||||||
group_ids = frozenset({"group-1"})
|
group_ids = frozenset({"group-1"})
|
||||||
|
api_key_id = None
|
||||||
|
|
||||||
def __init__(self, scopes):
|
def __init__(self, scopes):
|
||||||
self._scopes = frozenset(scopes)
|
self._scopes = frozenset(scopes)
|
||||||
@@ -81,6 +90,373 @@ class _Principal:
|
|||||||
|
|
||||||
|
|
||||||
class ProfileActorAuthorizationTests(unittest.TestCase):
|
class ProfileActorAuthorizationTests(unittest.TestCase):
|
||||||
|
def test_profile_mutation_selector_is_owner_bounded_and_row_locked(self) -> None:
|
||||||
|
statement = mail_profiles._mail_server_profile_mutation_statement( # noqa: SLF001
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id="profile-1",
|
||||||
|
owner_user_id="user-1",
|
||||||
|
can_manage_tenant_profiles=False,
|
||||||
|
can_manage_system_profiles=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = str(statement.compile(dialect=postgresql.dialect()))
|
||||||
|
self.assertIn("FOR UPDATE", sql)
|
||||||
|
self.assertIn("mail_server_profiles.tenant_id =", sql)
|
||||||
|
self.assertIn("mail_server_profiles.scope_type =", sql)
|
||||||
|
self.assertIn("mail_server_profiles.scope_id =", sql)
|
||||||
|
self.assertTrue(statement.get_execution_options()["populate_existing"])
|
||||||
|
|
||||||
|
def test_profile_mutation_selector_without_authority_cannot_lock_a_row(self) -> None:
|
||||||
|
statement = mail_profiles._mail_server_profile_mutation_statement( # noqa: SLF001
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id="profile-1",
|
||||||
|
owner_user_id=None,
|
||||||
|
can_manage_tenant_profiles=False,
|
||||||
|
can_manage_system_profiles=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = str(statement.compile(dialect=postgresql.dialect()))
|
||||||
|
self.assertIn("mail_server_profiles.id IS NULL", sql)
|
||||||
|
self.assertIn("FOR UPDATE", sql)
|
||||||
|
|
||||||
|
def test_self_service_profile_permissions_are_limited_to_own_user_scope(self) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
{"mail:profile:write_own", "mail:secret:manage_own"}
|
||||||
|
)
|
||||||
|
|
||||||
|
router._require_profile_write_scope( # noqa: SLF001 - authorization seam
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
"user",
|
||||||
|
"user-1",
|
||||||
|
)
|
||||||
|
router._require_profile_credentials_scope( # noqa: SLF001 - authorization seam
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
"user",
|
||||||
|
"user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
for scope_type, scope_id in (
|
||||||
|
("user", "user-2"),
|
||||||
|
("tenant", "tenant-1"),
|
||||||
|
("group", "group-1"),
|
||||||
|
("campaign", "campaign-1"),
|
||||||
|
("system", None),
|
||||||
|
):
|
||||||
|
with self.subTest(scope_type=scope_type, scope_id=scope_id):
|
||||||
|
with self.assertRaises(HTTPException) as write_denied:
|
||||||
|
router._require_profile_write_scope( # noqa: SLF001
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
scope_type,
|
||||||
|
scope_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(write_denied.exception.status_code, 403)
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as secret_denied:
|
||||||
|
router._require_profile_credentials_scope( # noqa: SLF001
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
scope_type,
|
||||||
|
scope_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(secret_denied.exception.status_code, 403)
|
||||||
|
|
||||||
|
def test_self_service_create_rejects_another_user_before_persistence(self) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
{
|
||||||
|
"mail:profile:read",
|
||||||
|
"mail:profile:write_own",
|
||||||
|
"mail:secret:manage_own",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload = MailServerProfileCreateRequest.model_validate(
|
||||||
|
{
|
||||||
|
"name": "Other user's profile",
|
||||||
|
"scope_type": "user",
|
||||||
|
"scope_id": "user-2",
|
||||||
|
"smtp": {
|
||||||
|
"host": "smtp.example.test",
|
||||||
|
"password": "not-persisted",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("govoplan_mail.backend.router.create_mail_server_profile") as create,
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router.create_profile(
|
||||||
|
payload,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=_Session(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 403)
|
||||||
|
create.assert_not_called()
|
||||||
|
|
||||||
|
def test_self_service_update_hides_another_user_before_mutation(self) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
{"mail:profile:write_own", "mail:secret:manage_own"}
|
||||||
|
)
|
||||||
|
profile = _profile(
|
||||||
|
"other-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-2",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router.update_profile(
|
||||||
|
profile.id,
|
||||||
|
MailServerProfileUpdateRequest(name="Must not change"),
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=_Session(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 404)
|
||||||
|
update.assert_not_called()
|
||||||
|
|
||||||
|
def test_self_service_delete_hides_another_user_before_mutation(self) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
{"mail:profile:write_own", "mail:secret:manage_own"}
|
||||||
|
)
|
||||||
|
profile = _profile(
|
||||||
|
"other-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-2",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.delete_mail_profile_credentials") as delete,
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router.deactivate_profile(
|
||||||
|
profile.id,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=_Session(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 404)
|
||||||
|
delete.assert_not_called()
|
||||||
|
|
||||||
|
def test_mutation_lookup_returns_the_same_not_found_for_missing_and_non_owned(self) -> None:
|
||||||
|
principal = _Principal({"mail:profile:write_own"})
|
||||||
|
other_profile = _profile(
|
||||||
|
"other-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-2",
|
||||||
|
)
|
||||||
|
cases = (
|
||||||
|
(other_profile, None),
|
||||||
|
(None, MailProfileError("Mail-server profile not found")),
|
||||||
|
)
|
||||||
|
for returned, failure in cases:
|
||||||
|
with self.subTest(failure=failure is not None):
|
||||||
|
kwargs = {"side_effect": failure} if failure else {"return_value": returned}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
**kwargs,
|
||||||
|
),
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router._profile_for_mutation( # noqa: SLF001 - authorization seam
|
||||||
|
_Session(), # type: ignore[arg-type]
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
profile_id="candidate-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 404)
|
||||||
|
self.assertEqual(denied.exception.detail, "Mail-server profile not found")
|
||||||
|
|
||||||
|
def test_broad_profile_admin_retains_cross_owner_mutation_access(self) -> None:
|
||||||
|
principal = _Principal({"mail:profile:write"})
|
||||||
|
profile = _profile(
|
||||||
|
"other-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-2",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
) as get_profile:
|
||||||
|
resolved = router._profile_for_mutation( # noqa: SLF001 - authorization seam
|
||||||
|
_Session(), # type: ignore[arg-type]
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
profile_id=profile.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(resolved, profile)
|
||||||
|
get_profile.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=profile.id,
|
||||||
|
for_update=True,
|
||||||
|
mutation_owner_user_id=None,
|
||||||
|
can_manage_tenant_profiles=True,
|
||||||
|
can_manage_system_profiles=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_self_service_transport_rebind_requires_own_secret_authority(self) -> None:
|
||||||
|
principal = _Principal({"mail:profile:write_own"})
|
||||||
|
profile = _profile(
|
||||||
|
"own-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-1",
|
||||||
|
)
|
||||||
|
profile.smtp_config = {
|
||||||
|
"host": "smtp.example.test",
|
||||||
|
"port": 587,
|
||||||
|
"security": "starttls",
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
}
|
||||||
|
profile.smtp_password_encrypted = "existing-ciphertext"
|
||||||
|
payload = MailServerProfileUpdateRequest.model_validate(
|
||||||
|
{"smtp": {"host": "smtp.attacker.test"}}
|
||||||
|
)
|
||||||
|
session = _Session()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router.update_profile(
|
||||||
|
profile.id,
|
||||||
|
payload,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=session, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 403)
|
||||||
|
self.assertEqual(session.rollbacks, 1)
|
||||||
|
update.assert_not_called()
|
||||||
|
|
||||||
|
def test_self_service_imap_rebind_requires_own_secret_authority(self) -> None:
|
||||||
|
principal = _Principal({"mail:profile:write_own"})
|
||||||
|
profile = _profile(
|
||||||
|
"own-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-1",
|
||||||
|
)
|
||||||
|
profile.imap_config = {
|
||||||
|
"host": "imap.example.test",
|
||||||
|
"port": 993,
|
||||||
|
"security": "tls",
|
||||||
|
"sent_folder": "auto",
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
}
|
||||||
|
profile.imap_password_encrypted = "existing-ciphertext"
|
||||||
|
payload = MailServerProfileUpdateRequest.model_validate(
|
||||||
|
{"imap": {"host": "imap.attacker.test"}}
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
|
||||||
|
self.assertRaises(HTTPException) as denied,
|
||||||
|
):
|
||||||
|
router.update_profile(
|
||||||
|
profile.id,
|
||||||
|
payload,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=_Session(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(denied.exception.status_code, 403)
|
||||||
|
update.assert_not_called()
|
||||||
|
|
||||||
|
def test_self_service_transport_rebind_is_allowed_with_own_secret_authority(self) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
{"mail:profile:write_own", "mail:secret:manage_own"}
|
||||||
|
)
|
||||||
|
profile = _profile(
|
||||||
|
"own-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-1",
|
||||||
|
)
|
||||||
|
profile.smtp_config = {
|
||||||
|
"host": "smtp.example.test",
|
||||||
|
"port": 587,
|
||||||
|
"security": "starttls",
|
||||||
|
"timeout_seconds": 30,
|
||||||
|
}
|
||||||
|
profile.smtp_password_encrypted = "existing-ciphertext"
|
||||||
|
payload = MailServerProfileUpdateRequest.model_validate(
|
||||||
|
{"smtp": {"host": "smtp.allowed.test"}}
|
||||||
|
)
|
||||||
|
session = _Session()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.update_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
) as update,
|
||||||
|
patch("govoplan_mail.backend.router._record_mail_change"),
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router._profile_response",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = router.update_profile(
|
||||||
|
profile.id,
|
||||||
|
payload,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=session, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(result, profile)
|
||||||
|
self.assertEqual(session.commits, 1)
|
||||||
|
update.assert_called_once()
|
||||||
|
|
||||||
|
def test_transport_endpoint_change_without_stored_secret_needs_only_write_authority(self) -> None:
|
||||||
|
principal = _Principal({"mail:profile:write_own"})
|
||||||
|
profile = _profile(
|
||||||
|
"own-user-profile",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-1",
|
||||||
|
)
|
||||||
|
payload = MailServerProfileUpdateRequest.model_validate(
|
||||||
|
{"smtp": {"host": "smtp.allowed.test"}}
|
||||||
|
)
|
||||||
|
session = _Session()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.get_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.update_mail_server_profile",
|
||||||
|
return_value=profile,
|
||||||
|
) as update,
|
||||||
|
patch("govoplan_mail.backend.router._record_mail_change"),
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router._profile_response",
|
||||||
|
return_value=profile,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
router.update_profile(
|
||||||
|
profile.id,
|
||||||
|
payload,
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
session=session, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session.commits, 1)
|
||||||
|
update.assert_called_once()
|
||||||
|
|
||||||
def test_general_profile_list_hides_other_owner_contexts(self) -> None:
|
def test_general_profile_list_hides_other_owner_contexts(self) -> None:
|
||||||
profiles = [
|
profiles = [
|
||||||
_profile("system", scope_type="system", scope_id=None, tenant_id=None),
|
_profile("system", scope_type="system", scope_id=None, tenant_id=None),
|
||||||
@@ -142,6 +518,63 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual([item.id for item in visible], ["inactive"])
|
self.assertEqual([item.id for item in visible], ["inactive"])
|
||||||
|
|
||||||
|
def test_self_service_repair_visibility_is_limited_to_the_exact_owner(self) -> None:
|
||||||
|
own_profile = _profile(
|
||||||
|
"own-policy-invalid",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-1",
|
||||||
|
)
|
||||||
|
other_profile = _profile(
|
||||||
|
"other-policy-invalid",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id="user-2",
|
||||||
|
)
|
||||||
|
denied = EffectiveMailProfilePolicy(
|
||||||
|
allowed_profile_id_sets=[{"different-profile"}]
|
||||||
|
)
|
||||||
|
session = _Session([own_profile, other_profile])
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
|
||||||
|
return_value=denied,
|
||||||
|
):
|
||||||
|
ordinary_visible = list_mail_server_profiles(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
include_inactive=True,
|
||||||
|
actor_user_id="user-1",
|
||||||
|
actor_administrative_visibility=True,
|
||||||
|
)
|
||||||
|
repair_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_own_profiles=True,
|
||||||
|
actor_administrative_visibility=True,
|
||||||
|
)
|
||||||
|
resolved = get_mail_server_profile_for_actor(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=own_profile.id,
|
||||||
|
user_id="user-1",
|
||||||
|
can_manage_own_profiles=True,
|
||||||
|
administrative_visibility=True,
|
||||||
|
)
|
||||||
|
with self.assertRaises(MailProfileError):
|
||||||
|
get_mail_server_profile_for_actor(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=other_profile.id,
|
||||||
|
user_id="user-1",
|
||||||
|
can_manage_own_profiles=True,
|
||||||
|
administrative_visibility=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(ordinary_visible, [])
|
||||||
|
self.assertEqual([item.id for item in repair_visible], [own_profile.id])
|
||||||
|
self.assertIs(resolved, own_profile)
|
||||||
|
|
||||||
def test_profile_admin_can_list_but_not_use_a_policy_denied_profile(self) -> None:
|
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")
|
profile = _profile("denied", scope_type="tenant", scope_id="tenant-1")
|
||||||
session = _Session([profile])
|
session = _Session([profile])
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
|
|||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
user=SimpleNamespace(id="user-1"),
|
user=SimpleNamespace(id="user-1"),
|
||||||
api_key_id=None,
|
api_key_id=None,
|
||||||
|
has=lambda _scope: False,
|
||||||
)
|
)
|
||||||
self.profile = SimpleNamespace(
|
self.profile = SimpleNamespace(
|
||||||
id="profile-1",
|
id="profile-1",
|
||||||
@@ -48,7 +49,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
|
|||||||
with (
|
with (
|
||||||
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
|
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
|
||||||
patch("govoplan_mail.backend.router._require_profile_write_scope"),
|
patch("govoplan_mail.backend.router._require_profile_write_scope"),
|
||||||
patch("govoplan_mail.backend.router._require_profile_credentials_scope"),
|
patch("govoplan_mail.backend.router._require_profile_credentials_scope") as require_credentials,
|
||||||
patch("govoplan_mail.backend.router.delete_mail_profile_credentials", return_value=()),
|
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.clear_mailbox_index") as clear_index,
|
||||||
patch("govoplan_mail.backend.router._record_mail_change") as record_change,
|
patch("govoplan_mail.backend.router._record_mail_change") as record_change,
|
||||||
@@ -61,6 +62,32 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
|
|||||||
self.assertEqual(session.rollbacks, 0)
|
self.assertEqual(session.rollbacks, 0)
|
||||||
clear_index.assert_called_once_with(session, profile_id="profile-1")
|
clear_index.assert_called_once_with(session, profile_id="profile-1")
|
||||||
record_change.assert_not_called()
|
record_change.assert_not_called()
|
||||||
|
require_credentials.assert_not_called()
|
||||||
|
|
||||||
|
def test_delete_requires_secret_authority_when_credentials_will_be_deleted(self) -> None:
|
||||||
|
self.profile.is_active = True
|
||||||
|
self.profile.smtp_password_encrypted = "existing-ciphertext"
|
||||||
|
self.profile.imap_password_encrypted = None
|
||||||
|
session = _Session()
|
||||||
|
with (
|
||||||
|
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
|
||||||
|
patch("govoplan_mail.backend.router._require_profile_write_scope"),
|
||||||
|
patch("govoplan_mail.backend.router._require_profile_credentials_scope") as require_credentials,
|
||||||
|
patch(
|
||||||
|
"govoplan_mail.backend.router.delete_mail_profile_credentials",
|
||||||
|
return_value=("smtp",),
|
||||||
|
),
|
||||||
|
patch("govoplan_mail.backend.router.clear_mailbox_index"),
|
||||||
|
patch("govoplan_mail.backend.router._record_mail_change"),
|
||||||
|
patch("govoplan_mail.backend.router._profile_response", return_value=self.profile),
|
||||||
|
):
|
||||||
|
deactivate_profile("profile-1", principal=self.principal, session=session)
|
||||||
|
|
||||||
|
require_credentials.assert_called_once_with(
|
||||||
|
self.principal,
|
||||||
|
"tenant",
|
||||||
|
"tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
def test_change_feed_reports_whether_credentials_were_deleted(self) -> None:
|
def test_change_feed_reports_whether_credentials_were_deleted(self) -> None:
|
||||||
self.profile.is_active = True
|
self.profile.is_active = True
|
||||||
|
|||||||
6
webui/package-lock.json
generated
6
webui/package-lock.json
generated
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.9",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.9",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
mailProfileEditTargetShowsProfileFields,
|
mailProfileEditTargetShowsProfileFields,
|
||||||
mailProfileEditTargetShowsSettingsPanel,
|
mailProfileEditTargetShowsSettingsPanel,
|
||||||
mailProfileEditTargetVisibleSections,
|
mailProfileEditTargetVisibleSections,
|
||||||
|
mailProfileCreateCredentialsPayload,
|
||||||
|
mailProfileTargetedUpdatePayload,
|
||||||
type MailProfileEditTarget,
|
type MailProfileEditTarget,
|
||||||
type MailProfileProtocol
|
type MailProfileProtocol
|
||||||
} from "./mailProfileEditorModel";
|
} from "./mailProfileEditorModel";
|
||||||
@@ -278,7 +280,7 @@ export function MailProfileScopeManager({
|
|||||||
const created = await createMailServerProfile(settings, payload);
|
const created = await createMailServerProfile(settings, payload);
|
||||||
setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_created.2a088d8d", { value0: created.name }));
|
setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_created.2a088d8d", { value0: created.name }));
|
||||||
} else {
|
} else {
|
||||||
const payload = updateProfilePayload(draft, editing);
|
const payload = updateProfilePayload(draft, editing, editingTarget);
|
||||||
const updated = await updateMailServerProfile(settings, editing.id, payload);
|
const updated = await updateMailServerProfile(settings, editing.id, payload);
|
||||||
setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_updated.fdbad0ea", { value0: updated.name }));
|
setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_updated.fdbad0ea", { value0: updated.name }));
|
||||||
}
|
}
|
||||||
@@ -369,13 +371,16 @@ export function MailProfileScopeManager({
|
|||||||
}
|
}
|
||||||
if (row.kind === "credential") {
|
if (row.kind === "credential") {
|
||||||
return <TableActionGroup actions={[{
|
return <TableActionGroup actions={[{
|
||||||
id: "edit-credentials", label: i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() }), icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile, { kind: "credentials", protocol: row.protocol })
|
id: "edit-credentials", label: i18nMessage("i18n:govoplan-mail.edit_value_credentials.1e2dc4f6", { value0: row.protocol.toUpperCase() }), icon: <Pencil size={16} />, disabled: !canWriteProfiles || !canManageCredentials || busy, onClick: () => openEdit(row.profile, { kind: "credentials", protocol: row.protocol })
|
||||||
}]} />;
|
}]} />;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
const deactivationDeletesCredentials = Boolean(
|
||||||
|
row.profile.smtp_password_configured || row.profile.imap_password_configured
|
||||||
|
);
|
||||||
return <TableActionGroup actions={[
|
return <TableActionGroup actions={[
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name }), icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name }), icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile) },
|
||||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: <Trash2 size={16} />, variant: "danger", applicable: row.profile.is_active, disabled: !canWriteProfiles || busy, onClick: () => setDeactivating(row.profile) }
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: <Trash2 size={16} />, variant: "danger", applicable: row.profile.is_active, disabled: !canWriteProfiles || busy || (deactivationDeletesCredentials && !canManageCredentials), onClick: () => setDeactivating(row.profile) }
|
||||||
]} />;
|
]} />;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1092,6 +1097,7 @@ function profileToDraft(profile: MailServerProfile): ProfileDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope, scopeId: string | null): MailServerProfilePayload {
|
function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope, scopeId: string | null): MailServerProfilePayload {
|
||||||
|
const credentials = profileCreateCredentialsPayload(draft);
|
||||||
return {
|
return {
|
||||||
name: draft.name.trim(),
|
name: draft.name.trim(),
|
||||||
slug: mailTextOrNull(draft.slug),
|
slug: mailTextOrNull(draft.slug),
|
||||||
@@ -1101,21 +1107,27 @@ function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope,
|
|||||||
scope_id: scopeId,
|
scope_id: scopeId,
|
||||||
smtp: smtpServerPayload(draft),
|
smtp: smtpServerPayload(draft),
|
||||||
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
|
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
|
||||||
credentials: profileCredentialsPayload(draft, false)
|
...(credentials ? { credentials } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProfilePayload(draft: ProfileDraft, profile: MailServerProfile): MailServerProfileUpdatePayload {
|
function updateProfilePayload(
|
||||||
return {
|
draft: ProfileDraft,
|
||||||
name: draft.name.trim(),
|
profile: MailServerProfile,
|
||||||
slug: mailTextOrNull(draft.slug),
|
target: MailProfileEditTarget
|
||||||
description: draft.description.trim(),
|
): MailServerProfileUpdatePayload {
|
||||||
is_active: draft.isActive,
|
return mailProfileTargetedUpdatePayload(target, {
|
||||||
|
profile: {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
slug: mailTextOrNull(draft.slug),
|
||||||
|
description: draft.description.trim(),
|
||||||
|
is_active: draft.isActive
|
||||||
|
},
|
||||||
smtp: smtpServerPayload(draft),
|
smtp: smtpServerPayload(draft),
|
||||||
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
|
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
|
||||||
credentials: profileCredentialsPayload(draft, true),
|
credentials: profileCredentialsPayload(draft, true),
|
||||||
clear_imap: !hasDraftImapSettings(draft) && Boolean(profile.imap)
|
clearImap: !hasDraftImapSettings(draft) && Boolean(profile.imap)
|
||||||
};
|
}) as MailServerProfileUpdatePayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
|
function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
|
||||||
@@ -1139,6 +1151,13 @@ function profileCredentialsPayload(draft: ProfileDraft, preserveBlankPassword: b
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileCreateCredentialsPayload(draft: ProfileDraft) {
|
||||||
|
return mailProfileCreateCredentialsPayload(
|
||||||
|
mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, true),
|
||||||
|
mailTransportCredentialsPayload(draft.imapUsername, draft.imapPassword, true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function rawSmtpPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailSmtpTestPayload {
|
function rawSmtpPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailSmtpTestPayload {
|
||||||
return { ...smtpServerPayload(draft), ...mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, preserveBlankPassword) };
|
return { ...smtpServerPayload(draft), ...mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, preserveBlankPassword) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ export type MailProfileChildDescriptor = {
|
|||||||
protocol: MailProfileProtocol;
|
protocol: MailProfileProtocol;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MailProfileTransportCredentialsLike = {
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailProfileTargetedUpdateParts = {
|
||||||
|
profile: Record<string, unknown>;
|
||||||
|
smtp: Record<string, unknown>;
|
||||||
|
imap: Record<string, unknown> | null;
|
||||||
|
credentials: {
|
||||||
|
smtp: MailProfileTransportCredentialsLike;
|
||||||
|
imap: MailProfileTransportCredentialsLike;
|
||||||
|
};
|
||||||
|
clearImap: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] {
|
export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] {
|
||||||
const children: MailProfileChildDescriptor[] = [
|
const children: MailProfileChildDescriptor[] = [
|
||||||
{ kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" },
|
{ kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" },
|
||||||
@@ -58,3 +74,42 @@ export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditT
|
|||||||
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
|
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
|
||||||
return target.kind !== "profile";
|
return target.kind !== "profile";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the PATCH authority surface equal to the focused editor. Profile and
|
||||||
|
* server edits must not accidentally replay credential fields merely because
|
||||||
|
* the form draft contains their displayed values.
|
||||||
|
*/
|
||||||
|
export function mailProfileTargetedUpdatePayload(
|
||||||
|
target: MailProfileEditTarget,
|
||||||
|
parts: MailProfileTargetedUpdateParts
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (target.kind === "profile") return parts.profile;
|
||||||
|
if (target.kind === "server") {
|
||||||
|
if (target.protocol === "smtp") return { smtp: parts.smtp };
|
||||||
|
return parts.imap === null
|
||||||
|
? { imap: null, clear_imap: parts.clearImap }
|
||||||
|
: { imap: parts.imap };
|
||||||
|
}
|
||||||
|
if (target.kind === "credentials") {
|
||||||
|
return { credentials: { [target.protocol]: parts.credentials[target.protocol] } };
|
||||||
|
}
|
||||||
|
throw new Error("Create is not an update target");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Omit blank create credentials so profile-write remains independent. */
|
||||||
|
export function mailProfileCreateCredentialsPayload(
|
||||||
|
smtp: MailProfileTransportCredentialsLike,
|
||||||
|
imap: MailProfileTransportCredentialsLike
|
||||||
|
): {smtp?: MailProfileTransportCredentialsLike;imap?: MailProfileTransportCredentialsLike;} | undefined {
|
||||||
|
const populated = (value: MailProfileTransportCredentialsLike): MailProfileTransportCredentialsLike =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== "")
|
||||||
|
);
|
||||||
|
const smtpCredentials = populated(smtp);
|
||||||
|
const imapCredentials = populated(imap);
|
||||||
|
const result: {smtp?: MailProfileTransportCredentialsLike;imap?: MailProfileTransportCredentialsLike;} = {};
|
||||||
|
if (Object.keys(smtpCredentials).length > 0) result.smtp = smtpCredentials;
|
||||||
|
if (Object.keys(imapCredentials).length > 0) result.imap = imapCredentials;
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
mailProfileEditTargetPanelMode,
|
mailProfileEditTargetPanelMode,
|
||||||
mailProfileEditTargetShowsProfileFields,
|
mailProfileEditTargetShowsProfileFields,
|
||||||
mailProfileEditTargetShowsSettingsPanel,
|
mailProfileEditTargetShowsSettingsPanel,
|
||||||
mailProfileEditTargetVisibleSections
|
mailProfileEditTargetVisibleSections,
|
||||||
|
mailProfileCreateCredentialsPayload,
|
||||||
|
mailProfileTargetedUpdatePayload
|
||||||
} from "../src/features/mail/mailProfileEditorModel";
|
} from "../src/features/mail/mailProfileEditorModel";
|
||||||
|
|
||||||
const smtpOnlyChildren = mailProfileChildDescriptors({ id: "profile-1", imap: null });
|
const smtpOnlyChildren = mailProfileChildDescriptors({ id: "profile-1", imap: null });
|
||||||
@@ -46,3 +48,42 @@ assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
|
|||||||
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
|
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
|
||||||
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
|
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
|
||||||
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);
|
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);
|
||||||
|
|
||||||
|
const updateParts = {
|
||||||
|
profile: { name: "Renamed" },
|
||||||
|
smtp: { host: "smtp.example.org" },
|
||||||
|
imap: { host: "imap.example.org" },
|
||||||
|
credentials: {
|
||||||
|
smtp: { username: "smtp-user", password: "smtp-secret" },
|
||||||
|
imap: { username: "imap-user", password: "imap-secret" }
|
||||||
|
},
|
||||||
|
clearImap: false
|
||||||
|
};
|
||||||
|
assertDeepEqual(
|
||||||
|
mailProfileTargetedUpdatePayload({ kind: "profile" }, updateParts),
|
||||||
|
{ name: "Renamed" },
|
||||||
|
"profile edits do not replay transport or credential fields"
|
||||||
|
);
|
||||||
|
assertDeepEqual(
|
||||||
|
mailProfileTargetedUpdatePayload({ kind: "server", protocol: "smtp" }, updateParts),
|
||||||
|
{ smtp: { host: "smtp.example.org" } },
|
||||||
|
"server edits do not replay credential fields"
|
||||||
|
);
|
||||||
|
assertDeepEqual(
|
||||||
|
mailProfileTargetedUpdatePayload({ kind: "credentials", protocol: "imap" }, updateParts),
|
||||||
|
{ credentials: { imap: { username: "imap-user", password: "imap-secret" } } },
|
||||||
|
"credential edits send only the selected protocol"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
mailProfileCreateCredentialsPayload({ username: null }, { password: "" }),
|
||||||
|
undefined,
|
||||||
|
"credential-free creates omit the credential object"
|
||||||
|
);
|
||||||
|
assertDeepEqual(
|
||||||
|
mailProfileCreateCredentialsPayload(
|
||||||
|
{ username: "smtp-user", password: null },
|
||||||
|
{ username: null, password: "imap-secret" }
|
||||||
|
),
|
||||||
|
{ smtp: { username: "smtp-user" }, imap: { password: "imap-secret" } },
|
||||||
|
"create payloads retain only explicitly populated credential fields"
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user