Files
computer-playbook/roles/web-app-stalwart/tasks/06_oidc.yml
kevinveenbirkenbachandClaude Opus 5.5 c75648ccdd fix(web-app-stalwart): port the role to server v1.0.0 and guard 0.16 stores
Stalwart v1.0.0 rejects unknown JMAP properties ("Invalid property" ->
notCreated/notUpdated), validates OIDC tokens more strictly and refuses to
open a data store from before 1.0. Port the role and protect existing
installs:

- Milter: hostname/timeoutConnect/timeoutCommand/timeoutData become
  host/connectTimeout/commandTimeout/dataTimeout; the existing milter is
  looked up by host.
- Onion relay route: address becomes host.
- OIDC directory: requireAudience is gone. Set skipAudienceValidation and
  unscopedTokens=interoperable on create and update, so Keycloak tokens
  (no Stalwart audience, no mail scope) keep working for the WebUI and
  Roundcube XOAUTH2.
- WebUI: send oauthClientId only with SSO enabled; without SSO the WebUI
  falls back to its own client metadata document.
- Store guard: before anything changes, a running Stalwart whose table u
  still has the 0.16 bigint layout stops the deploy and points to the
  stalwart-migrate steps, which the README now documents under
  "Upgrading from 0.16".
- Updater: pin the WebUI git_tags source to v2.* to match server 1.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:46:08 +01:00

99 lines
4.7 KiB
YAML

---
# 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.
# JMAP request bodies live in templates/jmap/*.json.j2.
- name: "📖 List existing Stalwart directories"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/directory/get.json.j2') }}"
- 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: "{{ lookup('template', 'jmap/directory/create_oidc.json.j2') }}"
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
- name: "🔧 Accept Keycloak tokens without a Stalwart audience or mail scope"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/directory/token_validation.json.j2') }}"
when: stalwart_oidc_dir_id | length > 0
- name: "📖 List Stalwart built-in roles"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/role_get.json.j2') }}"
- name: "🔍 Resolve built-in Stalwart role ids by description"
ansible.builtin.set_fact:
stalwart_role_user: "{{ stalwart_jmap.methodResponses[0][1].list | selectattr('description', 'equalto', 'User') | map(attribute='id') | first | default('') }}"
stalwart_role_group: "{{ stalwart_jmap.methodResponses[0][1].list | selectattr('description', 'equalto', 'Group') | map(attribute='id') | first | default('')
}}"
stalwart_role_tenant: "{{ stalwart_jmap.methodResponses[0][1].list | selectattr('description', 'equalto', 'Tenant Administrator') | map(attribute='id') | first
| default('') }}"
stalwart_role_admin: "{{ stalwart_jmap.methodResponses[0][1].list | selectattr('description', 'equalto', 'System Administrator') | map(attribute='id') | first
| default('') }}"
- name: "✅ Ensure the built-in Stalwart 'User' role exists (grants mailbox permissions)"
ansible.builtin.assert:
that: stalwart_role_user | length > 0
fail_msg: "Stalwart built-in 'User' role not found; cannot grant IMAP/SMTP mailbox permissions."
- name: "🔧 Assemble default role-id maps for the authentication singleton"
ansible.builtin.set_fact:
stalwart_auth_defaults:
directoryId: "{{ stalwart_oidc_dir_id }}"
defaultUserRoleIds: "{{ {stalwart_role_user: true} }}"
defaultGroupRoleIds: "{{ {stalwart_role_group: true} if stalwart_role_group | length > 0 else {stalwart_role_user: true} }}"
defaultTenantRoleIds: "{{ {stalwart_role_tenant: true} | combine({stalwart_role_user: true}) if stalwart_role_tenant | length > 0 else {stalwart_role_user:
true} }}"
defaultAdminRoleIds: "{{ {stalwart_role_admin: true} | combine({stalwart_role_user: true}) if stalwart_role_admin | length > 0 else {stalwart_role_user: true}
}}"
- name: "📖 Read the Stalwart authentication singleton"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/authentication/get.json.j2') }}"
- 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 (OIDC directory + default roles)"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/authentication/create.json.j2') }}"
when: not (stalwart_auth_exists | bool)
- name: "🔗 Point the authentication singleton at Keycloak + seed default roles"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/authentication/update.json.j2') }}"
when: stalwart_auth_exists | bool
- name: "📖 Read the RCPT stage singleton"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/rcpt/get.json.j2') }}"
- name: "🔒 Restrict relaying to authenticated sessions"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/rcpt/update.json.j2') }}"
when: (stalwart_jmap.methodResponses[0][1].list | length) > 0