Files
computer-playbook/roles/web-app-keycloak/tasks/update/_update.yml
Kevin Veen-Birkenbach 26dfab147d Implement reserved username handling for users, LDAP and Keycloak
Add end-to-end support for reserved usernames and tighten CAPTCHA / Keycloak logic.

Changes:

- Makefile: rename EXTRA_USERS → RESERVED_USERNAMES and pass it as --reserved-usernames to the users defaults generator.

- cli/build/defaults/users.py: propagate  flag into generated users, add --reserved-usernames CLI option and mark listed accounts as reserved.

- Add reserved_users filter plugin with  and  helpers for Ansible templates and tasks.

- Add unit tests for reserved_users filters and the new reserved-usernames behaviour in the users defaults generator.

- group_vars/all/00_general.yml: harden RECAPTCHA_ENABLED / HCAPTCHA_ENABLED checks with default('') and explicit > 0 length checks.

- svc-db-openldap: introduce OPENLDAP_PROVISION_* flags, add OPENLDAP_PROVISION_RESERVED and OPERNLDAP_USERS to optionally exclude reserved users from provisioning.

- svc-db-openldap templates/tasks: switch role/group LDIF and user import loops to use OPERNLDAP_USERS instead of the full users dict.

- networks: assign dedicated subnet for web-app-roulette-wheel.

- web-app-keycloak vars: compute KEYCLOAK_RESERVED_USERNAMES_LIST and KEYCLOAK_RESERVED_USERNAMES_REGEX from users | reserved_usernames.

- web-app-keycloak user profile template: inject reserved-username regex into username validation pattern and improve error message, fix SSH public key attribute usage and add component name field.

- web-app-keycloak update/_update.yml: strip subComponents from component payloads before update and disable async/poll for easier debugging.

- web-app-keycloak tasks/main.yml: guard cleanup include with MODE_CLEANUP and keep reCAPTCHA update behind KEYCLOAK_RECAPTCHA_ENABLED.

- user/users defaults: mark system/service accounts (root, daemon, mail, admin, webmaster, etc.) as reserved so they cannot be chosen as login names.

- svc-prx-openresty vars: simplify OPENRESTY_CONTAINER lookup by dropping unused default parameter.

- sys-ctl-rpr-btrfs-balancer: simplify main.yml by removing the extra block wrapper.

- sys-daemon handlers: quote handler name for consistency.

Context: change set discussed and refined in ChatGPT on 2025-11-29 (Infinito.Nexus reserved usernames & Keycloak user profile flow). See conversation: https://chatgpt.com/share/692b21f5-5d98-800f-8e15-1ded49deddc9
2025-11-29 17:40:45 +01:00

199 lines
6.7 KiB
YAML

