diff --git a/plugins/lookup/email.py b/plugins/lookup/email.py index b42b88d661..df75b2afbd 100644 --- a/plugins/lookup/email.py +++ b/plugins/lookup/email.py @@ -166,9 +166,6 @@ class LookupModule(LookupBase): return _as_bool(variables.get("TLS_ENABLED")) if short_key == "port": external = _as_bool(resolved.get("external")) - # SSO relay: port 25 is the only listener not requiring SMTP AUTH. - if self._provider_uses_sso_relay(variables): - return 25 if not external: return 25 ports = self._provider_ports(variables) @@ -179,8 +176,7 @@ class LookupModule(LookupBase): # binds the implicit-TLS client ports plus the MX, so it advertises # no `submission`; fall back to the MX port rather than a listener # nothing answers on (`auth` drops with it -- an MX takes local - # recipients without SMTP AUTH, the same property the SSO relay - # above relies on). + # recipients without SMTP AUTH). return ports.get("submission") or ports.get("smtp", 25) if short_key == "host": env = resolved.get("environment") @@ -200,8 +196,6 @@ class LookupModule(LookupBase): env = resolved.get("environment") if env in ("external_container", "localhost"): return False - if self._provider_uses_sso_relay(variables): - return False # The MX listener offers no SMTP AUTH: when `port` fell back to it # because the provider binds no plaintext submission, announcing # AUTH aborts msmtp with EX_UNAVAILABLE before it ever sends. @@ -216,11 +210,7 @@ class LookupModule(LookupBase): return "off" return "on" if _as_bool(resolved.get("tls")) else "plain" if short_key == "start_tls": - if str(resolved.get("host") or "").lower().endswith( - ".onion" - ) or self._provider_onion_primary(variables): - return False - return self._provider_uses_sso_relay(variables) + return False if short_key == "smtp": return True if short_key == "from": @@ -255,8 +245,7 @@ class LookupModule(LookupBase): def _provider_services(self, variables: dict[str, Any]) -> dict[str, Any]: """The active provider's ``services`` block, or ``{}`` when it cannot be - resolved. Both the relay probe and the port probe read the provider's - own self-declaration, so the applications lookup is done once here.""" + resolved. The port probe reads the provider's own self-declaration.""" apps = ApplicationsLookup() apps._templar = getattr(self, "_templar", None) forwarded = { @@ -299,27 +288,6 @@ class LookupModule(LookupBase): continue return out - def _provider_uses_sso_relay(self, variables: dict[str, Any]) -> bool: - """True when the active provider self-declares SSO relay (via - ``services.sso.oidc.submission_via_relay``) and Keycloak is deployed.""" - group_names = variables.get("group_names") or [] - if "web-app-keycloak" not in group_names: - return False - services = self._provider_services(variables) - sso = services.get("sso") or {} if isinstance(services, dict) else {} - oidc = sso.get("oidc") or {} if isinstance(sso, dict) else {} - if not isinstance(oidc, dict): - return False - # A variant/inventory can pin the provider's sso.enabled to a literal - # false while submission_via_relay stays true in the role defaults — - # then the provider keeps password auth and never widens allowRelaying, - # so relay mode must be off. Untemplated Jinja (the role default gates - # on group_names) falls through to the Keycloak check above. - enabled = sso.get("enabled") if isinstance(sso, dict) else None - if enabled is not None and "{{" not in str(enabled) and not _as_bool(enabled): - return False - return _as_bool(oidc.get("submission_via_relay")) - def _provider_onion_primary(self, variables: dict[str, Any]) -> bool: """True when the provider's own host serves it onion-first. diff --git a/roles/web-app-stalwart/README.md b/roles/web-app-stalwart/README.md index 83d455f38a..0ddffafa24 100644 --- a/roles/web-app-stalwart/README.md +++ b/roles/web-app-stalwart/README.md @@ -57,7 +57,7 @@ flowchart LR STALWART -- "OIDC directory
(token validation)" --> KC MAIL_VHOST -. "SPA login: PKCE
(stalwart-webui client)" .-> KC WEBMAIL_VHOST -. "OAuth login" .-> KC - APPS -- "STARTTLS :25, no AUTH
(SSO relay, trusted networks)" --> STALWART + APPS -- "SMTPS :465, AUTH
(bot credential from the token store)" --> STALWART WORLD <-- "public mail ports
(only when active MAIL_PROVIDER)" --> STALWART ``` @@ -151,19 +151,23 @@ delegates **interactive** authentication to Keycloak: **Design constraint (validated against the live JMAP schema):** Stalwart's authentication directory is *Internal XOR one external directory* — there is no -chaining or fallback. Enabling SSO therefore **disables password submission** -for every account, including the machine `no-reply` account. To keep outbound -notifications working, the role: +chaining or fallback. Enabling SSO therefore **disables the account password** +for every account, including the machine `no-reply` account. App passwords are +checked before the directory and keep working, so with SSO enabled the role: -1. Widens `x:MtaStageRcpt.allowRelaying` to trust the internal Docker networks - (`STALWART_TRUSTED_NETWORKS`), so the bot relays without SMTP AUTH; and -2. Self-declares `services.sso.oidc.submission_via_relay: true`, which makes - [`plugins/lookup/email.py`](../../plugins/lookup/email.py) switch the - `no-reply` client to unauthenticated STARTTLS relay on port 25. +1. Creates one app password per bot account, acting for that account as the + recovery admin (`tasks/04_manage_user/app_password.yml`), and +2. Stores it in the token store, where + [`plugins/lookup/email.py`](../../plugins/lookup/email.py) reads the SMTP + password of the `no-reply` client. -With SSO disabled the role uses the Internal directory and password submission, -exactly as before. Mailu keeps password submission in both modes and does not -set `submission_via_relay`. +A bot gets a new app password when its entry named +`STALWART_BOT_APP_PASSWORD_DESCRIPTION` is deleted in Stalwart or its token is +removed from the token store. Relaying stays limited to authenticated sessions +in both modes (`STALWART_RELAY_RULE`). + +With SSO disabled the role uses the Internal directory and the bot's derived +account password. ## Calendar & Contacts (CalDAV / CardDAV / WebDAV) @@ -195,8 +199,8 @@ account (or the Keycloak SSO token when SSO is enabled). - **`config.json` is bootstrap-only** (data store selection); all other configuration lives in the database and is provisioned over JMAP (`templates/jmap/*.json.j2`). -- **Under SSO, trusted internal networks relay without SMTP AUTH** — the - no-reply bot has no password once the auth directory is OIDC. +- **Only authenticated sessions relay, with or without SSO.** Under SSO a bot + authenticates with an app password. ## Features diff --git a/roles/web-app-stalwart/files/test/probe_mail.py b/roles/web-app-stalwart/files/test/probe_mail.py index b6210e7f4d..9b859d87bf 100755 --- a/roles/web-app-stalwart/files/test/probe_mail.py +++ b/roles/web-app-stalwart/files/test/probe_mail.py @@ -10,7 +10,10 @@ speaks IMAP through imaplib. probe_mail.py send probe_mail.py find + probe_mail.py relay +`relay` exits 0 when an unauthenticated session is refused an external recipient +and the same recipient is accepted once `` has authenticated, 1 otherwise. `find` exits 0 when the subject is present in INBOX or Junk, 1 when it is not. Both mailboxes are searched because a test domain publishes no mail-auth DNS, so a correctly delivered message is legitimately filed under Junk. Certificate @@ -32,10 +35,19 @@ import sys from pathlib import Path SMTP_PORT = 25 +SMTPS_PORT = 465 IMAPS_PORT = 993 TIMEOUT = 60 MAILBOXES = ("INBOX", "Junk Mail") +EXTERNAL_RECIPIENT = "relay-probe@example.org" + + +def unverified_context() -> ssl.SSLContext: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context def send(host: str, sender: str, recipient: str, eml_path: str) -> int: @@ -45,12 +57,46 @@ def send(host: str, sender: str, recipient: str, eml_path: str) -> int: return 0 -def find(host: str, user: str, password: str, subject: str) -> int: - context = ssl.create_default_context() - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE +def relay(host: str, sender: str, password: str) -> int: + with smtplib.SMTP(host, SMTP_PORT, timeout=TIMEOUT) as smtp: + smtp.ehlo() + smtp.mail(sender) + anonymous, _ = smtp.rcpt(EXTERNAL_RECIPIENT) + if anonymous < 500: + print( + f"open relay: an unauthenticated session got {anonymous} " + f"for {EXTERNAL_RECIPIENT}", + file=sys.stderr, + ) + return 1 - imap = imaplib.IMAP4_SSL(host, IMAPS_PORT, ssl_context=context, timeout=TIMEOUT) + with smtplib.SMTP_SSL( + host, SMTPS_PORT, timeout=TIMEOUT, context=unverified_context() + ) as smtp: + try: + smtp.login(sender, password) + except smtplib.SMTPAuthenticationError as error: + print( + f"submission refused: {sender} cannot authenticate ({error.smtp_code})", + file=sys.stderr, + ) + return 1 + smtp.mail(sender) + authenticated, _ = smtp.rcpt(EXTERNAL_RECIPIENT) + if authenticated != 250: + print( + f"submission refused: {sender} authenticated but got {authenticated} " + f"for {EXTERNAL_RECIPIENT}", + file=sys.stderr, + ) + return 1 + return 0 + + +def find(host: str, user: str, password: str, subject: str) -> int: + imap = imaplib.IMAP4_SSL( + host, IMAPS_PORT, ssl_context=unverified_context(), timeout=TIMEOUT + ) imap._encoding = "utf-8" try: imap.login(user, password) @@ -76,6 +122,8 @@ def main(argv: list[str]) -> int: return send(*args) if action == "find" and len(args) == 4: return find(*args) + if action == "relay" and len(args) == 3: + return relay(*args) print(__doc__, file=sys.stderr) return 2 diff --git a/roles/web-app-stalwart/files/test/relay.sh b/roles/web-app-stalwart/files/test/relay.sh new file mode 100644 index 0000000000..1c5e980814 --- /dev/null +++ b/roles/web-app-stalwart/files/test/relay.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +: "${PYTHON_BIN:?missing PYTHON_BIN}" +: "${MAIL_HOST:?missing MAIL_HOST}" +: "${NO_REPLY_EMAIL:?missing NO_REPLY_EMAIL}" +: "${NO_REPLY_SMTP_PASSWORD:?missing NO_REPLY_SMTP_PASSWORD}" + +"${PYTHON_BIN}" "${SCRIPT_DIR}/probe_mail.py" relay \ + "${MAIL_HOST}" "${NO_REPLY_EMAIL}" "${NO_REPLY_SMTP_PASSWORD}" +echo "[relay] unauthenticated relay refused, ${NO_REPLY_EMAIL} submits authenticated" diff --git a/roles/web-app-stalwart/files/test/test.sh b/roles/web-app-stalwart/files/test/test.sh index 3b5d6f53de..adf4d4ee51 100755 --- a/roles/web-app-stalwart/files/test/test.sh +++ b/roles/web-app-stalwart/files/test/test.sh @@ -4,4 +4,5 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +bash "${SCRIPT_DIR}/relay.sh" bash "${SCRIPT_DIR}/verify_migration.sh" diff --git a/roles/web-app-stalwart/meta/services.yml b/roles/web-app-stalwart/meta/services.yml index 9f0b082711..fd83fddb34 100644 --- a/roles/web-app-stalwart/meta/services.yml +++ b/roles/web-app-stalwart/meta/services.yml @@ -8,7 +8,6 @@ sso: oidc: email_by_username: true enable_user_creation: true - submission_via_relay: true clients: webui: stalwart-webui webmail: stalwart-webmail diff --git a/roles/web-app-stalwart/tasks/04_manage_user.yml b/roles/web-app-stalwart/tasks/04_manage_user.yml index 2cd75d2f1a..b9af38c469 100644 --- a/roles/web-app-stalwart/tasks/04_manage_user.yml +++ b/roles/web-app-stalwart/tasks/04_manage_user.yml @@ -46,4 +46,14 @@ sys_token_store_user_key: "{{ stalwart_user_key }}" sys_token_store_app: "{{ application_id }}" sys_token_store_token: "{{ stalwart_user_secret }}" - when: "'bot' in stalwart_user_roles" + when: + - "'bot' in stalwart_user_roles" + - not (STALWART_SSO_ENABLED | bool) + +- name: "🔑 Keep an app password for bot '{{ stalwart_user_name }}' under SSO" + include_tasks: 04_manage_user/app_password.yml + vars: + stalwart_jmap_act_as: "{{ stalwart_user_name }}@{{ STALWART_DOMAIN }}" + when: + - "'bot' in stalwart_user_roles" + - STALWART_SSO_ENABLED | bool diff --git a/roles/web-app-stalwart/tasks/04_manage_user/app_password.yml b/roles/web-app-stalwart/tasks/04_manage_user/app_password.yml new file mode 100644 index 0000000000..08b8cf593f --- /dev/null +++ b/roles/web-app-stalwart/tasks/04_manage_user/app_password.yml @@ -0,0 +1,52 @@ +--- +# Param: stalwart_jmap_act_as - address of the bot account, every call below runs as that account. +- name: "📖 List the app passwords of '{{ stalwart_user_name }}'" + include_tasks: "{{ role_path }}/tasks/jmap_call.yml" + vars: + stalwart_jmap_body: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:AppPassword/get', {'ids': none}, 'c0']]} | to_json }} + +- name: "🔍 Decide whether '{{ stalwart_user_name }}' needs a new app password" + ansible.builtin.set_fact: + stalwart_app_password_ids: "{{ stalwart_app_password_owned }}" + stalwart_app_password_mint: >- + {{ (stalwart_app_password_owned | length != 1) + or (stalwart_app_password_stored | length == 0) + or (stalwart_app_password_stored == stalwart_user_secret) }} + vars: + stalwart_app_password_owned: >- + {{ stalwart_jmap.methodResponses[0][1].list + | selectattr('description', 'equalto', STALWART_BOT_APP_PASSWORD_DESCRIPTION) + | map(attribute='id') | list }} + stalwart_app_password_stored: "{{ lookup('users', stalwart_user_key, {}).get('tokens', {}).get(application_id, '') }}" + +- name: "🗑️ Drop the stale app passwords of '{{ stalwart_user_name }}'" + include_tasks: "{{ role_path }}/tasks/jmap_call.yml" + vars: + stalwart_jmap_body: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:AppPassword/set', {'destroy': stalwart_app_password_ids}, 'c0']]} | to_json }} + when: + - stalwart_app_password_mint | bool + - stalwart_app_password_ids | length > 0 + +- name: "🔑 Mint the app password of '{{ stalwart_user_name }}'" + include_tasks: "{{ role_path }}/tasks/jmap_call.yml" + vars: + stalwart_jmap_body: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:AppPassword/set', {'create': {'p': { + 'description': STALWART_BOT_APP_PASSWORD_DESCRIPTION + }}}, 'c0']]} | to_json }} + when: stalwart_app_password_mint | bool + +- name: "💾 Persist the app password of '{{ stalwart_user_name }}'" + ansible.builtin.include_role: + name: sys-token-store + tasks_from: write.yml + vars: + sys_token_store_user_key: "{{ stalwart_user_key }}" + sys_token_store_app: "{{ application_id }}" + sys_token_store_token: "{{ stalwart_jmap.methodResponses[0][1].created.p.secret }}" + when: stalwart_app_password_mint | bool diff --git a/roles/web-app-stalwart/tasks/06_oidc.yml b/roles/web-app-stalwart/tasks/06_oidc.yml index fda058a667..a042e1f990 100644 --- a/roles/web-app-stalwart/tasks/06_oidc.yml +++ b/roles/web-app-stalwart/tasks/06_oidc.yml @@ -86,28 +86,13 @@ stalwart_jmap_body: "{{ lookup('template', 'jmap/authentication/update.json.j2') }}" when: stalwart_auth_exists | bool -- name: "⚙️ Build the trusted-network relay expression" - ansible.builtin.set_fact: - stalwart_relay_expr: >- - !is_empty(authenticated_as){% for net in STALWART_TRUSTED_NETWORKS %} || remote_ip == '{{ net }}'{% endfor %} - - name: "📖 Read the RCPT stage singleton" include_tasks: jmap_call.yml vars: stalwart_jmap_body: "{{ lookup('template', 'jmap/rcpt/get.json.j2') }}" -- name: "🔧 Record whether the RCPT stage singleton exists" - ansible.builtin.set_fact: - stalwart_rcpt_exists: "{{ (stalwart_jmap.methodResponses[0][1].list | length) > 0 }}" - -- name: "📁 Create the RCPT stage with trusted-network relay" - include_tasks: jmap_call.yml - vars: - stalwart_jmap_body: "{{ lookup('template', 'jmap/rcpt/create.json.j2') }}" - when: not (stalwart_rcpt_exists | bool) - -- name: "🔄 Update the RCPT stage with trusted-network relay" +- name: "🔒 Restrict relaying to authenticated sessions" include_tasks: jmap_call.yml vars: stalwart_jmap_body: "{{ lookup('template', 'jmap/rcpt/update.json.j2') }}" - when: stalwart_rcpt_exists | bool + when: (stalwart_jmap.methodResponses[0][1].list | length) > 0 diff --git a/roles/web-app-stalwart/tasks/jmap_call.yml b/roles/web-app-stalwart/tasks/jmap_call.yml index d41404ff6d..9232fc6038 100644 --- a/roles/web-app-stalwart/tasks/jmap_call.yml +++ b/roles/web-app-stalwart/tasks/jmap_call.yml @@ -1,6 +1,7 @@ --- # Reusable management JMAP call: in `stalwart_jmap_body` (JSON), out `stalwart_jmap`. # Param: stalwart_jmap_tolerate_rejected - true when the caller reads a set-level rejection itself (e.g. alreadyExists). +# Param: stalwart_jmap_act_as - account address the recovery admin acts for; unset runs the call as the admin itself. - name: "🔧 Stalwart JMAP call" ansible.builtin.shell: cmd: >- @@ -9,7 +10,7 @@ -H "Content-Type:application/json" {{ STALWART_JMAP_URL }} stdin: |- - user = {{ (STALWART_ADMIN_USER ~ ':' ~ STALWART_ADMIN_PASSWORD) | to_json(ensure_ascii=False) }} + user = {{ ((stalwart_jmap_act_as ~ '%' if stalwart_jmap_act_as is defined else '') ~ STALWART_ADMIN_USER ~ ':' ~ STALWART_ADMIN_PASSWORD) | to_json(ensure_ascii=False) }} data-binary = {{ stalwart_jmap_body | to_json(ensure_ascii=False) }} register: stalwart_jmap_raw changed_when: false diff --git a/roles/web-app-stalwart/templates/jmap/rcpt/create.json.j2 b/roles/web-app-stalwart/templates/jmap/rcpt/create.json.j2 deleted file mode 100644 index a5df60533b..0000000000 --- a/roles/web-app-stalwart/templates/jmap/rcpt/create.json.j2 +++ /dev/null @@ -1,4 +0,0 @@ -{ - "using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"], - "methodCalls": [["x:MtaStageRcpt/set", {"create": {"r": {"allowRelaying": {"else": {{ stalwart_relay_expr | trim | to_json }} }}}}, "c0"]] -} diff --git a/roles/web-app-stalwart/templates/jmap/rcpt/update.json.j2 b/roles/web-app-stalwart/templates/jmap/rcpt/update.json.j2 index 027616add2..b850276ba9 100644 --- a/roles/web-app-stalwart/templates/jmap/rcpt/update.json.j2 +++ b/roles/web-app-stalwart/templates/jmap/rcpt/update.json.j2 @@ -1,4 +1,4 @@ { "using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"], - "methodCalls": [["x:MtaStageRcpt/set", {"update": {"singleton": {"allowRelaying": {"else": {{ stalwart_relay_expr | trim | to_json }} }}}}, "c0"]] + "methodCalls": [["x:MtaStageRcpt/set", {"update": {"singleton": {"allowRelaying": {"else": {{ STALWART_RELAY_RULE | to_json }} }}}}, "c0"]] } diff --git a/roles/web-app-stalwart/templates/test.env.j2 b/roles/web-app-stalwart/templates/test.env.j2 index e74416f36a..2076165c5f 100644 --- a/roles/web-app-stalwart/templates/test.env.j2 +++ b/roles/web-app-stalwart/templates/test.env.j2 @@ -12,8 +12,14 @@ MAILU_SMTP_CONTAINER={{ lookup('config', 'web-app-mailu', 'services.smtp.name') MAIL_DOMAIN={{ STALWART_DOMAIN | quote }} +MAIL_HOST={{ lookup('email').host | quote }} + MIGRATION_SCRIPT={{ lookup('path_absolute', 'roles', application_id, 'files/python/migrate_from_mailu.py') | quote }} +NO_REPLY_EMAIL={{ lookup('users', 'no-reply').email | quote }} + +NO_REPLY_SMTP_PASSWORD={{ lookup('email').password | quote }} + PYTHON_BIN={{ (lookup('env', 'PYTHON') or '/opt/venvs/infinito/bin/python') | quote }} STALWART_MIGRATION_E2E={{ ((application_id in PRIMARY_APPS) and ('web-app-mailu' in group_names) and DEPLOYMENT_MODE == 'compose' and IS_STACK_HOST | bool) | string | lower }} diff --git a/roles/web-app-stalwart/vars/main.yml b/roles/web-app-stalwart/vars/main.yml index 4d4cb42c1b..670c423955 100644 --- a/roles/web-app-stalwart/vars/main.yml +++ b/roles/web-app-stalwart/vars/main.yml @@ -65,7 +65,8 @@ STALWART_USERS: "{{ lookup('stalwart_users') }}" STALWART_DMARC_RUF: "{{ lookup('config', application_id, 'users.administrator.email') }}" -STALWART_TRUSTED_NETWORKS: "{{ NETWORK_TRUSTED_PROXY_CIDRS }}" +STALWART_BOT_APP_PASSWORD_DESCRIPTION: "Infinito.Nexus submission" +STALWART_RELAY_RULE: "!is_empty(authenticated_as)" STALWART_SSO_ENABLED: "{{ lookup('sso', application_id, 'is_enabled') | bool }}" diff --git a/tests/unit/python/plugins/lookup/test_email.py b/tests/unit/python/plugins/lookup/test_email.py index 95b29c3efb..019b1ac16f 100644 --- a/tests/unit/python/plugins/lookup/test_email.py +++ b/tests/unit/python/plugins/lookup/test_email.py @@ -253,7 +253,7 @@ class TestEmailLookup(unittest.TestCase): _write_role_config( self._tmp, "web-app-mailprov", - {"sso": {"oidc": {"submission_via_relay": True}}}, + {"sso": {"enabled": True}}, ) variables = { "MAIL_PROVIDER": "web-app-mailprov", @@ -269,56 +269,11 @@ class TestEmailLookup(unittest.TestCase): self.assertFalse(result["start_tls"]) self.assertFalse(result["tls"]) - def test_sso_relay_provider_disables_auth_and_uses_port_25(self) -> None: - # submission_via_relay + Keycloak deployed -> relay on 25, no auth, STARTTLS. + def test_a_provider_behind_sso_keeps_authenticated_submission(self) -> None: _write_role_config( self._tmp, "web-app-mailprov", - {"sso": {"oidc": {"submission_via_relay": True}}}, - ) - variables = { - "MAIL_PROVIDER": "web-app-mailprov", - "group_names": ["web-app-mailprov", "web-app-keycloak"], - "groups": {"web-app-mailprov": ["host1"], "web-app-keycloak": ["host1"]}, - "TLS_ENABLED": True, - "inventory_hostname": "host1", - } - result = self.lookup.run( - [], variables=variables, roles_dir=str(self._tmp / "roles") - )[0] - self.assertEqual(result["port"], 25) - self.assertFalse(result["auth"]) - self.assertTrue(result["start_tls"]) - - def test_sso_relay_inactive_without_keycloak(self) -> None: - # Same provider, but Keycloak is not deployed: keep authenticated 465. - _write_role_config( - self._tmp, - "web-app-mailprov", - {"sso": {"oidc": {"submission_via_relay": True}}}, - ) - variables = { - "MAIL_PROVIDER": "web-app-mailprov", - "group_names": ["web-app-mailprov"], - "groups": {"web-app-mailprov": ["host1"]}, - "TLS_ENABLED": True, - "inventory_hostname": "host1", - } - result = self.lookup.run( - [], variables=variables, roles_dir=str(self._tmp / "roles") - )[0] - self.assertEqual(result["port"], 465) - self.assertTrue(result["auth"]) - self.assertFalse(result["start_tls"]) - - def test_sso_relay_inactive_when_sso_enabled_pinned_false(self) -> None: - # A variant pins the provider's sso.enabled to a literal false while - # submission_via_relay stays true (role default) and Keycloak is still - # deployed: the provider keeps password auth, so no relay mode. - _write_role_config( - self._tmp, - "web-app-mailprov", - {"sso": {"enabled": False, "oidc": {"submission_via_relay": True}}}, + {"sso": {"enabled": True}}, ) variables = { "MAIL_PROVIDER": "web-app-mailprov", @@ -332,6 +287,7 @@ class TestEmailLookup(unittest.TestCase): )[0] self.assertEqual(result["port"], 465) self.assertTrue(result["auth"]) + self.assertEqual(result["auth_mechanism"], "on") self.assertFalse(result["start_tls"]) def test_a_provider_without_plaintext_submission_falls_back_to_its_mx( @@ -396,33 +352,6 @@ class TestEmailLookup(unittest.TestCase): self.assertTrue(result["auth"]) self.assertEqual(result["auth_mechanism"], "plain") - def test_sso_relay_active_with_untemplated_enabled_gate(self) -> None: - # The role default gates sso.enabled on group_names (raw Jinja here); - # the guard must not treat the untemplated string as false. - _write_role_config( - self._tmp, - "web-app-mailprov", - { - "sso": { - "enabled": "{{ 'web-app-keycloak' in group_names }}", - "oidc": {"submission_via_relay": True}, - } - }, - ) - variables = { - "MAIL_PROVIDER": "web-app-mailprov", - "group_names": ["web-app-mailprov", "web-app-keycloak"], - "groups": {"web-app-mailprov": ["host1"], "web-app-keycloak": ["host1"]}, - "TLS_ENABLED": True, - "inventory_hostname": "host1", - } - result = self.lookup.run( - [], variables=variables, roles_dir=str(self._tmp / "roles") - )[0] - self.assertEqual(result["port"], 25) - self.assertFalse(result["auth"]) - self.assertTrue(result["start_tls"]) - def test_computed_defaults_are_templated(self) -> None: self.lookup._templar = _DummyTemplar( {"DOMAIN_PRIMARY_RESOLVED": "mail.example.org"} diff --git a/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py b/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py index ac76ffb055..cc1884e0ac 100644 --- a/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py +++ b/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py @@ -2,7 +2,7 @@ Ruby's ``net/smtp`` authenticates whenever a ``user_name`` or ``password`` is present, falling back to its default auth type — ``authentication: nil`` does -NOT switch it off. So on the provider's SSO relay, where port 25 offers no +NOT switch it off. So on the provider's MX listener, where port 25 offers no AUTH at all, leaving the credentials in the hash makes every send die with ``503 5.5.1 AUTH not allowed`` while GitLab still reports the mail as queued. The credentials therefore have to be omitted, not merely unaccompanied by an