mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 12:17:13 +00:00
fix OIDC and add more scenarios to tests
This commit is contained in:
17 files changed
+522
-98
No files matched your search
+35
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user