# Generic updater for Keycloak client/component/realm via kcadm.
# Flow: resolve ID → read current object → merge with desired → preserve immutable fields → update via stdin.
#
# Required vars (pass via include):
# - kc_object_kind: "client" | "component" | "client-scope" | "realm"
# - kc_lookup_value: e.g., KEYCLOAK_CLIENT_ID or KEYCLOAK_LDAP_CMP_NAME or KEYCLOAK_REALM
# - kc_desired: dict, e.g., KEYCLOAK_DICTIONARY_CLIENT or KEYCLOAK_DICTIONARY_LDAP
#
# Optional:
# - kc_lookup_field: override lookup field (defaults: clientId for client, name for component, id for realm)
# - kc_merge_path: if set (e.g. "config"), only that subkey is merged
# - kc_force_attrs: dict to force on the final payload (merged last)
- name: Assert required vars
assert:
that:
- kc_object_kind in ['client','component','client-scope','realm']
- kc_lookup_value is defined
- kc_desired is defined
fail_msg: "kc_object_kind, kc_lookup_value, kc_desired are required."
when: MODE_ASSERT | bool
- name: Derive API endpoint and lookup field
set_fact:
kc_api: >-
{{ 'clients' if kc_object_kind == 'client'
else 'components' if kc_object_kind == 'component'
else 'client-scopes' if kc_object_kind == 'client-scope'
else 'realms' if kc_object_kind == 'realm'
else '' }}
kc_lookup_field_eff: >-
{{ 'clientId' if kc_object_kind == 'client'
else (kc_lookup_field | default('name')) if kc_object_kind == 'component'
else 'name' if kc_object_kind == 'client-scope'
else 'id' if kc_object_kind == 'realm'
else '' }}
- name: Resolve object id (direct when lookup_field is id)
when: kc_lookup_field_eff == 'id'
set_fact:
kc_obj_id: "{{ kc_lookup_value | string }}"
- name: Resolve object id via query
when: kc_lookup_field_eff != 'id' and kc_object_kind != 'realm'
shell: >
{% if kc_object_kind == 'client-scope' -%}
{{ KEYCLOAK_EXEC_KCADM }} get client-scopes -r {{ KEYCLOAK_REALM }} --format json
| jq -r '.[] | select(.{{ kc_lookup_field_eff }}=="{{ kc_lookup_value }}") | .id' | head -n1
{%- else -%}
{{ KEYCLOAK_EXEC_KCADM }} get {{ kc_api }}
-r {{ KEYCLOAK_REALM }}
--query '{{ kc_lookup_field_eff }}={{ kc_lookup_value }}'
--fields id --format json | jq -r '.[0].id'
{%- endif %}
register: kc_obj_id_cmd
changed_when: false
- name: Normalize resolved id to a plain string
set_fact:
kc_obj_id: >-
{{
kc_obj_id
if kc_lookup_field_eff == 'id'
else (kc_obj_id_cmd.stdout | default('') | trim)
}}
- name: Fail if object not found
assert:
that:
- (kc_obj_id | trim) != ''
- (kc_obj_id | trim) != 'null'
fail_msg: "{{ kc_object_kind | capitalize }} '{{ kc_lookup_value }}' not found."
when: MODE_ASSERT | bool
- name: Read current object
shell: >
{% if kc_object_kind == 'realm' -%}
{{ KEYCLOAK_EXEC_KCADM }} get {{ kc_api }}/{{ kc_obj_id }} --format json
{%- else -%}
{{ KEYCLOAK_EXEC_KCADM }} get {{ kc_api }}/{{ kc_obj_id }} -r {{ KEYCLOAK_REALM }} --format json
{%- endif %}
register: kc_cur
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Parse current object
set_fact:
cur_obj: "{{ kc_cur.stdout | from_json }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "Safety check: providerId must match when updating a component"
when:
- kc_object_kind == 'component'
- (kc_desired.providerId is defined)
- MODE_ASSERT | bool
assert:
that:
- cur_obj.providerId == kc_desired.providerId
fail_msg: >
Refusing to update component '{{ cur_obj.name }}' (providerId={{ cur_obj.providerId }})
because desired providerId={{ kc_desired.providerId }}. Check your lookup/ID.
- name: Prepare merge payload (subpath)
when: kc_merge_path is defined and (kc_merge_path | length) > 0
set_fact:
merge_payload: "{{ { (kc_merge_path): (kc_desired[kc_merge_path] | default({}, true)) } }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Prepare merge payload (full object)
when: kc_merge_path is not defined or (kc_merge_path | length) == 0
set_fact:
merge_payload: "{{ kc_desired }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Build desired object (base merge)
set_fact:
desired_obj: "{{ cur_obj | combine(merge_payload, recursive=True) }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
# Preserve immutable fields
- name: Preserve immutable fields for client
when: kc_object_kind == 'client'
set_fact:
desired_obj: >-
{{
desired_obj
| combine({
'id': cur_obj.id,
'clientId': cur_obj.clientId
}, recursive=True)
}}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Preserve immutable fields for component
when: kc_object_kind == 'component'
set_fact:
desired_obj: >-
{{
desired_obj
| combine({
'id': cur_obj.id,
'providerId': cur_obj.providerId,
'providerType': cur_obj.providerType,
'parentId': cur_obj.parentId
}, recursive=True)
}}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Drop unsupported fields for components (e.g. subComponents)
when: kc_object_kind == 'component'
set_fact:
desired_obj: >-
{{
desired_obj
| dict2items
| rejectattr('key', 'equalto', 'subComponents')
| list
| items2dict
}}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Preserve immutable fields for client-scope
when: kc_object_kind == 'client-scope'
set_fact:
desired_obj: "{{ desired_obj | combine({'id': cur_obj.id, 'name': cur_obj.name}, recursive=True) }}"
- name: Preserve immutable fields for realm
when: kc_object_kind == 'realm'
set_fact:
desired_obj: >-
{{
desired_obj
| combine({
'id': cur_obj.id,
'realm': cur_obj.realm
}, recursive=True)
}}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
# Optional forced attributes (e.g., frontchannelLogout)
- name: Apply forced attributes (optional)
when: kc_force_attrs is defined
set_fact:
desired_obj: "{{ desired_obj | combine(kc_force_attrs, recursive=True) }}"
- name: Update object via stdin
shell: |
{% if kc_object_kind == 'realm' -%}
cat <<'JSON' | {{ KEYCLOAK_EXEC_KCADM }} update {{ kc_api }}/{{ kc_obj_id }} -f -
{{ desired_obj | to_json }}
JSON
{%- else -%}
cat <<'JSON' | {{ KEYCLOAK_EXEC_KCADM }} update {{ kc_api }}/{{ kc_obj_id }} -r {{ KEYCLOAK_REALM }} -f -
{{ desired_obj | to_json }}
JSON
{%- endif %}
#async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
#poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"