mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
fix(web-app-stalwart): authenticate bots with an app password under SSO
Under SSO the role tried to let applications relay without SMTP AUTH by widening the relay rule to the private networks. The rule compared the peer address to the text of a CIDR, which never matches, so an application could reach local mailboxes but got "550 5.1.2 Relay not allowed" for every external recipient. Repairing the comparison would have opened a relay, because published ports, Tor and the swarm mesh all hand outside clients an address inside those networks. Stalwart checks app passwords before it asks the external directory, so a bot can authenticate while the directory is OIDC. With SSO enabled the role now creates one app password per bot, acting for that account as the recovery admin, and stores it in the token store the SMTP password is already read from. A bot gets a new one when its entry is deleted in Stalwart or its token is removed. The relay rule is back to authenticated sessions only. The lookup loses its SSO relay branch and the role its submission_via_relay flag, so an SSO deployment submits on 465 with AUTH like every other one. The role's CLI test now fails when an unauthenticated session is given an external recipient, or when the bot credential is refused one. Closes #793 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ebae9c537c
commit
08ef66d8de
16 files changed
+168
-156
No files matched your search
+3
-35
@@ -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.
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ flowchart LR
|
||||
STALWART -- "OIDC directory<br/>(token validation)" --> KC
|
||||
MAIL_VHOST -. "SPA login: PKCE<br/>(stalwart-webui client)" .-> KC
|
||||
WEBMAIL_VHOST -. "OAuth login" .-> KC
|
||||
APPS -- "STARTTLS :25, no AUTH<br/>(SSO relay, trusted networks)" --> STALWART
|
||||
APPS -- "SMTPS :465, AUTH<br/>(bot credential from the token store)" --> STALWART
|
||||
WORLD <-- "public mail ports<br/>(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
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ speaks IMAP through imaplib.
|
||||
|
||||
probe_mail.py send <host> <from> <rcpt> <eml-file>
|
||||
probe_mail.py find <host> <user> <password> <subject>
|
||||
probe_mail.py relay <host> <sender> <password>
|
||||
|
||||
`relay` exits 0 when an unauthenticated session is refused an external recipient
|
||||
and the same recipient is accepted once `<sender>` 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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]]
|
||||
}
|
||||
@@ -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"]]
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -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 }}"
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user