perf(web-app-stalwart): provision accounts and bot app passwords in batched JMAP calls

Every account ran about six JMAP calls of three Ansible tasks each through jmap_call.yml, 80 s for 7 accounts on echoserver. Each phase now sends one request for all accounts: one x:Account/set creates them all (ids from created or, on alreadyExists, from notCreated.objectId), one updates the passwords of the accounts that already existed, and the app-password phases (list, mint, retire, drop) carry one method call per bot, each addressing its bot through accountId, in chunks of STALWART_JMAP_MAX_METHOD_CALLS (Stalwart's default maxMethodCalls, 16).

jmap_call.yml retries and asserts on every method response of a batch, and a refusal names its owner through the call id. A create refused for a reason other than alreadyExists now fails the deploy instead of being skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-24 10:37:01 +02:00
1 parent e8872bede8
commit 1fbd4758e3
9 files changed
+768 -119

No files matched your search

+9 -2
View File
@@ -156,8 +156,9 @@ chaining or fallback. Enabling SSO therefore **disables the account password**
for every account, including the machine `no-reply` account. App passwords are
checked before the directory and keep working, so with SSO enabled the role:
1. Creates one app password per bot account, acting for that account as the
recovery admin (`tasks/04_manage_user/app_password.yml`), and
1. Creates one app password per bot account as the recovery admin, which
addresses each bot account through its JMAP `accountId`
(`tasks/04_manage_user/app_password.yml`), and
2. Stores it in the token store, where
[`plugins/lookup/email.py`](../../plugins/lookup/email.py) reads the SMTP
password of the `no-reply` client.
@@ -207,6 +208,12 @@ account (or the Keycloak SSO token when SSO is enabled).
(`templates/jmap/*.json.j2`).
- **Only authenticated sessions relay, with or without SSO.** Under SSO a bot
authenticates with an app password.
- **Accounts and app passwords are provisioned in batches.** Each phase
(create, password update, app password list, mint, retire, drop) is one JMAP
request for all accounts. A phase on the bots' app passwords carries one
method call per bot, at most `STALWART_JMAP_MAX_METHOD_CALLS` (Stalwart's
default `maxMethodCalls`) per request, and a refusal names the account it
hit.
- **Address bans expire after `services.stalwart.ban_period_seconds`** (one
hour by default). Without a period Stalwart keeps a ban forever, and the
reverse proxy, the published ports, Tor and the swarm ingress hand many
@@ -29,19 +29,8 @@
when: stalwart_domain_id | length == 0
- name: "⚙️ Provision Stalwart accounts"
include_tasks:
file: 04_manage_user.yml
apply:
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
vars:
stalwart_user_key: "{{ user_item.key }}"
stalwart_user_name: "{{ user_item.value.username }}"
stalwart_user_password: "{{ user_item.value.password }}"
stalwart_user_roles: "{{ user_item.value.roles | default([]) }}"
loop: "{{ STALWART_USERS | dict2items }}"
loop_control:
loop_var: user_item
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
include_tasks: 04_manage_user.yml
when: STALWART_USERS | length > 0
- name: "🔧 Configure Stalwart mail-listener TLS (platform cert)"
include_tasks: 05_tls.yml
+96 -36
View File
@@ -2,58 +2,118 @@
# Bots authenticate over SMTP with a derived sha256-hex secret instead of the
# raw inventory password: consumers embed email.password unescaped in dotenv
# URLs and YAML, where the password charset ('[', '@', '#', ...) breaks parsing.
- name: "🔍 Resolve effective secret for '{{ stalwart_user_name }}'"
- name: "🔍 Resolve the Stalwart accounts and their effective secrets"
ansible.builtin.set_fact:
stalwart_user_secret: >-
{{ stalwart_user_password | hash('sha256')
if 'bot' in stalwart_user_roles else stalwart_user_password }}
stalwart_accounts: >-
{%- set accounts = [] -%}
{%- for key, user in STALWART_USERS.items() -%}
{%- set bot = 'bot' in (user.roles | default([])) -%}
{%- set _ = accounts.append({
'key': key,
'name': user.username,
'address': user.username ~ '@' ~ STALWART_DOMAIN,
'description': user.email | default(user.username),
'bot': bot,
'secret': user.password | hash('sha256') if bot else user.password,
'stored': (user.tokens | default({}, true)).get(application_id, '')}) -%}
{%- endfor -%}
{{ accounts }}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "📁 Create Stalwart account '{{ stalwart_user_name }}@{{ STALWART_DOMAIN }}'"
- name: "📁 Create the Stalwart accounts"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_tolerate_rejected: true
stalwart_jmap_body: >-
{%- set create = {} -%}
{%- for account in stalwart_accounts -%}
{%- set _ = create.update({'u' ~ loop.index0: {
'@type': 'User',
'name': account.name,
'domainId': stalwart_domain_id,
'description': account.description,
'credentials': {'0': {'@type': 'Password', 'secret': account.secret}}}}) -%}
{%- endfor -%}
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:Account/set', {'create': {'a': {
'@type': 'User',
'name': stalwart_user_name,
'domainId': stalwart_domain_id,
'description': (lookup('users', stalwart_user_key).email | default(stalwart_user_name)),
'credentials': {'0': {'@type': 'Password', 'secret': stalwart_user_secret}}
}}}, 'c0']]} | to_json }}
'methodCalls': [['x:Account/set', {'create': create}, 'c0']]} | to_json }}
- name: "🔍 Resolve pre-existing account id for '{{ stalwart_user_name }}'"
- name: "🔍 Match every account to its Stalwart id"
ansible.builtin.set_fact:
stalwart_existing_account_id: >-
{{ ((stalwart_jmap.methodResponses[0][1].notCreated | default({}))
.get('a', {}).get('objectId', {}) or {}).get('id', '') }}
stalwart_accounts: >-
{%- set result = stalwart_jmap.methodResponses[0][1] -%}
{%- set accounts = [] -%}
{%- for account in stalwart_accounts -%}
{%- set created = (result.created | default({}, true)).get('u' ~ loop.index0) or {} -%}
{%- set refusal = (result.notCreated | default({}, true)).get('u' ~ loop.index0) or {} -%}
{%- set existing = refusal.get('type') == 'alreadyExists' -%}
{%- set _ = accounts.append(account | combine({
'id': ((refusal.get('objectId') or {}).get('id') if existing else created.get('id')) or '',
'existing': existing,
'refusal': refusal})) -%}
{%- endfor -%}
{{ accounts }}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🔄 Update password for existing account '{{ stalwart_user_name }}'"
include_tasks: jmap_call.yml
- name: "✅ Ensure Stalwart created or already holds every account"
ansible.builtin.assert:
that: stalwart_account_refused | length == 0
fail_msg: "Stalwart refused the accounts {{ stalwart_account_refused }}"
quiet: true
vars:
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:Account/set', {'update': {stalwart_existing_account_id: {
'credentials': {'0': {'@type': 'Password', 'secret': stalwart_user_secret}}
}}}, 'c0']]} | to_json }}
when: stalwart_existing_account_id | length > 0
stalwart_account_refused: >-
{{ dict(stalwart_accounts | rejectattr('id') | map(attribute='address')
| zip(stalwart_accounts | rejectattr('id') | map(attribute='refusal'))) }}
- name: "💾 Persist Stalwart submission credential for bot '{{ stalwart_user_name }}'"
- name: "🔄 Update the password of every pre-existing account"
when: stalwart_accounts | selectattr('existing') | list | length > 0
block:
- name: "🔄 Send the passwords of the pre-existing accounts"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_tolerate_rejected: true
stalwart_jmap_body: >-
{%- set changes = {} -%}
{%- for account in stalwart_accounts if account.existing -%}
{%- set _ = changes.update({account.id: {
'credentials': {'0': {'@type': 'Password', 'secret': account.secret}}}}) -%}
{%- endfor -%}
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:Account/set', {'update': changes}, 'c0']]} | to_json }}
- name: "✅ Ensure Stalwart took the password of every pre-existing account"
ansible.builtin.assert:
that: stalwart_account_refused | length == 0
fail_msg: "Stalwart refused the password of the accounts {{ stalwart_account_refused }}"
quiet: true
vars:
stalwart_account_refused: >-
{%- set rejected = stalwart_jmap.methodResponses[0][1].notUpdated | default({}, true) -%}
{%- set refused = {} -%}
{%- for account in stalwart_accounts if account.id in rejected -%}
{%- set _ = refused.update({account.address: rejected[account.id]}) -%}
{%- endfor -%}
{{ refused }}
- name: "💾 Persist the Stalwart submission credential of every bot"
ansible.builtin.include_role:
name: sys-token-store
tasks_from: write.yml
vars:
sys_token_store_user_key: "{{ stalwart_user_key }}"
sys_token_store_user_key: "{{ stalwart_bot.key }}"
sys_token_store_app: "{{ application_id }}"
sys_token_store_token: "{{ stalwart_user_secret }}"
when:
- "'bot' in stalwart_user_roles"
- not (STALWART_SSO_ENABLED | bool)
sys_token_store_token: "{{ stalwart_bot.secret }}"
loop: "{{ stalwart_accounts | selectattr('bot') | list }}"
loop_control:
loop_var: stalwart_bot
label: "{{ stalwart_bot.address }}"
when: not (STALWART_SSO_ENABLED | bool)
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🔑 Keep an app password for bot '{{ stalwart_user_name }}' under SSO"
- name: "🔑 Keep an app password for every bot under SSO"
include_tasks: 04_manage_user/app_password.yml
vars:
stalwart_jmap_act_as: "{{ stalwart_user_name }}@{{ STALWART_DOMAIN }}"
when:
- "'bot' in stalwart_user_roles"
- STALWART_SSO_ENABLED | bool
loop: "{{ stalwart_accounts | selectattr('bot') | batch(STALWART_JMAP_MAX_METHOD_CALLS) | list }}"
loop_control:
loop_var: stalwart_bots
label: "{{ stalwart_bots | map(attribute='address') | join(', ') }}"
when: STALWART_SSO_ENABLED | bool
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -1,69 +1,102 @@
---
# Param: stalwart_jmap_act_as - address of the bot account, every call below runs as that account.
- name: "📖 List the app passwords of '{{ stalwart_user_name }}'"
# Param: stalwart_bots - up to STALWART_JMAP_MAX_METHOD_CALLS bot entries of stalwart_accounts; each method call addresses one bot through its accountId.
- name: "📖 List the app passwords of the bots"
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
vars:
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:AppPassword/get', {'ids': none}, 'c0']]} | to_json }}
{%- set calls = [] -%}
{%- for bot in stalwart_bots -%}
{%- set _ = calls.append(['x:AppPassword/get', {'accountId': bot.id, 'ids': none}, bot.address]) -%}
{%- endfor -%}
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], 'methodCalls': calls} | to_json }}
- name: "🔍 Decide whether '{{ stalwart_user_name }}' needs a new app password"
- name: "🔍 Decide which bots need a new app password"
ansible.builtin.set_fact:
stalwart_app_password_ids: "{{ stalwart_app_password_owned }}"
stalwart_app_password_mint: "{{ stalwart_app_password_renew }}"
stalwart_app_password_drop: "{{ stalwart_app_password_retired + (stalwart_app_password_owned if stalwart_app_password_renew | bool else []) }}"
vars:
stalwart_app_password_owned: >-
{{ stalwart_jmap.methodResponses[0][1].list
| selectattr('description', 'equalto', STALWART_BOT_APP_PASSWORD_DESCRIPTION)
| map(attribute='id') | list }}
stalwart_app_password_retired: >-
{{ stalwart_jmap.methodResponses[0][1].list
| selectattr('description', 'equalto', STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION)
| map(attribute='id') | list }}
stalwart_app_password_stored: "{{ lookup('users', stalwart_user_key, {}).get('tokens', {}).get(application_id, '') }}"
stalwart_app_password_renew: >-
{{ (stalwart_app_password_owned | length != 1)
or (stalwart_app_password_stored | length == 0)
or (stalwart_app_password_stored == stalwart_user_secret) }}
stalwart_app_password_plan: >-
{%- set listed = {} -%}
{%- for method, result, call in stalwart_jmap.methodResponses -%}
{%- set _ = listed.update({call: result.list}) -%}
{%- endfor -%}
{%- set plan = [] -%}
{%- for bot in stalwart_bots -%}
{%- set owned = listed[bot.address] | selectattr('description', 'equalto', STALWART_BOT_APP_PASSWORD_DESCRIPTION)
| map(attribute='id') | list -%}
{%- set retired = listed[bot.address] | selectattr('description', 'equalto', STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION)
| map(attribute='id') | list -%}
{%- set renew = (owned | length != 1) or (bot.stored | length == 0) or (bot.stored == bot.secret) -%}
{%- set _ = plan.append({'key': bot.key, 'address': bot.address, 'id': bot.id, 'owned': owned,
'renew': renew, 'drop': retired + (owned if renew else [])}) -%}
{%- endfor -%}
{{ plan }}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🔑 Replace the app password of '{{ stalwart_user_name }}'"
when: stalwart_app_password_mint | bool
- name: "🔑 Replace the app passwords of the bots that need a new one"
when: stalwart_app_password_plan | selectattr('renew') | list | length > 0
block:
- name: "🔑 Mint the app password of '{{ stalwart_user_name }}'"
- name: "🔑 Mint the new app passwords"
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
vars:
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:AppPassword/set', {'create': {'p': {
'description': STALWART_BOT_APP_PASSWORD_DESCRIPTION
}}}, 'c0']]} | to_json }}
{%- set calls = [] -%}
{%- for bot in stalwart_app_password_plan if bot.renew -%}
{%- set _ = calls.append(['x:AppPassword/set', {'accountId': bot.id,
'create': {'p': {'description': STALWART_BOT_APP_PASSWORD_DESCRIPTION}}}, bot.address]) -%}
{%- endfor -%}
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], 'methodCalls': calls} | to_json }}
- name: "💾 Persist the app password of '{{ stalwart_user_name }}'"
- name: "🔍 Read the minted app passwords"
ansible.builtin.set_fact:
stalwart_app_password_minted: >-
{%- set results = {} -%}
{%- for method, result, call in stalwart_jmap.methodResponses -%}
{%- set _ = results.update({call: result}) -%}
{%- endfor -%}
{%- set minted = [] -%}
{%- for bot in stalwart_app_password_plan if bot.renew -%}
{%- set _ = minted.append({'key': bot.key, 'address': bot.address,
'secret': results[bot.address].created.p.secret}) -%}
{%- endfor -%}
{{ minted }}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "💾 Persist the new app passwords"
ansible.builtin.include_role:
name: sys-token-store
tasks_from: write.yml
vars:
sys_token_store_user_key: "{{ stalwart_user_key }}"
sys_token_store_user_key: "{{ stalwart_app_password_new.key }}"
sys_token_store_app: "{{ application_id }}"
sys_token_store_token: "{{ stalwart_jmap.methodResponses[0][1].created.p.secret }}"
sys_token_store_token: "{{ stalwart_app_password_new.secret }}"
loop: "{{ stalwart_app_password_minted }}"
loop_control:
loop_var: stalwart_app_password_new
label: "{{ stalwart_app_password_new.address }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🏷️ Retire the replaced app passwords of '{{ stalwart_user_name }}'"
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
vars:
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:AppPassword/set', {'update': dict(stalwart_app_password_ids
| zip([{'description': STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION}] * (stalwart_app_password_ids | length)))},
'c0']]} | to_json }}
when: stalwart_app_password_ids | length > 0
- name: "🏷️ Retire the app passwords the new ones replace"
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
vars:
stalwart_jmap_body: >-
{%- set calls = [] -%}
{%- for bot in stalwart_app_password_plan if bot.renew and bot.owned | length > 0 -%}
{%- set _ = calls.append(['x:AppPassword/set', {'accountId': bot.id,
'update': dict(bot.owned | zip([{'description': STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION}] * (bot.owned | length)))},
bot.address]) -%}
{%- endfor -%}
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], 'methodCalls': calls} | to_json }}
when: stalwart_app_password_plan | selectattr('renew') | selectattr('owned') | list | length > 0
- name: "📥 Defer dropping the retired app passwords of '{{ stalwart_user_name }}'"
when: stalwart_app_password_drop | length > 0
- name: "📥 Defer dropping the retired app passwords"
when: stalwart_app_password_plan | selectattr('drop') | list | length > 0
block:
- name: "📥 Record the retired app passwords of '{{ stalwart_user_name }}'"
- name: "📥 Record the retired app passwords per bot"
ansible.builtin.set_fact:
stalwart_app_password_stale: "{{ stalwart_app_password_stale | combine({stalwart_jmap_act_as: stalwart_app_password_drop}) }}"
stalwart_app_password_stale: >-
{%- set stale = {} -%}
{%- for bot in stalwart_app_password_plan if bot.drop | length > 0 -%}
{%- set _ = stale.update({bot.address: {'account': bot.id, 'ids': bot.drop}}) -%}
{%- endfor -%}
{{ stalwart_app_password_stale | combine(stale) }}
- name: "📥 Queue the drop for the service-loader post-load flush"
include_role:
@@ -1,14 +1,17 @@
---
- name: "🗑️ Destroy the retired app passwords per bot account"
- name: "🗑️ Destroy the retired app passwords of the bots"
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
vars:
stalwart_jmap_act_as: "{{ stalwart_app_password_owner.key }}"
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:AppPassword/set', {'destroy': stalwart_app_password_owner.value}, 'c0']]} | to_json }}
loop: "{{ stalwart_app_password_stale | dict2items }}"
{%- set calls = [] -%}
{%- for owner in stalwart_app_password_owners -%}
{%- set _ = calls.append(['x:AppPassword/set', {'accountId': owner.value.account, 'destroy': owner.value.ids}, owner.key]) -%}
{%- endfor -%}
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], 'methodCalls': calls} | to_json }}
loop: "{{ stalwart_app_password_stale | dict2items | batch(STALWART_JMAP_MAX_METHOD_CALLS) | list }}"
loop_control:
loop_var: stalwart_app_password_owner
loop_var: stalwart_app_password_owners
label: "{{ stalwart_app_password_owners | map(attribute='key') | join(', ') }}"
- name: "🧹 Clear the dropped app passwords"
ansible.builtin.set_fact:
+19 -14
View File
@@ -1,5 +1,6 @@
---
# Reusable management JMAP call: in `stalwart_jmap_body` (JSON), out `stalwart_jmap`.
# Param: stalwart_jmap_body - JSON request; a batch carries one method call per object owner, its call id naming that owner in a refusal.
# Param: stalwart_jmap_tolerate_rejected - true when the caller reads a set-level rejection itself (e.g. alreadyExists).
# Param: stalwart_jmap_act_as - account address the recovery admin acts for; unset runs the call as the admin itself.
- name: "🔧 Stalwart JMAP call"
@@ -18,11 +19,9 @@
delay: 5
until: >-
stalwart_jmap_raw.rc == 0
and (
(stalwart_jmap_raw.stdout | from_json).methodResponses[0][0] != 'error'
or (stalwart_jmap_raw.stdout | from_json).methodResponses[0][1].type
not in STALWART_JMAP_TRANSIENT_ERRORS
)
and (stalwart_jmap_raw.stdout | from_json).methodResponses
| selectattr('0', 'equalto', 'error') | map(attribute='1.type')
| select('in', STALWART_JMAP_TRANSIENT_ERRORS) | list | length == 0
timeout: "{{ lookup('timeout', 120) | int }}"
# nocheck: include-uniform-apply-keyword — seven call sites include this file; the in-file no_log is the SPOT, per-site apply would repeat it 7×
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -35,14 +34,20 @@
- name: "✅ Ensure Stalwart accepted the JMAP call"
ansible.builtin.assert:
that:
- stalwart_jmap.methodResponses[0][0] != 'error'
- stalwart_jmap_rejected | length == 0
fail_msg: "Stalwart refused the JMAP call {{ stalwart_jmap.methodResponses[0][0] }}: {{ stalwart_jmap_rejected or stalwart_jmap.methodResponses[0][1] }}"
- stalwart_jmap_refused | length == 0
fail_msg: "Stalwart refused the JMAP call {{ stalwart_jmap_refused }}"
quiet: true
vars:
stalwart_jmap_rejected: >-
{{ {} if (stalwart_jmap_tolerate_rejected | default(false) | bool)
else (stalwart_jmap.methodResponses[0][1].notCreated | default({}, true))
| combine(stalwart_jmap.methodResponses[0][1].notUpdated | default({}, true))
| combine(stalwart_jmap.methodResponses[0][1].notDestroyed | default({}, true)
| dict2items | rejectattr('value.type', 'equalto', 'notFound') | items2dict) }}
stalwart_jmap_refused: >-
{%- set refused = {} -%}
{%- for method, result, call in stalwart_jmap.methodResponses -%}
{%- set rejected = {} if (method == 'error' or (stalwart_jmap_tolerate_rejected | default(false) | bool))
else (result.notCreated | default({}, true))
| combine(result.notUpdated | default({}, true))
| combine(result.notDestroyed | default({}, true)
| dict2items | rejectattr('value.type', 'equalto', 'notFound') | items2dict) -%}
{%- if method == 'error' or rejected | length > 0 -%}
{%- set _ = refused.update({call: {method: result if method == 'error' else rejected}}) -%}
{%- endif -%}
{%- endfor -%}
{{ refused }}
+1
View File
@@ -29,6 +29,7 @@ STALWART_JMAP_APPLICATION_GET: >-
STALWART_JMAP_TRANSIENT_ERRORS:
- serverUnavailable
- rateLimit
STALWART_JMAP_MAX_METHOD_CALLS: 16
STALWART_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, 'stalwart') }}"
STALWART_DOMAIN: "{{ lookup('config', application_id, 'services.stalwart.domain') }}"
@@ -0,0 +1,449 @@
from __future__ import annotations
import hashlib
import json
import unittest
from ansible.parsing.dataloader import DataLoader
from ansible.template import Templar
from utils.cache.yaml import load_yaml_any
from utils.templating.ansible import _trust_as_template
from . import PROJECT_ROOT
TASKS = PROJECT_ROOT / "roles" / "web-app-stalwart" / "tasks"
DESCRIPTION = "Infinito.Nexus submission"
RETIRED = "Infinito.Nexus submission, retired"
USING = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]
USERS = {
"administrator": {
"username": "administrator",
"password": "admin-pw",
"email": "administrator@infinito.test",
"roles": [],
},
"no-reply": {
"username": "no-reply",
"password": "no-reply-pw",
"email": "no-reply@infinito.test",
"roles": ["bot"],
"tokens": {"web-app-stalwart": "stored-app-password"},
},
"contact": {"username": "contact", "password": "contact-pw", "roles": ["bot"]},
}
def _flatten(tasks: list[dict]) -> list[dict]:
flat = []
for task in tasks:
flat.append(task)
flat.extend(_flatten(task.get("block") or []))
return flat
def _task(path: str, name: str) -> dict:
tasks = _flatten(load_yaml_any(str(TASKS / path), default_if_missing=[]))
return next(t for t in tasks if t.get("name") == name)
def _render(template, **variables):
templar = Templar(loader=DataLoader(), variables=variables)
return templar.template(_trust_as_template(template))
def _fact(path: str, name: str, fact: str, **variables):
return _render(_task(path, name)["ansible.builtin.set_fact"][fact], **variables)
def _body(path: str, name: str, **variables) -> dict:
task = _task(path, name)
variables = {**variables, **(task.get("vars") or {})}
return json.loads(_render(task["vars"]["stalwart_jmap_body"], **variables))
def _assert_var(path: str, name: str, var: str, **variables):
return _render(_task(path, name)["vars"][var], **variables)
def _sha(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
BASE = {
"STALWART_USERS": USERS,
"STALWART_DOMAIN": "infinito.test",
"STALWART_JMAP_MAX_METHOD_CALLS": 16,
"STALWART_BOT_APP_PASSWORD_DESCRIPTION": DESCRIPTION,
"STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION": RETIRED,
"application_id": "web-app-stalwart",
"stalwart_domain_id": "d1",
"stalwart_app_password_stale": {},
}
CREATE_RESPONSE = [
[
"x:Account/set",
{
"created": {"u0": {"id": "a"}},
"notCreated": {
"u1": {"type": "alreadyExists", "objectId": {"id": "b"}},
"u2": {"type": "alreadyExists", "objectId": {"id": "c"}},
},
},
"c0",
]
]
def _resolved() -> list[dict]:
return _fact(
"04_manage_user.yml",
"🔍 Resolve the Stalwart accounts and their effective secrets",
"stalwart_accounts",
**BASE,
)
def _matched(response: list = CREATE_RESPONSE) -> list[dict]:
return _fact(
"04_manage_user.yml",
"🔍 Match every account to its Stalwart id",
"stalwart_accounts",
**BASE,
stalwart_accounts=_resolved(),
stalwart_jmap={"methodResponses": response},
)
class TestAccountPhases(unittest.TestCase):
def test_bots_get_the_derived_secret_and_people_their_password(self):
accounts = {a["key"]: a for a in _resolved()}
self.assertEqual(accounts["administrator"]["secret"], "admin-pw")
self.assertEqual(accounts["no-reply"]["secret"], _sha("no-reply-pw"))
self.assertEqual(accounts["contact"]["address"], "contact@infinito.test")
self.assertEqual(accounts["contact"]["description"], "contact")
self.assertEqual(accounts["no-reply"]["stored"], "stored-app-password")
self.assertEqual(accounts["contact"]["stored"], "")
def test_one_create_carries_every_account(self):
body = _body(
"04_manage_user.yml",
"📁 Create the Stalwart accounts",
**BASE,
stalwart_accounts=_resolved(),
)
self.assertEqual(body["using"], USING)
self.assertEqual(len(body["methodCalls"]), 1)
method, arguments, _ = body["methodCalls"][0]
self.assertEqual(method, "x:Account/set")
creates = arguments["create"]
self.assertEqual(sorted(creates), ["u0", "u1", "u2"])
self.assertEqual(
creates["u1"],
{
"@type": "User",
"name": "no-reply",
"domainId": "d1",
"description": "no-reply@infinito.test",
"credentials": {
"0": {"@type": "Password", "secret": _sha("no-reply-pw")}
},
},
)
def test_created_and_existing_accounts_get_their_ids(self):
accounts = {a["key"]: a for a in _matched()}
self.assertEqual(
(accounts["administrator"]["id"], accounts["administrator"]["existing"]),
("a", False),
)
self.assertEqual(
(accounts["no-reply"]["id"], accounts["no-reply"]["existing"]), ("b", True)
)
def test_a_refused_create_fails_naming_the_account(self):
refusal = {"type": "invalidProperties", "properties": ["name"]}
response = [
[
"x:Account/set",
{
"created": {"u0": {"id": "a"}, "u1": {"id": "b"}},
"notCreated": {"u2": refusal},
},
"c0",
]
]
refused = _assert_var(
"04_manage_user.yml",
"✅ Ensure Stalwart created or already holds every account",
"stalwart_account_refused",
stalwart_accounts=_matched(response),
)
self.assertEqual(refused, {"contact@infinito.test": refusal})
def test_an_existing_account_without_an_id_fails(self):
response = [
["x:Account/set", {"notCreated": {"u0": {"type": "alreadyExists"}}}, "c0"]
]
refused = _assert_var(
"04_manage_user.yml",
"✅ Ensure Stalwart created or already holds every account",
"stalwart_account_refused",
stalwart_accounts=_matched(response),
)
self.assertIn("administrator@infinito.test", refused)
def test_one_update_carries_every_existing_account(self):
body = _body(
"04_manage_user.yml",
"🔄 Send the passwords of the pre-existing accounts",
**BASE,
stalwart_accounts=_matched(),
)
method, arguments, _ = body["methodCalls"][0]
self.assertEqual(method, "x:Account/set")
self.assertEqual(sorted(arguments["update"]), ["b", "c"])
self.assertEqual(
arguments["update"]["c"]["credentials"]["0"]["secret"], _sha("contact-pw")
)
def test_a_refused_update_fails_naming_the_account(self):
refused = _assert_var(
"04_manage_user.yml",
"✅ Ensure Stalwart took the password of every pre-existing account",
"stalwart_account_refused",
stalwart_accounts=_matched(),
stalwart_jmap={
"methodResponses": [
[
"x:Account/set",
{"updated": {"b": None}, "notUpdated": {"c": {"type": "x"}}},
"c0",
]
]
},
)
self.assertEqual(refused, {"contact@infinito.test": {"type": "x"}})
def test_bots_are_chunked_by_the_method_call_limit(self):
loop = _task(
"04_manage_user.yml", "🔑 Keep an app password for every bot under SSO"
)["loop"]
bots = [{"bot": True, "address": f"b{i}"} for i in range(17)]
chunks = _render(
loop,
stalwart_accounts=[*bots, {"bot": False}],
STALWART_JMAP_MAX_METHOD_CALLS=16,
)
self.assertEqual([len(c) for c in chunks], [16, 1])
def _bots() -> list[dict]:
return [a for a in _matched() if a["bot"]]
def _plan(listed: dict) -> list[dict]:
return _fact(
"04_manage_user/app_password.yml",
"🔍 Decide which bots need a new app password",
"stalwart_app_password_plan",
**BASE,
stalwart_bots=_bots(),
stalwart_jmap={
"methodResponses": [
["x:AppPassword/get", {"list": entries}, address]
for address, entries in listed.items()
]
},
)
LISTED = {
"no-reply@infinito.test": [
{"id": "1", "description": DESCRIPTION},
{"id": "2", "description": RETIRED},
],
"contact@infinito.test": [
{"id": "1", "description": DESCRIPTION},
{"id": "4", "description": DESCRIPTION},
{"id": "5", "description": "Laptop"},
],
}
class TestAppPasswordPhases(unittest.TestCase):
def test_one_list_addresses_every_bot_by_account_id(self):
body = _body(
"04_manage_user/app_password.yml",
"📖 List the app passwords of the bots",
**BASE,
stalwart_bots=_bots(),
)
self.assertEqual(
body["methodCalls"],
[
[
"x:AppPassword/get",
{"accountId": "b", "ids": None},
"no-reply@infinito.test",
],
[
"x:AppPassword/get",
{"accountId": "c", "ids": None},
"contact@infinito.test",
],
],
)
def test_the_plan_reads_each_bot_from_its_own_call(self):
plan = {p["key"]: p for p in _plan(dict(reversed(list(LISTED.items()))))}
self.assertFalse(plan["no-reply"]["renew"])
self.assertEqual(plan["no-reply"]["drop"], ["2"])
self.assertTrue(plan["contact"]["renew"])
self.assertEqual(plan["contact"]["owned"], ["1", "4"])
self.assertEqual(plan["contact"]["drop"], ["1", "4"])
def test_a_stored_derived_secret_renews(self):
bots = _bots()
bots[0]["stored"] = bots[0]["secret"]
plan = _fact(
"04_manage_user/app_password.yml",
"🔍 Decide which bots need a new app password",
"stalwart_app_password_plan",
**BASE,
stalwart_bots=bots,
stalwart_jmap={
"methodResponses": [
["x:AppPassword/get", {"list": entries}, address]
for address, entries in LISTED.items()
]
},
)
self.assertTrue(plan[0]["renew"])
def test_mint_retire_and_drop_touch_only_the_renewed_bot(self):
plan = _plan(LISTED)
variables = {**BASE, "stalwart_app_password_plan": plan}
mint = _body(
"04_manage_user/app_password.yml",
"🔑 Mint the new app passwords",
**variables,
)
self.assertEqual(
mint["methodCalls"],
[
[
"x:AppPassword/set",
{"accountId": "c", "create": {"p": {"description": DESCRIPTION}}},
"contact@infinito.test",
]
],
)
minted = _fact(
"04_manage_user/app_password.yml",
"🔍 Read the minted app passwords",
"stalwart_app_password_minted",
**variables,
stalwart_jmap={
"methodResponses": [
[
"x:AppPassword/set",
{"created": {"p": {"id": "6", "secret": "fresh"}}},
"contact@infinito.test",
]
]
},
)
self.assertEqual(
minted,
[{"key": "contact", "address": "contact@infinito.test", "secret": "fresh"}],
)
retire = _body(
"04_manage_user/app_password.yml",
"🏷️ Retire the app passwords the new ones replace",
**variables,
)
self.assertEqual(
retire["methodCalls"],
[
[
"x:AppPassword/set",
{
"accountId": "c",
"update": {
"1": {"description": RETIRED},
"4": {"description": RETIRED},
},
},
"contact@infinito.test",
]
],
)
stale = _fact(
"04_manage_user/app_password.yml",
"📥 Record the retired app passwords per bot",
"stalwart_app_password_stale",
**variables,
)
self.assertEqual(
stale,
{
"no-reply@infinito.test": {"account": "b", "ids": ["2"]},
"contact@infinito.test": {"account": "c", "ids": ["1", "4"]},
},
)
drop_task = _task(
"04_manage_user/app_password_drop.yml",
"🗑️ Destroy the retired app passwords of the bots",
)
chunks = _render(
drop_task["loop"],
stalwart_app_password_stale=stale,
STALWART_JMAP_MAX_METHOD_CALLS=16,
)
self.assertEqual(len(chunks), 1)
drop = json.loads(
_render(
drop_task["vars"]["stalwart_jmap_body"],
stalwart_app_password_owners=chunks[0],
)
)
self.assertEqual(
drop["methodCalls"],
[
[
"x:AppPassword/set",
{"accountId": "b", "destroy": ["2"]},
"no-reply@infinito.test",
],
[
"x:AppPassword/set",
{"accountId": "c", "destroy": ["1", "4"]},
"contact@infinito.test",
],
],
)
def test_the_record_keeps_the_owners_of_an_earlier_chunk(self):
stale = _fact(
"04_manage_user/app_password.yml",
"📥 Record the retired app passwords per bot",
"stalwart_app_password_stale",
**{
**BASE,
"stalwart_app_password_stale": {
"bounce@infinito.test": {"account": "z", "ids": ["9"]}
},
},
stalwart_app_password_plan=_plan(LISTED),
)
self.assertEqual(
sorted(stale),
["bounce@infinito.test", "contact@infinito.test", "no-reply@infinito.test"],
)
if __name__ == "__main__":
unittest.main()
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import unittest
from typing import ClassVar
from ansible.parsing.dataloader import DataLoader
from ansible.template import Templar
@@ -45,14 +46,36 @@ def _render(template: str, **variables):
return templar.template(_trust_as_template(template))
def _accepted(response: list, **extra) -> bool:
def _assert_vars(responses: list, **extra) -> dict:
task = _assert_task()
variables = {"stalwart_jmap": {"methodResponses": [response]}, **extra}
variables = {"stalwart_jmap": {"methodResponses": responses}, **extra}
for name, raw in (task.get("vars") or {}).items():
variables[name] = _render(raw, **variables)
return variables
def _batch_accepted(responses: list, **extra) -> bool:
variables = _assert_vars(responses, **extra)
that = _module(_assert_task(), "assert")["that"]
return all(
_render("{{ (" + expr + ") | bool }}", **variables)
for expr in _module(task, "assert")["that"]
for expr in ([that] if isinstance(that, str) else that)
)
def _accepted(response: list, **extra) -> bool:
return _batch_accepted([response], **extra)
def _retry_done(rc: int, responses: list) -> bool:
task = next(t for t in _tasks() if _module(t, "shell"))
return _render(
"{{ (" + task["until"] + ") | bool }}",
stalwart_jmap_raw={
"rc": rc,
"stdout": json.dumps({"methodResponses": responses}),
},
STALWART_JMAP_TRANSIENT_ERRORS=["serverUnavailable", "rateLimit"],
)
@@ -105,6 +128,85 @@ class TestJmapRejectionIsAFailure(unittest.TestCase):
)
class TestJmapBatchNamesTheRefusedOwner(unittest.TestCase):
GET: ClassVar[list] = ["x:AppPassword/get", {"list": []}, "contact@infinito.test"]
def test_a_clean_batch_passes(self):
self.assertTrue(
_batch_accepted(
[self.GET, ["x:AppPassword/get", {"list": []}, "bounce@infinito.test"]]
)
)
def test_an_error_in_a_later_call_fails(self):
self.assertFalse(
_batch_accepted(
[self.GET, ["error", {"type": "forbidden"}, "bounce@infinito.test"]]
)
)
def test_a_later_rejection_fails_and_names_its_call(self):
responses = [
self.GET,
[
"x:AppPassword/set",
{"notCreated": {"p": {"type": "overQuota"}}},
"bounce@infinito.test",
],
]
self.assertFalse(_batch_accepted(responses))
self.assertEqual(
_assert_vars(responses)["stalwart_jmap_refused"],
{
"bounce@infinito.test": {
"x:AppPassword/set": {"p": {"type": "overQuota"}}
}
},
)
def test_a_tolerated_rejection_still_fails_on_an_error(self):
self.assertFalse(
_batch_accepted(
[
["x:Account/set", {"notCreated": {"u0": {"type": "x"}}}, "c0"],
["error", {"type": "invalidArguments"}, "c1"],
],
stalwart_jmap_tolerate_rejected=True,
)
)
def test_a_destroy_of_an_absent_object_passes(self):
self.assertTrue(
_batch_accepted(
[
[
"x:AppPassword/set",
{"notDestroyed": {"3": {"type": "notFound"}}},
"bounce@infinito.test",
]
]
)
)
class TestJmapRetryCoversEveryCall(unittest.TestCase):
OK: ClassVar[list] = ["x:AppPassword/get", {"list": []}, "a"]
def test_a_clean_batch_ends_the_retry(self):
self.assertTrue(_retry_done(0, [self.OK, self.OK]))
def test_a_transient_error_in_a_later_call_retries(self):
self.assertFalse(
_retry_done(0, [self.OK, ["error", {"type": "serverUnavailable"}, "b"]])
)
def test_a_permanent_error_ends_the_retry(self):
self.assertTrue(_retry_done(0, [["error", {"type": "forbidden"}, "a"]]))
def test_a_failed_transfer_retries(self):
self.assertFalse(_retry_done(22, []))
class TestJmapSecretsStayOffArgv(unittest.TestCase):
def setUp(self):
self.task = _call_task()