Files
computer-playbook/roles/web-app-keycloak/vars/main.yml
kevinveenbirkenbachandClaude Opus 5.5 b358a8bb00 fix(web-app-keycloak): append the admin allowlist only when networks are declared
tasks/00_core.yml built webserver_extra_configuration by concatenating the
helper iframes, the admin allowlist and the logout panel template lookups
with `~`. With the shipped empty `services.keycloak.admin.allowed_networks`
the allowlist template rendered no text, Ansible's template lookup returned
None for it, and `~` wrote the word "None" into the auth vhost. OpenResty
then refused `unknown directive "Nonelocation"` in front of the logout
panel location and every deploy with Keycloak went red.

The role now resolves the declared networks once in KEYCLOAK_ADMIN_NETWORKS
and the task appends the allowlist template only when that list is not
empty, the same guard the logout panel uses. The template renders the
restricted /admin location unconditionally from KEYCLOAK_ADMIN_NETWORKS.

With a list that did not contain the deploy host, the deploy still went
red: Keycloak answers `/` with a redirect to `/admin/`, so the CSP health
check and the front proxy probe, which request `/` from the host, got 403
and hlth-csp failed. While the allowlist is active, `location = /` now
redirects to the account console of the platform realm instead, and the
README no longer claims the deploy needs no entry for any reason.

test_admin_allowlist.py rendered the template with plain Jinja, which
yields '' for an empty template, so it could not see the None. It now
evaluates the task's own webserver_extra_configuration expression through
Ansible's Templar and real template lookup, which reproduces
"HELPER_IFRAMES\nNoneLOGOUT_PANEL" on the old task, and covers the root
redirect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:57:11 +01:00

145 lines
8.0 KiB
YAML

