diff --git a/plugins/lookup/email.py b/plugins/lookup/email.py index c39176c9c6..87110f6046 100644 --- a/plugins/lookup/email.py +++ b/plugins/lookup/email.py @@ -134,6 +134,9 @@ class LookupModule(LookupBase): if short_key == "port": external = _as_bool(resolved.get("external")) tls = _as_bool(resolved.get("tls")) + # SSO relay: port 25 is the only listener not requiring SMTP AUTH. + if self._provider_uses_sso_relay(variables): + return 25 return 465 if (external and tls) else 25 if short_key == "host": env = resolved.get("environment") @@ -144,9 +147,12 @@ class LookupModule(LookupBase): env = resolved.get("environment") if env in ("external_container", "localhost"): return False + if self._provider_uses_sso_relay(variables): + return False return _as_bool(resolved.get("tls")) if short_key == "start_tls": - return False + # SSO relay uses STARTTLS on port 25. + return self._provider_uses_sso_relay(variables) if short_key == "smtp": return True if short_key == "from": @@ -174,6 +180,34 @@ class LookupModule(LookupBase): value = variables.get("MAIL_PROVIDER") return str(value).strip() if value else DEFAULT_MAIL_PROVIDER + 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 + apps = ApplicationsLookup() + apps._templar = getattr(self, "_templar", None) + forwarded = { + k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir" + } + try: + entry = apps.run( + [self._mail_provider(variables), {}], + variables=variables, + **forwarded, + )[0] + except AnsibleError: + return False + if not isinstance(entry, dict): + return False + services = entry.get("services") or {} + 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 + return _as_bool(oidc.get("submission_via_relay")) + def _lookup_mail_provider_domain(self, variables: dict[str, Any]) -> Any: domain_lookup = DomainLookup() domain_lookup._templar = getattr(self, "_templar", None) diff --git a/roles/web-app-stalwart/README.md b/roles/web-app-stalwart/README.md index 79b1f8aa0d..c84a78508e 100644 --- a/roles/web-app-stalwart/README.md +++ b/roles/web-app-stalwart/README.md @@ -30,6 +30,33 @@ at runtime via the JMAP management API; `config.json` only bootstraps the data store. (Stalwart's spam filter is built in, so no separate ClamAV container is needed.) +## SSO (Keycloak / OpenID Connect) + +When `web-app-keycloak` is present the role joins the shared Keycloak client and +delegates **interactive** authentication to Keycloak: + +- **WebAdmin + IMAP/SMTP/JMAP** authenticate against an **OIDC directory** + (`tasks/08_oidc.yml`): Stalwart validates Keycloak-issued tokens, matching the + `preferred_username` claim to the provisioned account. +- **Roundcube** logs users in over OAuth2 and talks to Stalwart with **XOAUTH2** + (`templates/roundcube-oauth.inc.php.j2`). + +**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: + +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. + +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`. + ## Features - All-in-one mail server (SMTP/IMAP/JMAP/POP3/ManageSieve) diff --git a/roles/web-app-stalwart/TODO.md b/roles/web-app-stalwart/TODO.md deleted file mode 100644 index 2f1b5fc765..0000000000 --- a/roles/web-app-stalwart/TODO.md +++ /dev/null @@ -1,32 +0,0 @@ -# web-app-stalwart — open items - -## Playwright persona flows are blocked (PERSONA_*_BLOCKED=true) - -`templates/playwright.env.j2` declares `PERSONA_ADMINISTRATOR_BLOCKED`, -`PERSONA_BIBER_BLOCKED` and `PERSONA_GUEST_BLOCKED` as `true`. - -**Why:** the shared persona journeys (`runGuestFlow`/`runBiberFlow`/`runAdminFlow`) -navigate via the dashboard/SSO Account menu, which depends on the Stalwart OIDC -chain. That chain is not wired yet, so the journeys cannot complete. - -**Path back to runnable:** wire the OIDC chain (below), confirm Keycloak SSO login -to the WebAdmin + Roundcube works on a `.test` deploy, then flip the three -`PERSONA_*_BLOCKED` flags to `false` (or dashboard-conditional, as web-app-mailu does). - -## OIDC chain (the remaining Mailu-parity gap) - -The `sso: {flavor: oidc}` block is declared, so Stalwart auto-joins the shared -Keycloak client (redirect URIs include `webmail.`). Still to do, validated -on a `.test` deploy: - -- **Stalwart side:** additive `x:OidcDirectory` (issuerUrl = `OIDC.CLIENT.ISSUER_URL`, - claimUsername = `preferred_username`) for token validation — WITHOUT switching - `x:Authentication.directoryId` (that would break the internal admin/mail-bot accounts). -- **Roundcube side:** OAuth2 config → Keycloak + XOAUTH2 to Stalwart. -- Then the `safeSkipUnlessEnabled("sso")` bespoke scenarios run instead of skip. - -## Submission port - -Stalwart's default submission listener is `465` (implicit TLS); it does not bind -`587`. The compose maps `587` but nothing serves it. Consider adding a `587` -submission listener (standard for desktop clients) or dropping the unused map. diff --git a/roles/web-app-stalwart/files/playwright/playwright.spec.js b/roles/web-app-stalwart/files/playwright/playwright.spec.js index 6729ae6b8a..75b6ba745a 100644 --- a/roles/web-app-stalwart/files/playwright/playwright.spec.js +++ b/roles/web-app-stalwart/files/playwright/playwright.spec.js @@ -9,12 +9,9 @@ const { safeSkipUnlessEnabled, } = require("./personas"); -// Stalwart e2e — mirrors web-app-mailu's scenario set: -// smoke (WebAdmin TLS), SSO login -> admin -> logout, biber -> administrator -// send/receive (via Roundcube webmail), plus the shared guest/biber/admin -// persona flows. SSO scenarios run only when the OIDC chain is wired -// (SSO_SERVICE_ENABLED); the persona flows are gated by PERSONA_*_BLOCKED in -// templates/playwright.env.j2 (see TODO.md). +// Stalwart e2e — mirrors web-app-mailu: WebAdmin TLS smoke, SSO login, biber -> +// admin send/receive via Roundcube, and the shared persona flows. SSO scenarios +// run when SSO_SERVICE_ENABLED; personas gated by PERSONA_*_BLOCKED. test.use({ ignoreHTTPSErrors: true }); const appBaseUrl = decodeDotenvQuotedValue(process.env.APP_BASE_URL || "").replace(/\/+$/, ""); @@ -24,19 +21,29 @@ const oidcIssuerUrl = decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL || "") const adminEmail = decodeDotenvQuotedValue(process.env.ADMIN_EMAIL || ""); const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || ""); const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || ""); -const biberEmail = decodeDotenvQuotedValue(process.env.BIBER_EMAIL || ""); const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME || ""); const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD || ""); -async function roundcubeLogin(page, user, pass) { +// Roundcube auto-redirects to Keycloak (oauth_login_redirect); drive the login +// form, then wait for the mail UI. +async function roundcubeSsoLogin(page, username, password) { + const expectedOidcAuthUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`; await page.goto(`${webmailBaseUrl}/`); - const userField = page.locator("#rcmloginuser, input[name='_user']").first(); - await userField.waitFor({ state: "visible", timeout: 30_000 }); - await userField.fill(user); - await page.locator("#rcmloginpwd, input[name='_pass']").first().fill(pass); - await page.locator("#rcmloginsubmit, button[type='submit'], input[type='submit']").first().click(); + const ssoButton = page + .getByRole("button", { name: /sso|single sign.?on|login with|openid/i }) + .or(page.getByRole("link", { name: /sso|single sign.?on|login with|openid/i })); + if (await ssoButton.first().isVisible({ timeout: 5_000 }).catch(() => false)) { + await ssoButton.first().click(); + } + await expect + .poll(() => page.url(), { + timeout: 30_000, + message: `Expected redirect to Keycloak OIDC: ${expectedOidcAuthUrl}`, + }) + .toContain(expectedOidcAuthUrl); + await performKeycloakLoginForm(page, username, password); await page.locator("#messagelist, .compose, a[href*='_action=compose'], .toolbar").first() - .waitFor({ state: "visible", timeout: 30_000 }); + .waitFor({ state: "visible", timeout: 60_000 }); } async function roundcubeLogout(page) { @@ -72,7 +79,7 @@ test("stalwart: WebAdmin is served under canonical domain with TLS", async ({ pa expect(response.headers()["strict-transport-security"], "Stalwart must emit HSTS").toBeTruthy(); }); -// Scenario I: SSO login -> WebAdmin -> logout (runs once the OIDC chain is wired). +// Scenario I: SSO login -> WebAdmin -> logout. test("stalwart: sso login, open WebAdmin, logout", async ({ page }) => { safeSkipUnlessEnabled("sso"); const expectedOidcAuthUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`; @@ -98,8 +105,10 @@ test("stalwart: sso login, open WebAdmin, logout", async ({ page }) => { }); // Scenario II: biber -> administrator send/receive through the Roundcube webmail UI. +// Login is via Keycloak SSO (Roundcube XOAUTH2 -> Stalwart), mirroring web-app-mailu. // biber and the administrator are separate people: isolated browser contexts. test("stalwart: biber sends to administrator, administrator receives it", async ({ browser }) => { + safeSkipUnlessEnabled("sso"); test.skip(!webmailBaseUrl || !biberPassword || !adminPassword, "Requires WEBMAIL_BASE_URL + provisioned biber/administrator accounts"); @@ -109,7 +118,7 @@ test("stalwart: biber sends to administrator, administrator receives it", async try { const biberPage = await biberContext.newPage(); - await roundcubeLogin(biberPage, biberUsername || biberEmail, biberPassword); + await roundcubeSsoLogin(biberPage, biberUsername, biberPassword); await biberPage.goto(`${webmailBaseUrl}/?_task=mail&_action=compose`); await biberPage.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => {}); await biberPage.locator("#_to, input[name='_to']").first().fill(adminEmail); @@ -121,7 +130,7 @@ test("stalwart: biber sends to administrator, administrator receives it", async await roundcubeLogout(biberPage); const adminPage = await adminContext.newPage(); - await roundcubeLogin(adminPage, adminUsername || adminEmail, adminPassword); + await roundcubeSsoLogin(adminPage, adminUsername, adminPassword); await adminPage.getByRole("link", { name: "Inbox" }).first().click().catch(() => {}); const emailRow = await waitForEmailInInbox(adminPage, testSubject, 60_000); await expect(emailRow).toBeVisible(); diff --git a/roles/web-app-stalwart/meta/services.yml b/roles/web-app-stalwart/meta/services.yml index b9c454a4b1..c12ef6fa44 100644 --- a/roles/web-app-stalwart/meta/services.yml +++ b/roles/web-app-stalwart/meta/services.yml @@ -8,6 +8,9 @@ sso: oidc: email_by_username: true enable_user_creation: true + # Under SSO, Stalwart disables password submission, so the no-reply bot + # relays without SMTP AUTH (see plugins/lookup/email.py). Mailu omits this. + submission_via_relay: true logout: bond: 1 enabled: "{{ 'web-svc-logout' in group_names }}" diff --git a/roles/web-app-stalwart/tasks/01_core.yml b/roles/web-app-stalwart/tasks/01_core.yml index 1be2a9bf33..1c6540ce32 100644 --- a/roles/web-app-stalwart/tasks/01_core.yml +++ b/roles/web-app-stalwart/tasks/01_core.yml @@ -17,6 +17,21 @@ # Must be world-readable: the container runs as UID 2000. mode: "0644" +- name: Ensure Roundcube custom-config directory exists + file: + path: "{{ STALWART_WEBMAIL_CONFIG_DIR }}" + state: directory + mode: "0755" + when: STALWART_SSO_ENABLED | bool + +- name: Render Roundcube OAuth (Keycloak SSO) configuration + template: + src: roundcube-oauth.inc.php.j2 + dest: "{{ STALWART_WEBMAIL_OAUTH_CONFIG }}" + # Included by the roundcube container, which runs as www-data. + mode: "0644" + when: STALWART_SSO_ENABLED | bool + - name: "load docker, db and proxy for {{ application_id }}" include_role: name: sys-stk-full diff --git a/roles/web-app-stalwart/tasks/03b_provision_domain.yml b/roles/web-app-stalwart/tasks/03b_provision_domain.yml index 5febc85e27..d8a4b106a3 100644 --- a/roles/web-app-stalwart/tasks/03b_provision_domain.yml +++ b/roles/web-app-stalwart/tasks/03b_provision_domain.yml @@ -45,6 +45,10 @@ include_tasks: 07_tls.yml when: lookup('tls', application_id, 'enabled') | bool +- name: "Wire Keycloak as the OIDC authentication directory (SSO)" + include_tasks: 08_oidc.yml + when: STALWART_SSO_ENABLED | bool + - name: "Read Stalwart-managed DKIM public key" include_tasks: 05_read_dkim.yml diff --git a/roles/web-app-stalwart/tasks/08_oidc.yml b/roles/web-app-stalwart/tasks/08_oidc.yml new file mode 100644 index 0000000000..e5a0a70c07 --- /dev/null +++ b/roles/web-app-stalwart/tasks/08_oidc.yml @@ -0,0 +1,108 @@ +--- +# Point Stalwart's auth directory at Keycloak. The directory is Internal XOR one +# external directory (no fallback), so SSO switches all interactive auth to OIDC +# and disables password login — see the role README for the full rationale. + +# OIDC directory (Keycloak) +- name: "List existing Stalwart directories" + include_tasks: jmap_call.yml + vars: + stalwart_jmap_body: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:Directory/get', {'ids': none}, 'c0']]} | to_json }} + +- name: "Resolve existing Keycloak OIDC directory id (if any)" + ansible.builtin.set_fact: + stalwart_oidc_dir_id: >- + {{ (stalwart_jmap.methodResponses[0][1].list + | selectattr('@type', 'equalto', 'Oidc') + | selectattr('issuerUrl', 'equalto', OIDC.CLIENT.ISSUER_URL) + | map(attribute='id') | first) | default('') }} + +- name: "Create the Keycloak OIDC authentication directory" + include_tasks: jmap_call.yml + vars: + stalwart_jmap_body: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:Directory/set', {'create': {'kc': { + '@type': 'Oidc', + 'description': 'Keycloak SSO (' ~ OIDC.CLIENT.REALM ~ ')', + 'issuerUrl': OIDC.CLIENT.ISSUER_URL, + 'requireAudience': OIDC.CLIENT.ID, + 'claimUsername': OIDC.ATTRIBUTES.USERNAME, + 'usernameDomain': STALWART_DOMAIN, + 'claimName': 'name' + }}}, 'c0']]} | to_json }} + when: stalwart_oidc_dir_id | length == 0 + +- name: "Capture the newly created OIDC directory id" + ansible.builtin.set_fact: + stalwart_oidc_dir_id: "{{ stalwart_jmap.methodResponses[0][1].created.kc.id }}" + when: stalwart_oidc_dir_id | length == 0 + +# Point authentication at the OIDC directory +- name: "Read the Stalwart authentication singleton" + include_tasks: jmap_call.yml + vars: + stalwart_jmap_body: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:Authentication/get', {'ids': none}, 'c0']]} | to_json }} + +- name: "Record whether the authentication singleton exists" + ansible.builtin.set_fact: + stalwart_auth_exists: "{{ (stalwart_jmap.methodResponses[0][1].list | length) > 0 }}" + +- name: "Create the authentication singleton pointing at Keycloak" + include_tasks: jmap_call.yml + vars: + stalwart_jmap_body: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:Authentication/set', {'create': {'a': + {'directoryId': stalwart_oidc_dir_id}}}, 'c0']]} | to_json }} + when: not (stalwart_auth_exists | bool) + +- name: "Point the authentication singleton at Keycloak" + include_tasks: jmap_call.yml + vars: + stalwart_jmap_body: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:Authentication/set', {'update': {'singleton': + {'directoryId': stalwart_oidc_dir_id}}}, 'c0']]} | to_json }} + when: stalwart_auth_exists | bool + +# Trusted-network relay: let the internal nets relay without SMTP AUTH (the +# no-reply account has no password under OIDC). `remote_ip == ''` is a +# subnet match. +- 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: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:MtaStageRcpt/get', {'ids': none}, 'c0']]} | to_json }} + +- 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: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:MtaStageRcpt/set', {'create': {'r': + {'allowRelaying': {'else': stalwart_relay_expr | trim}}}}, 'c0']]} | to_json }} + when: not (stalwart_rcpt_exists | bool) + +- name: "Update the RCPT stage with trusted-network relay" + include_tasks: jmap_call.yml + vars: + stalwart_jmap_body: >- + {{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + 'methodCalls': [['x:MtaStageRcpt/set', {'update': {'singleton': + {'allowRelaying': {'else': stalwart_relay_expr | trim}}}}, 'c0']]} | to_json }} + when: stalwart_rcpt_exists | bool diff --git a/roles/web-app-stalwart/templates/compose.yml.j2 b/roles/web-app-stalwart/templates/compose.yml.j2 index 601b769ec5..2f8b071e87 100644 --- a/roles/web-app-stalwart/templates/compose.yml.j2 +++ b/roles/web-app-stalwart/templates/compose.yml.j2 @@ -43,8 +43,15 @@ ROUNDCUBEMAIL_SMTP_SERVER: "ssl://stalwart" ROUNDCUBEMAIL_SMTP_PORT: "465" ROUNDCUBEMAIL_DB_TYPE: "sqlite" +{% if STALWART_SSO_ENABLED | bool %} + # Keycloak SSO client secret (kept out of the oauth.inc.php include). + ROUNDCUBEMAIL_OAUTH_CLIENT_SECRET: "{{ OIDC.CLIENT.SECRET }}" +{% endif %} volumes: - "webmail_data:/var/www/html" +{% if STALWART_SSO_ENABLED | bool %} + - "{{ STALWART_WEBMAIL_OAUTH_CONFIG }}:/var/roundcube/config/oauth.inc.php:ro" +{% endif %} depends_on: - stalwart {% include 'roles/sys-svc-container/templates/networks.yml.j2' %} diff --git a/roles/web-app-stalwart/templates/playwright.env.j2 b/roles/web-app-stalwart/templates/playwright.env.j2 index d7dc830f21..8f581d8539 100644 --- a/roles/web-app-stalwart/templates/playwright.env.j2 +++ b/roles/web-app-stalwart/templates/playwright.env.j2 @@ -6,8 +6,6 @@ ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }} APP_BASE_URL={{ lookup('tls', application_id, 'url.base') }} -BIBER_EMAIL={{ (lookup('users', 'biber').username ~ '@' ~ STALWART_DOMAIN) | dotenv_quote }} - BIBER_PASSWORD={{ lookup('users', 'biber').password | dotenv_quote }} BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }} @@ -25,14 +23,13 @@ WEBMAIL_BASE_URL={{ (lookup('tls', application_id, 'protocols.web') ~ '://' ~ ST OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }} -# Shared persona journeys route through the dashboard/SSO Account menu, which -# depends on the Stalwart OIDC chain (not yet wired — see TODO.md). Block them -# until OIDC lands; the bespoke send/receive + SSO scenarios drive Stalwart directly. +# Persona gating mirrors web-app-mailu: admin/biber blocked, guest runs when the +# dashboard is present. The SSO/send-receive scenarios drive Keycloak directly. PERSONA_ADMINISTRATOR_BLOCKED=true PERSONA_BIBER_BLOCKED=true -PERSONA_GUEST_BLOCKED=true +PERSONA_GUEST_BLOCKED={{ (not (lookup('config', application_id, 'services.dashboard.enabled') | bool)) | string | lower }} # nocheck: playwright-service-gate — SSO surface; auth flow gated by Keycloak realm not Playwright env var SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }} diff --git a/roles/web-app-stalwart/templates/roundcube-oauth.inc.php.j2 b/roles/web-app-stalwart/templates/roundcube-oauth.inc.php.j2 new file mode 100644 index 0000000000..ae7fd41393 --- /dev/null +++ b/roles/web-app-stalwart/templates/roundcube-oauth.inc.php.j2 @@ -0,0 +1,18 @@ + Keycloak, then XOAUTH2 to +// Stalwart. + +$config['oauth_provider'] = 'generic'; +$config['oauth_provider_name'] = '{{ OIDC.BUTTON_TEXT }}'; +$config['oauth_client_id'] = '{{ OIDC.CLIENT.ID }}'; +$config['oauth_auth_uri'] = '{{ OIDC.CLIENT.AUTHORIZE_URL }}'; +$config['oauth_token_uri'] = '{{ OIDC.CLIENT.TOKEN_URL }}'; +$config['oauth_identity_uri'] = '{{ OIDC.CLIENT.USER_INFO_URL }}'; +$config['oauth_logout_uri'] = '{{ OIDC.CLIENT.LOGOUT_URL }}'; +$config['oauth_scope'] = 'openid email profile'; +$config['oauth_auth_parameters'] = []; +$config['oauth_identity_fields'] = ['email', '{{ OIDC.ATTRIBUTES.USERNAME }}']; +// Auto-redirect the login form straight to Keycloak (single sign-on). +$config['oauth_login_redirect'] = true; diff --git a/roles/web-app-stalwart/vars/main.yml b/roles/web-app-stalwart/vars/main.yml index 68c5ecbd78..60661cde6a 100644 --- a/roles/web-app-stalwart/vars/main.yml +++ b/roles/web-app-stalwart/vars/main.yml @@ -31,8 +31,13 @@ STALWART_IMAGE: "{{ lookup('config', application_id, 'services.stalwart.image') STALWART_CONTAINER: "{{ lookup('config', application_id, 'services.stalwart.name') }}" STALWART_WEBMAIL_IMAGE: "{{ lookup('config', application_id, 'services.webmail.image') }}" STALWART_WEBMAIL_VERSION: "{{ lookup('config', application_id, 'services.webmail.version') }}" -STALWART_WEBMAIL_DOMAIN: "webmail.{{ DOMAIN_PRIMARY }}" +# webmail is a secondary canonical (declared in meta/server.yml +# domains.canonical); lookup('domain') only exposes the primary. +STALWART_WEBMAIL_DOMAIN: "webmail.{{ DOMAIN_PRIMARY }}" # nocheck: domain-spot STALWART_WEBMAIL_HTTP_PORT: "{{ lookup('config', application_id, 'services.webmail.ports.local.http') }}" +# Roundcube OAuth config include (rendered only when SSO is enabled). +STALWART_WEBMAIL_CONFIG_DIR: "{{ [ STALWART_VOLUMES_DIR, 'webmail' ] | path_join }}" +STALWART_WEBMAIL_OAUTH_CONFIG: "{{ [ STALWART_WEBMAIL_CONFIG_DIR, 'oauth.inc.php' ] | path_join }}" # Volumes STALWART_DATA_VOLUME: "stalwart_data" @@ -47,3 +52,12 @@ STALWART_ADMIN_PASSWORD: "{{ lookup('config', application_id, 'credentials.admin # DMARC report address (DKIM selector + key are read at runtime in 05_read_dkim.yml). STALWART_DMARC_RUF: "{{ lookup('config', application_id, 'users.administrator.email') }}" + +# Internal nets trusted to relay without SMTP AUTH under SSO (see tasks/08_oidc.yml). +STALWART_TRUSTED_NETWORKS: + - "10.0.0.0/8" + - "172.16.0.0/12" + - "192.168.0.0/16" + - "127.0.0.0/8" + +STALWART_SSO_ENABLED: "{{ lookup('sso', application_id, 'is_enabled') | bool }}" diff --git a/tests/integration/roles/meta/services/test_dynamic_flags.py b/tests/integration/roles/meta/services/test_dynamic_flags.py index b7fe93ec51..9ed4951290 100644 --- a/tests/integration/roles/meta/services/test_dynamic_flags.py +++ b/tests/integration/roles/meta/services/test_dynamic_flags.py @@ -140,6 +140,11 @@ def _suppressed_inline_flags(file_path: Path) -> set[tuple[str, str]]: return result +# Consumer ``email`` blocks reference the MAIL_PROVIDER indirection variable +# instead of a concrete provider role (Stalwart <-> Mailu is a one-var switch). +_MAIL_PROVIDER_INDIRECTION = "MAIL_PROVIDER in group_names" + + def _references_role_in_group_names(value, role_name: str) -> bool: """Return True iff *value* is a Jinja string of the shape ``{{ '' in group_names }}`` (single or double quotes).""" @@ -213,6 +218,13 @@ class TestServicesDynamicFlags(unittest.TestCase): f"databases)." ) continue + # email slot references MAIL_PROVIDER, not a provider role. + if ( + service_key == "email" + and isinstance(value, str) + and _MAIL_PROVIDER_INDIRECTION in value + ): + continue if expected_role and not _references_role_in_group_names( value, expected_role ): diff --git a/tests/unit/plugins/lookup/test_email.py b/tests/unit/plugins/lookup/test_email.py index 615b8a6b79..b0dba43add 100644 --- a/tests/unit/plugins/lookup/test_email.py +++ b/tests/unit/plugins/lookup/test_email.py @@ -136,6 +136,46 @@ class TestEmailLookup(unittest.TestCase): with self.assertRaises(AnsibleError): self.lookup.run(["a", "b"], variables={}) + def test_sso_relay_provider_disables_auth_and_uses_port_25(self) -> None: + # submission_via_relay + Keycloak deployed -> relay on 25, no auth, STARTTLS. + _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"], + "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"], + "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_computed_defaults_are_templated(self) -> None: self.lookup._templar = _DummyTemplar( {"DOMAIN_PRIMARY_RESOLVED": "mail.example.org"} diff --git a/tests/unit/utils/templating/test_ansible.py b/tests/unit/utils/templating/test_ansible.py index 21131dc32a..7583f15ca4 100644 --- a/tests/unit/utils/templating/test_ansible.py +++ b/tests/unit/utils/templating/test_ansible.py @@ -223,6 +223,36 @@ class TestTemplatingRenderStrict(unittest.TestCase): ) self.assertEqual(out, "/etc/infinito.nexus/ca/root-ca.crt") + def test_inline_conditional_with_concat(self): + # Mirrors web-app-mailu/meta/server.yml (inline conditional + ~ concat). + raw = ( + "{{ ('mail.' ~ DOMAIN_PRIMARY) if MAIL_PROVIDER == 'web-app-mailu' " + "else ('legacy-mail.' ~ DOMAIN_PRIMARY) }}" + ) + base = {"DOMAIN_PRIMARY": "example.com"} + self.assertEqual( + render_ansible_strict( + templar=None, raw=raw, var_name="x", err_prefix="t", + variables={**base, "MAIL_PROVIDER": "web-app-mailu"}, + ), + "mail.example.com", + ) + self.assertEqual( + render_ansible_strict( + templar=None, raw=raw, var_name="x", err_prefix="t", + variables={**base, "MAIL_PROVIDER": "web-app-stalwart"}, + ), + "legacy-mail.example.com", + ) + # Unset MAIL_PROVIDER takes the else branch. + self.assertEqual( + render_ansible_strict( + templar=None, raw=raw, var_name="x", err_prefix="t", + variables=base, + ), + "legacy-mail.example.com", + ) + def test_filters_lower_upper_default(self): variables = {"NAME": "MiXeD", "EMPTY": ""} out1 = render_ansible_strict( diff --git a/utils/templating/ansible.py b/utils/templating/ansible.py index 5ddb09c030..afabfcc389 100644 --- a/utils/templating/ansible.py +++ b/utils/templating/ansible.py @@ -9,6 +9,12 @@ from typing import Any from ansible.errors import AnsibleError from utils.manager.value_generator import ValueGenerator +from utils.templating.expr import ( + find_top_level_op, + is_paren_wrapped, + split_list_items, + split_top_level, +) try: from ansible._internal._datatag._tags import TrustedAsTemplate @@ -45,46 +51,6 @@ _RE_FLOAT_LITERAL = re.compile(r"^-?\d+\.\d+$") _RE_JINJA_BLOCK = re.compile(r"\{\{\s*(.*?)\s*\}\}", re.DOTALL) -def _split_list_items(s: str) -> list[str]: - """ - Split list literal inner content into items. - Supports commas, single/double quotes (no escapes), and bare tokens. - - Example: - "DIR_BIN, 'ca-inject'" -> ["DIR_BIN", "'ca-inject'"] - """ - items: list[str] = [] - buf: list[str] = [] - q: str | None = None - - for ch in s: - if q: - buf.append(ch) - if ch == q: - q = None - continue - - if ch in ("'", '"'): - buf.append(ch) - q = ch - continue - - if ch == ",": - token = "".join(buf).strip() - if token: - items.append(token) - buf = [] - continue - - buf.append(ch) - - token = "".join(buf).strip() - if token: - items.append(token) - - return items - - def _eval_list_literal(head: str, variables: dict) -> list[str]: """ Evaluate a minimal Jinja list literal like: @@ -105,7 +71,7 @@ def _eval_list_literal(head: str, variables: dict) -> list[str]: return [] out: list[str] = [] - for tok in _split_list_items(inner): + for tok in split_list_items(inner): t = tok.strip() if not t: continue @@ -186,6 +152,18 @@ def _apply_filter(value: Any, filt: str) -> Any: return value +def _eval_condition(cond: str, variables: dict) -> bool: + """Evaluate a minimal boolean condition: ``A == B`` / ``A != B`` / truthy.""" + for op, test in (("==", str.__eq__), ("!=", str.__ne__)): + idx = find_top_level_op(cond, op) + if idx != -1: + left = _fallback_eval_expr(cond[:idx], variables) + right = _fallback_eval_expr(cond[idx + len(op) :], variables) + return bool(test(left, right)) + val = _fallback_eval_expr(cond, variables) + return val not in ("", "False", "false", "0", "None") + + def _fallback_eval_expr(expr: str, variables: dict) -> str: """ Evaluate a single Jinja expression (no surrounding {{ }}). @@ -194,10 +172,37 @@ def _fallback_eval_expr(expr: str, variables: dict) -> str: - lookup('env','NAME') - VAR / VAR.path - list literal: [ VAR, 'literal', ... ] + - string concatenation: 'a' ~ VAR ~ 'b' + - inline conditional: A if COND else B (COND: X == 'y' / X != 'y') - filters: | lower, | upper, | default('x', true), | path_join Any other lookup(...) must NOT be handled here. """ + expr = expr.strip() + + # Inline conditional: A if COND else B + if_idx = find_top_level_op(expr, " if ") + else_idx = find_top_level_op(expr, " else ") + if if_idx != -1 and else_idx != -1 and if_idx < else_idx: + a = expr[:if_idx] + cond = expr[if_idx + len(" if ") : else_idx] + b = expr[else_idx + len(" else ") :] + chosen = a if _eval_condition(cond, variables) else b + return _fallback_eval_expr(chosen, variables) + + # Fully parenthesised expression + if is_paren_wrapped(expr): + return _fallback_eval_expr(expr[1:-1], variables) + + # String concatenation with ~ + tilde_parts = split_top_level(expr, "~") + if len(tilde_parts) > 1: + return "".join(_fallback_eval_expr(p, variables) for p in tilde_parts) + + # Bare string literal + if len(expr) >= 2 and expr[0] in "'\"" and expr[-1] == expr[0]: + return expr[1:-1] + parts = [p.strip() for p in expr.split("|")] head = parts[0].strip() diff --git a/utils/templating/expr.py b/utils/templating/expr.py new file mode 100644 index 0000000000..0cbb85980e --- /dev/null +++ b/utils/templating/expr.py @@ -0,0 +1,133 @@ +"""Quote/paren-aware string splitters for the fallback expression evaluator. + +Split out from ``utils.templating.ansible`` to keep that module under the +per-file line budget. +""" + +from __future__ import annotations + + +def split_list_items(s: str) -> list[str]: + """Split list-literal inner content: ``"A, 'b'"`` -> ``["A", "'b'"]``.""" + items: list[str] = [] + buf: list[str] = [] + q: str | None = None + + for ch in s: + if q: + buf.append(ch) + if ch == q: + q = None + continue + + if ch in ("'", '"'): + buf.append(ch) + q = ch + continue + + if ch == ",": + token = "".join(buf).strip() + if token: + items.append(token) + buf = [] + continue + + buf.append(ch) + + token = "".join(buf).strip() + if token: + items.append(token) + + return items + + +def find_top_level_op(s: str, op: str) -> int: + """Index of the first ``op`` at paren-depth 0 and outside quotes, else -1. + + ``op`` is matched literally; callers pad word operators with spaces + (e.g. ``" if "``) so they don't match inside identifiers. + """ + depth = 0 + quote: str | None = None + span = len(op) + i = 0 + n = len(s) + while i <= n - span: + ch = s[i] + if quote: + if ch == quote: + quote = None + i += 1 + continue + if ch in ("'", '"'): + quote = ch + i += 1 + continue + if ch in "([{": + depth += 1 + i += 1 + continue + if ch in ")]}": + depth -= 1 + i += 1 + continue + if depth == 0 and s[i : i + span] == op: + return i + i += 1 + return -1 + + +def split_top_level(s: str, sep: str) -> list[str]: + """Split ``s`` on single-char ``sep`` at paren-depth 0 outside quotes.""" + parts: list[str] = [] + buf: list[str] = [] + depth = 0 + quote: str | None = None + for ch in s: + if quote: + buf.append(ch) + if ch == quote: + quote = None + continue + if ch in ("'", '"'): + quote = ch + buf.append(ch) + continue + if ch in "([{": + depth += 1 + buf.append(ch) + continue + if ch in ")]}": + depth -= 1 + buf.append(ch) + continue + if depth == 0 and ch == sep: + parts.append("".join(buf)) + buf = [] + continue + buf.append(ch) + parts.append("".join(buf)) + return parts + + +def is_paren_wrapped(s: str) -> bool: + """True iff the outer parentheses wrap the whole expression.""" + if not (s.startswith("(") and s.endswith(")")): + return False + depth = 0 + quote: str | None = None + for i, ch in enumerate(s): + if quote: + if ch == quote: + quote = None + continue + if ch in ("'", '"'): + quote = ch + continue + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0 and i != len(s) - 1: + return False + return True