Finished implementation of oauth2 import

This commit is contained in:
2025-08-17 21:00:45 +02:00
parent 7d0502ebc5
commit 5f0762e4f6
20 changed files with 620 additions and 190 deletions

View File

@@ -0,0 +1,7 @@
- ansible.builtin.include_vars:
file: "{{ playbook_dir }}/roles/docker-compose/vars/docker-compose.yml"
- name: "remove directory {{ KEYCLOAK_REALM_IMPORT_DIR_HOST }}"
ansible.builtin.file:
path: "{{ KEYCLOAK_REALM_IMPORT_DIR_HOST }}"
state: absent

View File

@@ -1,15 +0,0 @@
- name: "load variables from {{ DOCKER_VARS_FILE }}"
include_vars: "{{ DOCKER_VARS_FILE }}"
- name: "create directory {{ KEYCLOAK_HOST_IMPORT_DIR }}"
file:
path: "{{ KEYCLOAK_HOST_IMPORT_DIR }}"
state: directory
mode: "0755"
- name: "Copy import files to {{ KEYCLOAK_HOST_IMPORT_DIR }}"
template:
src: "{{ item }}"
dest: "{{ KEYCLOAK_HOST_IMPORT_DIR }}/{{ item | basename | regex_replace('\\.j2$', '') }}"
mode: "0770"
loop: "{{ lookup('fileglob', role_path ~ '/templates/import/*.j2', wantlist=True) }}"

View File

@@ -0,0 +1,15 @@
- name: "load variables from {{ DOCKER_VARS_FILE }}"
include_vars: "{{ DOCKER_VARS_FILE }}"
- name: "create directory {{ KEYCLOAK_REALM_IMPORT_DIR_HOST }}"
file:
path: "{{ KEYCLOAK_REALM_IMPORT_DIR_HOST }}"
state: directory
mode: "0755"
- name: "Copy REALM import file '{{ KEYCLOAK_REALM_IMPORT_FILE_SRC }}' to '{{ KEYCLOAK_REALM_IMPORT_FILE_DST }}'"
template:
src: "{{ KEYCLOAK_REALM_IMPORT_FILE_SRC }}"
dest: "{{ KEYCLOAK_REALM_IMPORT_FILE_DST }}"
mode: "0770"
when: KEYCLOAK_REALM_IMPORT_ENABLED | bool

View File

@@ -0,0 +1,25 @@
- name: Load the '{{ application_id }}' role dependencies
block:
- name: "load required 'web-svc-logout' for {{ application_id }}"
include_role:
name: web-svc-logout
when: run_once_web_svc_logout is not defined
- name: "load docker, db and proxy for {{ application_id }}"
include_role:
name: cmp-db-docker-proxy
vars:
docker_compose_flush_handlers: true
when: KEYCLOAK_LOAD_DEPENDENCIES | bool
- name: "Load database & docker-compose variables if role dependencies aren't loaded"
block:
- include_tasks: "{{ playbook_dir }}/tasks/utils/load_handlers.yml"
vars:
handler_role_name: "docker-compose"
- ansible.builtin.include_vars:
file: "{{ item }}"
loop:
- "{{ playbook_dir }}/roles/docker-compose/vars/docker-compose.yml"
- "{{ playbook_dir }}/roles/cmp-rdbms/vars/database.yml"
when: not KEYCLOAK_LOAD_DEPENDENCIES | bool

View File

