feat(web-app-stalwart): let the platform administrator administer Stalwart over SSO

OIDC principals received only the built-in User role, so the platform
administrator reached the web administration and was bounced to
self-service half a second later. Three design specs asserted on a table
that is not hers; in run 38482429514 they failed on clearnet exactly as on
tor, which is what ruled out the onion CDN as the cause.

The Keycloak client already carries the groups scope and the RBAC mapper
already sends full paths, so the token held the groups all along. The
directory simply never read them: claimGroups was unset. Setting it, and
giving one group account the System Administrator role, grants through
membership instead of through a default. defaultUserRoleIds and
defaultGroupRoleIds stay untouched, so no other principal inherits
anything.

The group is named as a local part because a Stalwart account is
addressed as name@domain; the Keycloak path lives in externalId. Creating
it follows 04_manage_user.yml: create, read the id back out of notCreated
on conflict, then update.

Verified on a local compose deploy of variant 0: the group was created,
and the suite went from three failing design specs to one failing and one
flaky. The remaining one is a stylesheet gap, not a permission: style.css
hangs its table rules on body[class*=task-] .table while WebUI v1.0.11
renders a bare table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-14 13:04:55 +02:00
1 parent 2605de7d90
commit 199f2a6d41
3 files changed
+48

No files matched your search

+42
View File
@@ -35,6 +35,12 @@
stalwart_jmap_body: "{{ lookup('template', 'jmap/directory/clear_audience.json.j2') }}"
when: stalwart_oidc_dir_id | length > 0
- name: "👥 Let the OIDC directory read the RBAC groups the token carries"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/directory/set_claim_groups.json.j2') }}"
when: stalwart_oidc_dir_id | length > 0
- name: "📖 List Stalwart built-in roles"
include_tasks: jmap_call.yml
vars:
@@ -66,6 +72,42 @@
defaultAdminRoleIds: "{{ {stalwart_role_admin: true} | combine({stalwart_role_user: true}) if stalwart_role_admin | length > 0 else {stalwart_role_user: true}
}}"
- name: "📁 Create the group that carries System Administrator to its OIDC members"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_tolerate_rejected: true
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:Account/set', {'create': {'g': {
'@type': 'Group',
'name': STALWART_ADMIN_GROUP_NAME,
'domainId': stalwart_domain_id,
'description': 'Keycloak ' ~ STALWART_ADMIN_GROUP_PATH,
'externalId': STALWART_ADMIN_GROUP_PATH,
'roles': {'@type': 'Custom', 'roleIds': {stalwart_role_admin: true}}
}}}, 'c0']]} | to_json }}
when: stalwart_role_admin | length > 0
- name: "🔍 Resolve the group id when it already existed"
ansible.builtin.set_fact:
stalwart_admin_group_id: >-
{{ ((stalwart_jmap.methodResponses[0][1].notCreated | default({}))
.get('g', {}).get('objectId', {}) or {}).get('id', '') }}
when: stalwart_role_admin | length > 0
- name: "🔄 Keep the existing group's external id and role in step"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:Account/set', {'update': {stalwart_admin_group_id: {
'externalId': STALWART_ADMIN_GROUP_PATH,
'roles': {'@type': 'Custom', 'roleIds': {stalwart_role_admin: true}}
}}}, 'c0']]} | to_json }}
when:
- stalwart_role_admin | length > 0
- stalwart_admin_group_id | length > 0
- name: "📖 Read the Stalwart authentication singleton"
include_tasks: jmap_call.yml
vars:
@@ -0,0 +1,4 @@
{
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
"methodCalls": [["x:Directory/set", {"update": { {{ stalwart_oidc_dir_id | to_json }}: {"claimGroups": {{ RBAC.GROUP.CLAIM | to_json }}} }}, "c0"]]
}
+2
View File
@@ -60,6 +60,8 @@ STALWART_IP4_PUBLIC: "{{ DOCKER_PUBLIC_BIND_HOST }}"
STALWART_ACTIVE_PROVIDER: "{{ lookup('mail_provider') == application_id }}"
STALWART_ADMIN_USER: "{{ lookup('users', 'stalwartadmin').username }}"
STALWART_ADMIN_GROUP_NAME: administrators
STALWART_ADMIN_GROUP_PATH: "/{{ lookup('rbac_group_path', application_id='web-app-stalwart', role='administrator') }}"
STALWART_ADMIN_PASSWORD: "{{ lookup('users', 'stalwartadmin').password }}"
STALWART_USERS: "{{ lookup('stalwart_users') }}"