---
application_id: "web-app-keycloak"
entity_name: "{{ application_id | get_entity_name }}"
domain: "{{ lookup('domain',application_id) }}"
KEYCLOAK_CONTAINER_HOSTNAME: "{{ entity_name }}"
KEYCLOAK_DOMAIN: "{{ domain }}"
KEYCLOAK_REALM: "{{ OIDC.CLIENT.REALM }}"
KEYCLOAK_URL: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}"
KEYCLOAK_REALM_URL: "{{ KEYCLOAK_URL }}/realms/{{ KEYCLOAK_REALM }}"
KEYCLOAK_DEBUG_ENABLED: "{{ MODE_DEBUG }}"
KEYCLOAK_CLIENT_ID: "{{ OIDC.CLIENT.ID }}"
KEYCLOAK_SERVER_INTERNAL_URL: "http://127.0.0.1:8080"
KEYCLOAK_RBAC_GROUP_CLAIM: "{{ RBAC.GROUP.CLAIM }}"
KEYCLOAK_RBAC_GROUP_NAME: "{{ RBAC.GROUP.NAME }}"
KEYCLOAK_RBAC_ALL_APP_IDS: "{{ lookup('deployment').groups | rbac_app_ids }}"
KEYCLOAK_RBAC_RUN_APP_IDS: "{{ lookup('deployment').running | rbac_app_ids }}"
KEYCLOAK_PER_APP_LDAP_ROLES_MAPPER: >-
{{
((KEYCLOAK_DICTIONARY_REALM.components['org.keycloak.storage.UserStorageProvider']
| selectattr('name','equalto', KEYCLOAK_LDAP_CMP_NAME)
| list | first
).subComponents['org.keycloak.storage.ldap.mappers.LDAPStorageMapper']
| selectattr('name','equalto','ldap-roles')
| list | first
| dict2items
| rejectattr('key','in',['subComponents','id'])
| list | items2dict)
| combine({
'name': kc_app_id | kc_per_app_mapper_name,
'parentId': ldap_cmp_id | trim,
'providerId': 'group-ldap-mapper',
'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper',
'config': {
'preserve.group.inheritance': ['false'],
'group.name.ldap.attribute': ['description'],
'groups.ldap.filter': [kc_app_id | kc_per_app_ldap_filter],
'groups.path': ['/' ~ (KEYCLOAK_RBAC_GROUP_NAME | trim('/')) ~ '/' ~ kc_app_id],
'groups.path.group': [kc_app_group_id],
'drop.non.existing.groups.during.sync': ['true'],
}
}, recursive=True)
}}
KEYCLOAK_HEALTH_ENABLED: true
KEYCLOAK_REALM_IMPORT_ENABLED: "{{ lookup('config', application_id, 'services.keycloak.realm.import_enabled') }}"
KEYCLOAK_REALM_IMPORT_DIR_HOST: "{{ [lookup('container', application_id, 'directories.volumes'),'import'] | path_join }}"
KEYCLOAK_REALM_IMPORT_DIR_DOCKER: "{{ lookup('volume', 'web-app-keycloak', 'realm_import').target }}"
KEYCLOAK_REALM_IMPORT_FILE_SRC: "import/realm.json.j2"
KEYCLOAK_REALM_IMPORT_FILENAME: "realm.json"
KEYCLOAK_REALM_IMPORT_FILE_DST: "{{ [KEYCLOAK_REALM_IMPORT_DIR_HOST,KEYCLOAK_REALM_IMPORT_FILENAME] | path_join }}"
KEYCLOAK_REALM_IMPORT_FILE_DOCKER: "{{ [KEYCLOAK_REALM_IMPORT_DIR_DOCKER,KEYCLOAK_REALM_IMPORT_FILENAME] | path_join }}"
KEYCLOAK_REALM_TOTP_ENABLED: "{{ lookup('config', application_id, 'services.keycloak.realm.totp_enabled') | bool }}"
KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME: "{{ lookup('config', application_id, 'services.keycloak.accounts.bootstrap.username') }}"
KEYCLOAK_BOOTSTRAP_ADMIN_PASSWORD: "{{ lookup('config', application_id, 'secrets.credentials.administrator_password') }}"
KEYCLOAK_PERMANENT_ADMIN_USERNAME: "{{ lookup('config', application_id, 'services.keycloak.accounts.system.username') }}"
KEYCLOAK_PERMANENT_ADMIN_PASSWORD: "{{ lookup('config', application_id, 'secrets.credentials.administrator_password') }}"
KEYCLOAK_CONTAINER: "{{ lookup('config', application_id, 'services.keycloak.name') }}"
KEYCLOAK_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, entity_name) }}"
# Exception: KEYCLOAK_LOCAL_CID is set by utils/login.yml after resolving the swarm-hosting
# node; fall back to the compose-mode container_address when the fact is not yet set
# so vars resolve cleanly before the manager-side ops kick in.
KEYCLOAK_TARGET_CONTAINER: "{{ KEYCLOAK_LOCAL_CID | default(KEYCLOAK_CONTAINER_ADDRESS) }}" # nocheck: container-var-name-lookup references the consumer's container, not this role's service
KEYCLOAK_EXEC_CONTAINER: "container exec -i {{ KEYCLOAK_TARGET_CONTAINER }}" # nocheck: container-exec-resolver # nocheck: container-var-name-lookup references the consumer's container, not this role's service
KEYCLOAK_KCADM: "/opt/keycloak/bin/kcadm.sh"
KEYCLOAK_EXEC_KCADM: "{{ KEYCLOAK_EXEC_CONTAINER }} {{ KEYCLOAK_KCADM }}"
KEYCLOAK_CID_RESOLVE_CMD: >-
container ps
--filter "label=com.docker.swarm.service.name={{ lookup('container_service', application_id, entity_name) }}"
--filter status=running
--format '{{ "{{.ID}}" }}' | head -1
KEYCLOAK_IMAGE: "{{ lookup('config', application_id, 'services.keycloak.image') }}"
KEYCLOAK_VERSION: "{{ lookup('config', application_id, 'services.keycloak.version') }}"
KEYCLOAK_REPLICAS: "{{ groups[application_id] | length }}"
KEYCLOAK_REPLICAS_WANT: "{{ KEYCLOAK_REPLICAS }}/{{ KEYCLOAK_REPLICAS }}"
KEYCLOAK_DB_NETWORK_ARG: "{% if lookup('database', application_id, 'shared') | bool %}--network {{ lookup('database', application_id, 'network') | quote }}{% endif
%}"
KEYCLOAK_FRONTCHANNEL_LOGOUT_URL: "{{ lookup('tls', 'web-svc-logout', 'url.base') }}"
KEYCLOAK_LOGOUT_PANEL_FILE: "{{ lookup('nginx', 'directories.configuration.lua') }}{{ application_id }}-logout-panel.js"
KEYCLOAK_LOGOUT_PANEL_ENABLED: "{{ lookup('config', application_id, 'services.javascript.enabled') | bool }}"
KEYCLOAK_ADMIN_NETWORKS: "{{ lookup('config', application_id, 'services.keycloak.admin.allowed_networks') | kc_admin_networks }}"
KEYCLOAK_REDIRECT_URIS: "{{ lookup('redirect_uris') }}"
KEYCLOAK_SAML_APPS: >-
{{ lookup('applications') | dict2items
| selectattr('value.services', 'defined')
| selectattr('value.services.sso', 'defined')
| selectattr('value.services.sso.flavor', 'defined')
| selectattr('value.services.sso.flavor', 'equalto', 'saml')
| map(attribute='key')
| select('in', group_names)
| list }}
KEYCLOAK_WEB_ORIGINS: >-
{{ KEYCLOAK_REDIRECT_URIS
| map('regex_replace','/\\*$','')
| map('regex_search','^(https?://[^/]+)')
| select('string')
| list | unique }}
KEYCLOAK_POST_LOGOUT_URIS: "+"
KEYCLOAK_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') }}"
KEYCLOAK_LDAP_SHARED: "{{ lookup('config', application_id, 'services.ldap.shared') }}"
KEYCLOAK_LDAP_CMP_NAME: "{{ LDAP.SERVER.DOMAIN }}" # Name of the LDAP User Federation component in Keycloak (as shown in UI)
KEYCLOAK_LDAP_BIND_DN: "{{ LDAP.DN.ADMINISTRATOR.DATA }}"
KEYCLOAK_LDAP_BIND_PW: "{{ LDAP.BIND_CREDENTIAL }}"
KEYCLOAK_LDAP_URL: "{{ LDAP.SERVER.URI }}"
KEYCLOAK_LDAP_CONNECTION_POOLING: "{{ lookup('config', application_id, 'services.ldap.connection_pooling') | bool }}"
KEYCLOAK_LDAP_SYNC_CHANGES_PERIOD: "{{ lookup('config', application_id, 'services.ldap.sync.period.changes') }}"
KEYCLOAK_LDAP_SYNC_FULL_PERIOD: "{{ lookup('config', application_id, 'services.ldap.sync.period.full') }}"
KEYCLOAK_LDAP_USER_OBJECT_CLASSES: >
{{
(
(LDAP.USER.OBJECTS.STRUCTURAL | reject('equalto','posixAccount') | list)
+ (LDAP.USER.OBJECTS.AUXILIARY | dict2items | map(attribute='value') | list)
) | join(', ')
}}
KEYCLOAK_USER_EMAILS_VERIFIED: "{{ lookup('config', application_id, 'services.keycloak.users.emails_verified') }}"
KEYCLOAK_MOODLE_ENABLED: "{{ 'web-app-moodle' in group_names }}"
KEYCLOAK_DECLARED_CLIENT_APPS: "{{ lookup('applications') | kc_declared_client_apps(lookup('deployment').groups) }}"
KEYCLOAK_DECLARED_CLIENT_IDS: "{{ lookup('applications') | kc_declared_client_ids(lookup('deployment').groups) }}"
KEYCLOAK_RECAPTCHA_ENABLED: "{{ lookup('config', application_id, 'services.recaptcha.enabled') }}"
KEYCLOAK_RECAPTCHA_KEY: "{{ lookup('config', application_id, 'secrets.credentials.recaptcha.key') }}"
KEYCLOAK_RECAPTCHA_SECRET: "{{ lookup('config', application_id, 'secrets.credentials.recaptcha.secret') }}"
KEYCLOAK_COMMAND_PERMANENT_ADMIN_LOGIN: |-
{{ KEYCLOAK_EXEC_CONTAINER }} sh -lc '
set -e
{{ KEYCLOAK_KCADM }} config credentials \
--server {{ KEYCLOAK_SERVER_INTERNAL_URL }} \
--realm master \
--user "$KEYCLOAK_PERMANENT_ADMIN_USERNAME" \
--password "$KEYCLOAK_PERMANENT_ADMIN_PASSWORD"
'