@@ -14,58 +14,100 @@
- name: Assert required vars
assert:
that:
- kc_object_kind in ['client','component']
- kc_object_kind in ['client','component','client-scope']
- kc_lookup_value is defined
- kc_desired is defined
fail_msg: "kc_object_kind, kc_lookup_value, kc_desired are required."
- name: Derive API endpoint and lookup field
set_fact:
kc_api: "{{ 'clients' if kc_object_kind == 'client' else 'components' }}"
kc_lookup_field_eff: "{{ 'clientId' if kc_object_kind == 'client' else (kc_lookup_field | default('name')) }}"
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 '' }}
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 '' }}
- name: Resolve object id
- 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'
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'
register: kc_obj_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.stdout | trim) != ''
- (kc_obj_id.stdout | trim) != 'null'
- (kc_obj_id | trim) != ''
- (kc_obj_id | trim) != 'null'
fail_msg: "{{ kc_object_kind | capitalize }} '{{ kc_lookup_value }}' not found."
- name: Read current object
shell: >
{{ KEYCLOAK_EXEC_KCADM }} get {{ kc_api }}/{{ kc_obj_id.stdout }}
{{ KEYCLOAK_EXEC_KCADM }} get {{ kc_api }}/{{ kc_obj_id }}
-r {{ KEYCLOAK_REALM }} --format json
register: kc_cur
changed_when: false
# ── Build merge payload safely (avoid evaluating kc_desired[kc_merge_path] when undefined) ─────────
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)
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
@@ -79,6 +121,7 @@
'clientId': cur_obj.clientId
}, recursive=True)
}}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Preserve immutable fields for component
when: kc_object_kind == 'component'
@@ -93,6 +136,14 @@
'parentId': cur_obj.parentId
}, recursive=True)
}}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
# Preserve immutables for client-scope
- 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) }}"
# Optional forced attributes (e.g., frontchannelLogout)
- name: Apply forced attributes (optional)
@@ -102,6 +153,8 @@
- name: Update object via stdin
shell: |
cat <<'JSON' | {{ KEYCLOAK_EXEC_KCADM }} update {{ kc_api }}/{{ kc_obj_id.stdout }} -r {{ KEYCLOAK_REALM }} -f -
cat <<'JSON' | {{ KEYCLOAK_EXEC_KCADM }} update {{ kc_api }}/{{ kc_obj_id }} -r {{ KEYCLOAK_REALM }} -f -
{{ desired_obj | to_json }}
JSON
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"

View File

@@ -0,0 +1,72 @@
# --- Ensure RBAC client scope exists (idempotent) ---
- name: Ensure RBAC client scope exists
shell: |
cat <<'JSON' | {{ KEYCLOAK_EXEC_KCADM }} create client-scopes -r {{ KEYCLOAK_REALM }} -f -
{{
(
KEYCLOAK_DICTIONARY_REALM.clientScopes
| selectattr('name','equalto', KEYCLOAK_OIDC_RBAC_SCOPE_NAME)
| list | first
) | to_json
}}
JSON
register: create_rbac_scope
changed_when: create_rbac_scope.rc == 0
failed_when: create_rbac_scope.rc != 0 and
('already exists' not in (create_rbac_scope.stderr | lower))
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
# --- Get the scope id we will attach to the client ---
- name: Get all client scopes
shell: "{{ KEYCLOAK_EXEC_KCADM }} get client-scopes -r {{ KEYCLOAK_REALM }} --format json"
register: all_scopes
changed_when: false
- name: Extract RBAC scope id
set_fact:
scope_id_rbac: >-
{{ (
all_scopes.stdout | from_json
| selectattr('name','equalto', KEYCLOAK_OIDC_RBAC_SCOPE_NAME)
| list | first | default({})
).id | default('') }}
- name: Resolve application client id
shell: >
{{ KEYCLOAK_EXEC_KCADM }} get clients
-r {{ KEYCLOAK_REALM }}
--query 'clientId={{ KEYCLOAK_CLIENT_ID }}'
--fields id --format json | jq -r '.[0].id'
register: app_client_id_cmd
changed_when: false
- name: Sanity check IDs
assert:
that:
- scope_id_rbac | length > 0
- (app_client_id_cmd.stdout | trim) is match('^[0-9a-f-]+$')
fail_msg: "Could not determine client or scope ID."
- name: Get current optional client scopes
shell: >
{{ KEYCLOAK_EXEC_KCADM }} get
clients/{{ app_client_id_cmd.stdout | trim }}/optional-client-scopes
-r {{ KEYCLOAK_REALM }} --format json
register: opt_scopes
changed_when: false
- name: Decide if RBAC scope already assigned
set_fact:
has_rbac_optional: >-
{{ (opt_scopes.stdout | from_json
| selectattr('id','equalto', scope_id_rbac) | list | length) > 0 }}
- name: Ensure RBAC scope assigned as optional (only if missing)
when: not has_rbac_optional
shell: >
{{ KEYCLOAK_EXEC_KCADM }} update
clients/{{ app_client_id_cmd.stdout | trim }}/optional-client-scopes/{{ scope_id_rbac }}
-r {{ KEYCLOAK_REALM }}
register: add_opt
changed_when: true
failed_when: add_opt.rc != 0

View File

