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