security: enforce actor-aware Mail profile access

This commit is contained in:
2026-07-21 17:51:25 +02:00
parent 9d1d9bfb58
commit 06e0fbd3c3
7 changed files with 1336 additions and 158 deletions

View File

@@ -14,15 +14,75 @@ from govoplan_mail.backend.mail_profiles import (
_campaign_mail_profile_reference_id,
_apply_profile_transport_update,
campaign_profile_transport_revisions,
create_mail_server_profile,
_merge_policy,
_next_profile_transport_state,
_policy_parent_lock_message,
_policy_parent_lock_violations,
delete_mail_profile_credentials,
update_mail_server_profile,
)
class MailProfileTransportHelperTests(unittest.TestCase):
def test_inactive_profile_creation_rejects_dormant_credentials(self):
session = SimpleNamespace()
with (
patch(
"govoplan_mail.backend.mail_profiles._scope_tuple_for_create",
return_value=("tenant-1", "tenant", "tenant-1"),
),
patch("govoplan_mail.backend.mail_profiles._ensure_scope_allows_profile_creation"),
patch("govoplan_mail.backend.mail_profiles.assert_mail_policy_allows_transport"),
patch("govoplan_mail.backend.mail_profiles._ensure_unique_slug"),
self.assertRaisesRegex(MailProfileError, "cannot retain credentials"),
):
create_mail_server_profile(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
name="Dormant secret",
slug=None,
description=None,
smtp=SmtpConfig(host="smtp.example.test", password="secret"),
imap=None,
is_active=False,
)
def test_inactive_profile_cannot_retain_or_recreate_dormant_credentials(self):
session = SimpleNamespace()
legacy = SimpleNamespace(
is_active=False,
smtp_password_encrypted="legacy-ciphertext",
imap_password_encrypted=None,
)
with self.assertRaisesRegex(MailProfileError, "use DELETE to scrub"):
update_mail_server_profile(
session, # type: ignore[arg-type]
legacy, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
is_active=True,
)
self.assertFalse(legacy.is_active)
self.assertEqual(legacy.smtp_password_encrypted, "legacy-ciphertext")
scrubbed = SimpleNamespace(
is_active=False,
smtp_password_encrypted=None,
imap_password_encrypted=None,
)
with self.assertRaisesRegex(MailProfileError, "activated in the same update"):
update_mail_server_profile(
session, # type: ignore[arg-type]
scrubbed, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
smtp=SmtpConfig(host="smtp.example.test", password="new-secret"),
)
self.assertFalse(scrubbed.is_active)
self.assertIsNone(scrubbed.smtp_password_encrypted)
def test_campaign_transport_revisions_are_random_opaque_values(self):
profile = SimpleNamespace(
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
@@ -140,7 +200,7 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertEqual(profile.smtp_password_encrypted, "smtp-ciphertext")
self.assertEqual(profile.imap_password_encrypted, "imap-ciphertext")
def test_next_transport_state_uses_saved_profile_credentials(self):
def test_next_transport_state_uses_only_saved_non_secret_metadata(self):
profile = SimpleNamespace(
tenant_id="tenant-1",
scope_type="tenant",
@@ -153,12 +213,19 @@ class MailProfileTransportHelperTests(unittest.TestCase):
imap_password_encrypted=encrypt_secret("imap-secret"),
)
smtp, imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=False)
self.assertEqual(smtp.username, "saved-smtp")
self.assertEqual(smtp.password, "smtp-secret")
with patch(
"govoplan_mail.backend.mail_profiles.decrypt_secret",
side_effect=AssertionError("policy validation must not decrypt credentials"),
):
smtp, imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=False)
self.assertEqual(smtp["host"], "smtp.example.org")
self.assertNotIn("username", smtp)
self.assertNotIn("password", smtp)
self.assertIsNotNone(imap)
self.assertEqual(imap.username, "saved-imap")
self.assertEqual(imap.password, "imap-secret")
assert imap is not None
self.assertEqual(imap["host"], "imap.example.org")
self.assertNotIn("username", imap)
self.assertNotIn("password", imap)
_, cleared_imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=True)
self.assertIsNone(cleared_imap)
@@ -180,15 +247,17 @@ class MailProfileTransportHelperTests(unittest.TestCase):
)
session = SimpleNamespace(flush=lambda: None)
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=SmtpConfig(host="smtp2.example.org", username="new-smtp"),
imap=ImapConfig(host="imap2.example.org", username="new-imap"),
clear_imap=False,
)
with patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index") as clear_index:
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=SmtpConfig(host="smtp2.example.org", username="new-smtp"),
imap=ImapConfig(host="imap2.example.org", username="new-imap"),
clear_imap=False,
)
clear_index.assert_called_once_with(session, profile_id="profile-1")
self.assertEqual(profile.smtp_config["host"], "smtp2.example.org")
self.assertEqual(profile.smtp_username, "new-smtp")
@@ -199,7 +268,10 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertNotEqual(profile.smtp_transport_revision, "smtp-before")
self.assertNotEqual(profile.imap_transport_revision, "imap-before")
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
with (
patch("govoplan_mail.backend.mail_profiles.audit_event") as audit,
patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index") as clear_index,
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
@@ -209,6 +281,7 @@ class MailProfileTransportHelperTests(unittest.TestCase):
imap=None,
clear_imap=True,
)
clear_index.assert_called_once_with(session, profile_id="profile-1")
self.assertIsNone(profile.imap_config)
self.assertIsNone(profile.imap_username)
self.assertIsNone(profile.imap_password_encrypted)
@@ -216,6 +289,53 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org"},
smtp_username=None,
smtp_password_encrypted=None,
smtp_transport_revision="smtp-before",
imap_config={
"host": "imap.example.org",
"port": 993,
"security": "tls",
"sent_folder": "auto",
"timeout_seconds": 30,
},
imap_username="saved-imap",
imap_password_encrypted=encrypt_secret("old-secret"),
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with (
patch("govoplan_mail.backend.mail_profiles.audit_event"),
patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index") as clear_index,
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=None,
imap=ImapConfig(
host="imap.example.org",
port=993,
security="tls",
timeout_seconds=30,
password="new-secret",
),
clear_imap=False,
)
clear_index.assert_called_once_with(session, profile_id="profile-1")
self.assertEqual(profile.imap_transport_revision, "imap-before")
self.assertEqual(decrypt_secret(profile.imap_password_encrypted), "new-secret")
def test_password_replacement_preserves_revision_and_is_audited_without_secret(self):
profile = SimpleNamespace(
id="profile-1",
@@ -295,7 +415,7 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertEqual(profile.smtp_password_encrypted, encrypted)
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
def test_exact_password_update_is_an_idempotent_no_op(self):
def test_supplied_password_is_replaced_without_decrypting_old_ciphertext(self):
encrypted = encrypt_secret("same-secret")
profile = SimpleNamespace(
id="profile-1",
@@ -313,7 +433,13 @@ class MailProfileTransportHelperTests(unittest.TestCase):
)
session = SimpleNamespace(flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
with (
patch("govoplan_mail.backend.mail_profiles.audit_event") as audit,
patch(
"govoplan_mail.backend.mail_profiles.decrypt_secret",
side_effect=AssertionError("replacement must not decrypt old ciphertext"),
),
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
@@ -324,9 +450,49 @@ class MailProfileTransportHelperTests(unittest.TestCase):
clear_imap=False,
)
self.assertEqual(profile.smtp_password_encrypted, encrypted)
self.assertNotEqual(profile.smtp_password_encrypted, encrypted)
self.assertEqual(decrypt_secret(profile.smtp_password_encrypted), "same-secret")
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
audit.assert_not_called()
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_replaced")
def test_metadata_update_does_not_decrypt_unrelated_transport_credentials(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted="corrupt-smtp-ciphertext",
smtp_transport_revision="smtp-before",
imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30},
imap_username="saved-imap",
imap_password_encrypted="corrupt-imap-ciphertext",
imap_transport_revision="imap-before",
name="Profile",
slug="profile",
description=None,
is_active=True,
updated_by_user_id=None,
)
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
with (
patch("govoplan_mail.backend.mail_profiles.decrypt_secret", side_effect=AssertionError("must not decrypt")),
patch("govoplan_mail.backend.mail_profiles._assert_profile_transport_allowed") as policy_check,
):
update_mail_server_profile(
session, # type: ignore[arg-type]
profile,
tenant_id="tenant-1",
user_id="user-1",
description="Updated",
)
policy_check.assert_called_once()
self.assertEqual(profile.description, "Updated")
self.assertEqual(profile.smtp_password_encrypted, "corrupt-smtp-ciphertext")
self.assertEqual(profile.imap_password_encrypted, "corrupt-imap-ciphertext")
class MailProfilePolicyHelperTests(unittest.TestCase):