fix OIDC and add more scenarios to tests

This commit is contained in:
Alejandro Roman committed 2026-07-01 11:19:49 +02:00
1 parent 9962429e59
commit 233f15f3e0
17 files changed
+522 -98

No files matched your search

+35 -1
View File
@@ -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)
+27
View File
@@ -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)
-32
View File
@@ -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.<domain>`). 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.
@@ -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();
+3
View File
@@ -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 }}"
+15
View File
@@ -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
@@ -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
+108
View File
@@ -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 == '<cidr>'` 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
@@ -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' %}
@@ -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 }}
@@ -0,0 +1,18 @@
<?php
// Roundcube OAuth2/OIDC against Keycloak. Auto-included by the roundcube image
// (config/*.php). Client secret comes from ROUNDCUBEMAIL_OAUTH_CLIENT_SECRET so
// it stays out of this world-readable file. Login -> 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;
+15 -1
View File
@@ -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 }}"
@@ -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
``{{ '<role_name>' 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
):
+40
View File
@@ -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"}
@@ -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(
+46 -41
View File
@@ -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()
+133
View File
@@ -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