@@ -0,0 +1,144 @@
- name: "Update REALM settings (merge LDAP component .config)"
include_tasks: 03_update.yml
vars:
kc_object_kind: "component"
kc_lookup_value: "{{ KEYCLOAK_LDAP_CMP_NAME }}"
kc_desired: >-
{{
(
KEYCLOAK_DICTIONARY_REALM.components['org.keycloak.storage.UserStorageProvider']
| selectattr('name','equalto', KEYCLOAK_LDAP_CMP_NAME)
| list | first
)
}}
kc_merge_path: "config"
- name: Resolve LDAP component id
shell: >
{{ KEYCLOAK_EXEC_KCADM }} get components
-r {{ KEYCLOAK_REALM }}
--query 'name={{ KEYCLOAK_LDAP_CMP_NAME }}'
--fields id --format json | jq -r '.[0].id'
register: ldap_cmp_id
changed_when: false
- name: Assert LDAP component id resolved
assert:
that: [ "(ldap_cmp_id.stdout | trim) not in ['', 'null']" ]
fail_msg: "LDAP component '{{ KEYCLOAK_LDAP_CMP_NAME }}' not found in Keycloak."
- name: Pull LDAP component from dictionary (by name)
set_fact:
ldap_component_tpl: >-
{{
KEYCLOAK_DICTIONARY_REALM.components['org.keycloak.storage.UserStorageProvider']
| selectattr('name','equalto', KEYCLOAK_LDAP_CMP_NAME)
| list | first | default({})
}}
- name: Sanity check dictionary LDAP component
assert:
that:
- ldap_component_tpl | length > 0
- (ldap_component_tpl.subComponents | default({})) | length > 0
fail_msg: "LDAP component '{{ KEYCLOAK_LDAP_CMP_NAME }}' not found in KEYCLOAK_DICTIONARY_REALM."
- name: Extract mapper 'ldap-roles' from template (raw)
set_fact:
desired_group_mapper_raw: >-
{{
(
ldap_component_tpl.subComponents['org.keycloak.storage.ldap.mappers.LDAPStorageMapper']
| default([])
)
| selectattr('name','equalto','ldap-roles')
| list | first | default({})
}}
- name: Ensure mapper exists in dictionary
assert:
that: [ "desired_group_mapper_raw | length > 0" ]
fail_msg: "'ldap-roles' mapper not found in dictionary under LDAP component."
- name: Build clean mapper payload
set_fact:
desired_group_mapper: >-
{{
desired_group_mapper_raw
| dict2items
| rejectattr('key','in',['subComponents','id'])
| list | items2dict
}}
- name: Extract desired groups.path (if any)
set_fact:
desired_groups_path: "{{ (desired_group_mapper.config['groups.path'] | default([])) | first | default('') | trim }}"
desired_groups_top: "{{ ( (desired_group_mapper.config['groups.path'] | default([])) | first | default('') | trim ).lstrip('/') | split('/') | first | default('') }}"
changed_when: false
- name: Resolve existing top-level group id for groups.path
when: desired_groups_top | length > 0
shell: >
{{ KEYCLOAK_EXEC_KCADM }} get groups -r {{ KEYCLOAK_REALM }} --format json
| jq -r '.[] | select(.name=="{{ desired_groups_top }}") | .id' | head -n1
register: groups_top_id
changed_when: false
- name: Create top-level group for groups.path if missing
when:
- desired_groups_top | length > 0
- (groups_top_id.stdout | trim) in ["", "null"]
shell: >
{{ KEYCLOAK_EXEC_KCADM }} create groups -r {{ KEYCLOAK_REALM }} -s name={{ desired_groups_top }}
register: create_groups_top
changed_when: create_groups_top.rc == 0
failed_when: create_groups_top.rc != 0 and ('already exists' not in (create_groups_top.stderr | lower))
- name: Find existing 'ldap-roles' mapper under LDAP component
shell: >
{{ KEYCLOAK_EXEC_KCADM }} get components
-r {{ KEYCLOAK_REALM }}
--query "parent={{ ldap_cmp_id.stdout | trim }}&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=ldap-roles"
--format json
| jq -r '.[] | select(.parentId=="{{ ldap_cmp_id.stdout | trim }}" and .name=="ldap-roles") | .id' | head -n1
register: grp_mapper_id
changed_when: false
- name: Create 'ldap-roles' mapper if missing
when: (grp_mapper_id.stdout | trim) in ["", "null"]
shell: |
cat <<'JSON' | {{ KEYCLOAK_EXEC_KCADM }} create components -r {{ KEYCLOAK_REALM }} -f -
{{
desired_group_mapper
| combine({
'name': 'ldap-roles',
'parentId': ldap_cmp_id.stdout | trim,
'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper',
'providerId': 'group-ldap-mapper'
}, recursive=True)
| to_json
}}
JSON
register: create_mapper
changed_when: create_mapper.rc == 0
failed_when: create_mapper.rc != 0 and ('already exists' not in (create_mapper.stderr | lower))
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: Update 'ldap-roles' mapper config (merge only .config)
when: (grp_mapper_id.stdout | trim) not in ["", "null"]
vars:
kc_object_kind: "component"
kc_lookup_field: "id"
kc_lookup_value: "{{ grp_mapper_id.stdout | trim }}"
kc_desired: >-
{{
desired_group_mapper
| combine({
'name': 'ldap-roles',
'parentId': ldap_cmp_id.stdout | trim,
'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper',
'providerId': 'group-ldap-mapper'
}, recursive=True)
}}
kc_merge_path: "config"
include_tasks: 03_update.yml

View File

@@ -1,31 +1,28 @@
---
- name: "create import files for {{ application_id }}"
include_tasks: 01_initialize.yml
- name: "Load cleanup routine for '{{ application_id }}'"
include_tasks: 01_cleanup.yml
- name: "load required 'web-svc-logout' for {{ application_id }}"
include_role:
name: web-svc-logout
when: run_once_web_svc_logout is not defined
- name: "Load init routine for '{{ application_id }}'"
include_tasks: 02_initialize.yml
- name: "load docker, db and proxy for {{ application_id }}"
include_role:
name: cmp-db-docker-proxy
vars:
docker_compose_flush_handlers: true
- name: "Load the depdendencies required by '{{ application_id }}'"
include_tasks: 03_load_dependencies.yml
- name: "Wait until Keycloak is reachable at {{ KEYCLOAK_SERVER_HOST_URL }}"
uri:
url: "{{ KEYCLOAK_MASTER_REALM_URL }}"
method: GET
status_code: 200
validate_certs: false
register: kc_up
retries: 30
- name: "Wait until '{{ KEYCLOAK_CONTAINER }}' container is healthy"
community.docker.docker_container_info:
name: "{{ KEYCLOAK_CONTAINER }}"
register: kc_info
retries: 60
delay: 5
until: kc_up.status == 200
until: >
kc_info is succeeded and
(kc_info.container | default({})) != {} and
(kc_info.container.State | default({})) != {} and
(kc_info.container.State.Health | default({})) != {} and
(kc_info.container.State.Health.Status | default('')) == 'healthy'
- name: kcadm login (master)
no_log: true
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
shell: >
{{ KEYCLOAK_EXEC_KCADM }} config credentials
--server {{ KEYCLOAK_SERVER_INTERNAL_URL }}
@@ -34,19 +31,6 @@
--password {{ KEYCLOAK_MASTER_API_USER_PASSWORD }}
changed_when: false
- name: "Update REALM settings"
include_tasks: 02_update.yml
vars:
kc_object_kind: "component"
kc_lookup_value: "{{ KEYCLOAK_LDAP_CMP_NAME }}"
kc_desired: >-
{{
KEYCLOAK_DICTIONARY_REALM.components['org.keycloak.storage.UserStorageProvider']
| selectattr('providerId','equalto','ldap')
| list | first }}
kc_merge_path: "config"
when: KEYCLOAK_LDAP_ENABLED | bool
- name: "Update Client settings"
vars:
kc_object_kind: "client"
@@ -59,6 +43,16 @@
}}
kc_force_attrs:
frontchannelLogout: true
attributes: "{{ (KEYCLOAK_DICTIONARY_CLIENT.attributes | default({}))
| combine({'frontchannel.logout.url': KEYCLOAK_FRONTCHANNEL_LOGOUT_URL}, recursive=True) }}"
include_tasks: 02_update.yml
attributes: >-
{{
( (KEYCLOAK_DICTIONARY_REALM.clients
| selectattr('clientId','equalto', KEYCLOAK_CLIENT_ID)
| list | first | default({}) ).attributes | default({}) )
| combine({'frontchannel.logout.url': KEYCLOAK_FRONTCHANNEL_LOGOUT_URL}, recursive=True)
}}
include_tasks: 03_update.yml
- include_tasks: 04_rbac_client_scope.yml
- include_tasks: 05_ldap.yml
when: KEYCLOAK_LDAP_ENABLED | bool