diff --git a/roles/web-app-keycloak/config/main.yml b/roles/web-app-keycloak/config/main.yml index 3a05c492..da69fd3f 100644 --- a/roles/web-app-keycloak/config/main.yml +++ b/roles/web-app-keycloak/config/main.yml @@ -1,3 +1,4 @@ +load_dependencies: True # When set to false the dependencies aren't loaded. Helpful for developing actions: import_realm: True # Import REALM features: @@ -29,7 +30,6 @@ server: canonical: - "auth.{{ PRIMARY_DOMAIN }}" scopes: - rbac_roles: rbac_roles nextcloud: nextcloud rbac_groups: "/rbac" diff --git a/roles/web-app-keycloak/filter_plugins/ldap_filters.py b/roles/web-app-keycloak/filter_plugins/ldap_filters.py new file mode 100644 index 00000000..57c86245 --- /dev/null +++ b/roles/web-app-keycloak/filter_plugins/ldap_filters.py @@ -0,0 +1,47 @@ +from typing import Iterable + +class FilterModule(object): + """Custom Jinja2 filters for LDAP related rendering.""" + + def filters(self): + return { + "ldap_groups_filter": self.ldap_groups_filter, + } + + def ldap_groups_filter(self, flavors, default="groupOfNames") -> str: + """ + Build an LDAP objectClass filter for groups based on available flavors. + + Args: + flavors: list/tuple/set of enabled flavors (e.g. ["groupOfNames","organizationalUnit"]) + default: fallback objectClass if nothing matches + + Returns: + A *single-line* LDAP filter string suitable for JSON, e.g.: + (|(objectClass=groupOfNames)(objectClass=organizationalUnit)) + + Rules: + - If both groupOfNames and organizationalUnit are present -> OR them. + - If one of them is present -> use that one. + - Otherwise -> use `default`. + """ + if flavors is None: + flavors = [] + if isinstance(flavors, str): + # be forgiving if someone passes a comma-separated string + flavors = [f.strip() for f in flavors.split(",") if f.strip()] + if not isinstance(flavors, Iterable): + raise ValueError("ldap_groups_filter: 'flavors' must be an iterable or comma-separated string") + + have_gon = "groupOfNames" in flavors + have_ou = "organizationalUnit" in flavors + + if have_gon and have_ou: + classes = ["groupOfNames", "organizationalUnit"] + return f"(|{''.join(f'(objectClass={c})' for c in classes)})" + if have_gon: + return "(objectClass=groupOfNames)" + if have_ou: + return "(objectClass=organizationalUnit)" + # fallback + return f"(objectClass={default})" diff --git a/roles/web-app-keycloak/meta/main.yml b/roles/web-app-keycloak/meta/main.yml index 8787ab96..32f4dad1 100644 --- a/roles/web-app-keycloak/meta/main.yml +++ b/roles/web-app-keycloak/meta/main.yml @@ -21,5 +21,3 @@ galaxy_info: class: "fa-solid fa-lock" run_after: - web-app-matomo -dependencies: - - web-svc-logout \ No newline at end of file diff --git a/roles/web-app-keycloak/tasks/01_cleanup.yml b/roles/web-app-keycloak/tasks/01_cleanup.yml new file mode 100644 index 00000000..2cf3f26a --- /dev/null +++ b/roles/web-app-keycloak/tasks/01_cleanup.yml @@ -0,0 +1,4 @@ +- name: "remove directory {{ KEYCLOAK_REALM_IMPORT_DIR_HOST }}" + ansible.builtin.file: + path: "{{ KEYCLOAK_REALM_IMPORT_DIR_HOST }}" + state: absent \ No newline at end of file diff --git a/roles/web-app-keycloak/tasks/01_initialize.yml b/roles/web-app-keycloak/tasks/01_initialize.yml deleted file mode 100644 index 69d324cd..00000000 --- a/roles/web-app-keycloak/tasks/01_initialize.yml +++ /dev/null @@ -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) }}" \ No newline at end of file diff --git a/roles/web-app-keycloak/tasks/02_initialize.yml b/roles/web-app-keycloak/tasks/02_initialize.yml new file mode 100644 index 00000000..7c2a43d8 --- /dev/null +++ b/roles/web-app-keycloak/tasks/02_initialize.yml @@ -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 \ No newline at end of file diff --git a/roles/web-app-keycloak/tasks/02_update.yml b/roles/web-app-keycloak/tasks/03_update.yml similarity index 54% rename from roles/web-app-keycloak/tasks/02_update.yml rename to roles/web-app-keycloak/tasks/03_update.yml index 0f165446..c6fa5c7e 100644 --- a/roles/web-app-keycloak/tasks/02_update.yml +++ b/roles/web-app-keycloak/tasks/03_update.yml @@ -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 }}" diff --git a/roles/web-app-keycloak/tasks/04_rbac_client_scope.yml b/roles/web-app-keycloak/tasks/04_rbac_client_scope.yml new file mode 100644 index 00000000..cfb2e955 --- /dev/null +++ b/roles/web-app-keycloak/tasks/04_rbac_client_scope.yml @@ -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 diff --git a/roles/web-app-keycloak/tasks/05_ldap.yml b/roles/web-app-keycloak/tasks/05_ldap.yml new file mode 100644 index 00000000..cd5f829d --- /dev/null +++ b/roles/web-app-keycloak/tasks/05_ldap.yml @@ -0,0 +1,134 @@ +# roles/web-app-keycloak/tasks/05_ldap.yml +--- +- 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('providerId','equalto','ldap') + | list | first + }} + kc_merge_path: "config" + +# --- Read desired mapper definition from KEYCLOAK_DICTIONARY_REALM --- + +- name: Get LDAP component (from KEYCLOAK_DICTIONARY_REALM) + set_fact: + ldap_component: >- + {{ + KEYCLOAK_DICTIONARY_REALM.components['org.keycloak.storage.UserStorageProvider'] + | selectattr('providerId','equalto','ldap') + | list | first | default({}) + }} + +- name: Sanity check LDAP component + assert: + that: + - ldap_component | length > 0 + - (ldap_component.subComponents | default({})) | length > 0 + fail_msg: "LDAP component not found in KEYCLOAK_DICTIONARY_REALM." + +- name: Extract desired group-ldap-mapper definition (raw) + set_fact: + desired_group_mapper_raw: >- + {{ + ( + ldap_component.subComponents['org.keycloak.storage.ldap.mappers.LDAPStorageMapper'] + | default([]) + ) + | selectattr('providerId','equalto','group-ldap-mapper') + | list | first | default({}) + }} + +- name: Ensure we found the mapper in the dictionary + assert: + that: + - desired_group_mapper_raw | length > 0 + fail_msg: "group-ldap-mapper not found below LDAP component in KEYCLOAK_DICTIONARY_REALM." + +- name: Compute desired mapper name + set_fact: + desired_group_mapper_name: "{{ desired_group_mapper_raw.name | default('ldap-roles') }}" + +- name: Build clean mapper payload (strip unsupported keys) + set_fact: + desired_group_mapper: >- + {{ + desired_group_mapper_raw + | dict2items + | rejectattr('key','equalto','subComponents') + | rejectattr('key','equalto','id') + | list | items2dict + }} + +# --- Work against Keycloak --- + +- 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: Check for group-ldap-mapper existence (by name 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={{ desired_group_mapper_name }}" + --format json + | jq -r '.[] | select(.parentId=="{{ ldap_cmp_id.stdout | trim }}" + and .providerType=="org.keycloak.storage.ldap.mappers.LDAPStorageMapper" + and .providerId=="group-ldap-mapper" + and .name=="{{ desired_group_mapper_name }}") | .id' | head -n1 + register: grp_mapper_id + changed_when: false + +- name: Ensure group-ldap-mapper exists (create 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': desired_group_mapper_name, + '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 existing group-ldap-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': desired_group_mapper_name, + '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 diff --git a/roles/web-app-keycloak/tasks/main.yml b/roles/web-app-keycloak/tasks/main.yml index d71e4d53..ccde926d 100644 --- a/roles/web-app-keycloak/tasks/main.yml +++ b/roles/web-app-keycloak/tasks/main.yml @@ -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 diff --git a/roles/web-app-keycloak/templates/docker-compose.yml.j2 b/roles/web-app-keycloak/templates/docker-compose.yml.j2 index e94691ee..2ffda9c1 100644 --- a/roles/web-app-keycloak/templates/docker-compose.yml.j2 +++ b/roles/web-app-keycloak/templates/docker-compose.yml.j2 @@ -3,12 +3,12 @@ application: image: "{{ KEYCLOAK_IMAGE }}:{{ KEYCLOAK_VERSION }}" container_name: {{ KEYCLOAK_CONTAINER }} - command: start{% if KEYCLOAK_IMPORT_REALM_ENABLED %} --import-realm{% endif %}{% if KEYCLOAK_DEBUG_ENABLED %} --verbose{% endif %} + command: start{% if KEYCLOAK_REALM_IMPORT_ENABLED %} --import-realm{% endif %}{% if KEYCLOAK_DEBUG_ENABLED %} --verbose{% endif %} {% include 'roles/docker-container/templates/base.yml.j2' %} ports: - "{{ KEYCLOAK_SERVER_HOST }}:8080" volumes: - - "{{ KEYCLOAK_HOST_IMPORT_DIR }}:{{KEYCLOAK_DOCKER_IMPORT_DIR}}" + - "{{ KEYCLOAK_REALM_IMPORT_DIR_HOST }}:{{ KEYCLOAK_REALM_IMPORT_DIR_DOCKER }}" {% include 'roles/docker-container/templates/depends_on/dmbs_excl.yml.j2' %} {% include 'roles/docker-container/templates/networks.yml.j2' %} {% set container_port = 9000 %} diff --git a/roles/web-app-keycloak/templates/env.j2 b/roles/web-app-keycloak/templates/env.j2 index 6d78ce7a..89d1d0c5 100644 --- a/roles/web-app-keycloak/templates/env.j2 +++ b/roles/web-app-keycloak/templates/env.j2 @@ -7,7 +7,7 @@ KC_HTTP_ENABLED= true # Health Checks # @see https://quarkus.io/guides/smallrye-health -KC_HEALTH_ENABLED= true +KC_HEALTH_ENABLED= {{ KEYCLOAK_HEALTH_ENABLED | lower }} KC_METRICS_ENABLED= true # Administrator diff --git a/roles/web-app-keycloak/templates/import/client.json.j2 b/roles/web-app-keycloak/templates/import/clients/default.json.j2 similarity index 95% rename from roles/web-app-keycloak/templates/import/client.json.j2 rename to roles/web-app-keycloak/templates/import/clients/default.json.j2 index 15f5ce30..57dee708 100644 --- a/roles/web-app-keycloak/templates/import/client.json.j2 +++ b/roles/web-app-keycloak/templates/import/clients/default.json.j2 @@ -28,6 +28,7 @@ "oidc.ciba.grant.enabled": "false", "client.secret.creation.time": "0", "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "false", "post.logout.redirect.uris": {{ KEYCLOAK_POST_LOGOUT_URIS | to_json }}, "frontchannel.logout.session.required": "true", "oauth2.device.authorization.grant.enabled": "false", @@ -53,7 +54,7 @@ "organization", "offline_access", "microprofile-jwt", - "{{ applications | get_app_conf(application_id, 'scopes.rbac_roles', True) }}", + "{{ KEYCLOAK_OIDC_RBAC_SCOPE_NAME }}", "{{ applications | get_app_conf(application_id, 'scopes.nextcloud', True) }}" ] } diff --git a/roles/web-app-keycloak/templates/import/ldap.json.j2 b/roles/web-app-keycloak/templates/import/components/org.keycloak.storage.UserStorageProvider.json.j2 similarity index 85% rename from roles/web-app-keycloak/templates/import/ldap.json.j2 rename to roles/web-app-keycloak/templates/import/components/org.keycloak.storage.UserStorageProvider.json.j2 index 470221f7..0935e107 100644 --- a/roles/web-app-keycloak/templates/import/ldap.json.j2 +++ b/roles/web-app-keycloak/templates/import/components/org.keycloak.storage.UserStorageProvider.json.j2 @@ -1,9 +1,10 @@ -{ +"org.keycloak.storage.UserStorageProvider": [ + { "name": "{{ KEYCLOAK_LDAP_CMP_NAME }}", "providerId": "ldap", "subComponents": { "org.keycloak.storage.ldap.mappers.LDAPStorageMapper": [ - + {# ---------------------- First Name ---------------------- #} { "name": "first name", @@ -149,13 +150,7 @@ "groups.dn": [ "{{ ldap.dn.ou.roles }}" ], "mode": [ "LDAP_ONLY" ], "user.roles.retrieve.strategy": [ "LOAD_GROUPS_BY_MEMBER_ATTRIBUTE" ], - "groups.ldap.filter": [ - "{% set flavors = ldap.rbac.flavors | default([]) %}\ -{% if 'groupOfNames' in flavors and 'organizationalUnit' in flavors %}(|(objectClass=groupOfNames)(objectClass=organizationalUnit))\ -{% elif 'groupOfNames' in flavors %}(objectClass=groupOfNames)\ -{% elif 'organizationalUnit' in flavors %}(objectClass=organizationalUnit)\ -{% else %}(objectClass=groupOfNames){% endif %}" - ], + "groups.ldap.filter": ["{{ ldap.rbac.flavors | ldap_groups_filter }}"], "membership.ldap.attribute": [ "member" ], "ignore.missing.groups": [ "true" ], "group.object.classes": [ "groupOfNames" ], @@ -163,7 +158,44 @@ "drop.non.existing.groups.during.sync": [ "false" ], "groups.path": [ "{{ applications | get_app_conf(application_id, 'rbac_groups', True) }}" ] } - }{% if keycloak_map_ldap_realm_roles | default(false) %}, + }, + { + "name": "phone number", + "providerId": "user-attribute-ldap-mapper", + "subComponents": {}, + "config": { + "ldap.attribute": [ "telephoneNumber" ], + "is.mandatory.in.ldap": [ "false" ], + "always.read.value.from.ldap": [ "true" ], + "read.only": [ "false" ], + "user.model.attribute": [ "phoneNumber" ] + } + }, + { + "name": "locale", + "providerId": "user-attribute-ldap-mapper", + "subComponents": {}, + "config": { + "ldap.attribute": [ "preferredLanguage" ], + "is.mandatory.in.ldap": [ "false" ], + "always.read.value.from.ldap": [ "true" ], + "read.only": [ "false" ], + "user.model.attribute": [ "locale" ] + } + }, + { + "name": "uidNumber", + "providerId": "user-attribute-ldap-mapper", + "subComponents": {}, + "config": { + "ldap.attribute": [ "uidNumber" ], + "is.mandatory.in.ldap": [ "false" ], + "always.read.value.from.ldap": [ "true" ], + "read.only": [ "false" ], + "user.model.attribute": [ "uidNumber" ] + } + } + {% if keycloak_map_ldap_realm_roles | default(false) %}, {# ---------------------- LDAP -> Realm Roles (optional) -- #} { "name": "ldap-realm-roles", @@ -182,7 +214,6 @@ "role.object.classes": [ "groupOfNames" ] } }{% endif %} - ] }, "config": { @@ -225,3 +256,4 @@ "removeInvalidUsersEnabled": [ "true" ] } } +] \ No newline at end of file diff --git a/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 b/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 new file mode 100644 index 00000000..1109ad7a --- /dev/null +++ b/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 @@ -0,0 +1,61 @@ +{% set user_profile = { + "attributes": [ + { + "name": "username", + "displayName": "${username}", + "validations": {"length": {"min": 3, "max": 255}, "pattern": {"pattern": "^[a-z0-9]+$", "error-message": ""}}, + "annotations": {}, + "permissions": {"view": ["admin","user"], "edit": ["admin","user"]}, + "multivalued": false + }, + { + "name": "email", + "displayName": "${email}", + "validations": {"email": {}, "length": {"max": 255}}, + "required": {"roles": ["user"]}, + "permissions": {"view": ["admin","user"], "edit": ["admin","user"]}, + "multivalued": false + }, + { + "name": "firstName", + "displayName": "${firstName}", + "validations": {"length": {"max": 255}, "person-name-prohibited-characters": {}}, + "required": {"roles": ["user"]}, + "permissions": {"view": ["admin","user"], "edit": ["admin","user"]}, + "multivalued": false + }, + { + "name": "lastName", + "displayName": "${lastName}", + "validations": {"length": {"max": 255}, "person-name-prohibited-characters": {}}, + "required": {"roles": ["user"]}, + "permissions": {"view": ["admin","user"], "edit": ["admin","user"]}, + "multivalued": false + }, + { + "name": ldap.user.attributes.ssh_public_key, + "displayName": "SSH Public Key", + "validations": {}, + "annotations": {}, + "permissions": {"view": ["admin","user"], "edit": ["admin","user"]}, + "group": "user-metadata", + "multivalued": true + } + ], + "groups": [ + { + "name": "user-metadata", + "displayHeader": "User metadata", + "displayDescription": "Attributes, which refer to user metadata" + } + ] +} %} +"org.keycloak.userprofile.UserProfileProvider": [ + { + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": { + "kc.user.profile.config": [{{ (user_profile | tojson) | tojson }}] + } + } +] \ No newline at end of file diff --git a/roles/web-app-keycloak/templates/import/realm.json.j2 b/roles/web-app-keycloak/templates/import/realm.json.j2 index 1dc4d83c..e8131479 100644 --- a/roles/web-app-keycloak/templates/import/realm.json.j2 +++ b/roles/web-app-keycloak/templates/import/realm.json.j2 @@ -507,7 +507,7 @@ "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, "defaultClientScopes": [ - "web-app-origins", + "web-origins", "acr", "roles", "profile", @@ -572,7 +572,7 @@ } ], "defaultClientScopes": [ - "web-app-origins", + "web-origins", "acr", "roles", "profile", @@ -614,7 +614,7 @@ "fullScopeAllowed": true, "nodeReRegistrationTimeout": 0, "defaultClientScopes": [ - "web-app-origins", + "web-origins", "acr", "roles", "profile", @@ -655,7 +655,7 @@ "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, "defaultClientScopes": [ - "web-app-origins", + "web-origins", "acr", "roles", "profile", @@ -696,7 +696,7 @@ "fullScopeAllowed": false, "nodeReRegistrationTimeout": 0, "defaultClientScopes": [ - "web-app-origins", + "web-origins", "acr", "roles", "profile", @@ -763,7 +763,7 @@ } ], "defaultClientScopes": [ - "web-app-origins", + "web-origins", "acr", "roles", "profile", @@ -778,7 +778,7 @@ "microprofile-jwt" ] }, - {% include "client.json.j2" %} + {% include "clients/default.json.j2" %} ], "clientScopes": [ { @@ -1057,86 +1057,10 @@ } ] }, + {% include "scopes/rbac.json.j2" %}, + {% include "scopes/nextcloud.json.j2" %}, { - "name": "{{ applications | get_app_conf(application_id, 'scopes.nextcloud', True) }}", - "description": "Optimized mappers for nextcloud oidc_login with ldap.", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "true", - "gui.order": "", - "consent.screen.text": "" - }, - "protocolMappers": [ - { - "name": "{{ ldap.user.attributes.nextcloud_quota }}", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "aggregate.attrs": "false", - "introspection.token.claim": "true", - "multivalued": "false", - "userinfo.token.claim": "true", - "user.attribute": "{{ ldap.user.attributes.nextcloud_quota }}", - "id.token.claim": "true", - "lightweight.claim": "false", - "access.token.claim": "true", - "claim.name": "{{ ldap.user.attributes.nextcloud_quota }}", - "jsonType.label": "int" - } - }, - { - "name": "UID Mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "consentRequired": false, - "config": { - "aggregate.attrs": "false", - "introspection.token.claim": "true", - "multivalued": "false", - "userinfo.token.claim": "true", - "user.attribute": "username", - "id.token.claim": "true", - "lightweight.claim": "false", - "access.token.claim": "true", - "claim.name": "{{ldap.user.attributes.id}}", - "jsonType.label": "String" - } - } - ] - }, - { - "name": "{{ applications | get_app_conf(application_id, 'scopes.rbac_roles', True) }}", - "description": "RBAC Groups", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "false", - "display.on.consent.screen": "true", - "gui.order": "", - "consent.screen.text": "" - }, - "protocolMappers": [ - { - "name": "groups", - "protocol": "openid-connect", - "protocolMapper": "oidc-group-membership-mapper", - "consentRequired": false, - "config": { - "full.path": "true", - "introspection.token.claim": "true", - "userinfo.token.claim": "true", - "multivalued": "true", - "id.token.claim": "true", - "lightweight.claim": "false", - "access.token.claim": "true", - "claim.name": "{{ OIDC.CLAIMS.GROUPS }}" - } - } - ] - }, - { - "name": "web-app-origins", + "name": "web-origins", "description": "OpenID Connect scope for add allowed web origins to the access token", "protocol": "openid-connect", "attributes": { @@ -1496,7 +1420,7 @@ "profile", "email", "roles", - "web-app-origins", + "web-origins", "acr", "basic" ], @@ -1506,7 +1430,7 @@ "phone", "microprofile-jwt", "organization", - "{{ applications | get_app_conf(application_id, 'scopes.rbac_roles', True) }}", + "{{ KEYCLOAK_OIDC_RBAC_SCOPE_NAME }}", "{{ applications | get_app_conf(application_id, 'scopes.nextcloud', True) }}" ], "browserSecurityHeaders": { @@ -1642,20 +1566,8 @@ "config": {} } ], - "org.keycloak.userprofile.UserProfileProvider": [ - { - "providerId": "declarative-user-profile", - "subComponents": {}, - "config": { - "kc.user.profile.config": [ - "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"pattern\":{\"pattern\":\"^[a-z0-9]+$\",\"error-message\":\"\"}},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"{{ ldap.user.attributes.ssh_public_key }}\",\"displayName\":\"SSH Public Key\",\"validations\":{},\"annotations\":{},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"group\":\"user-metadata\",\"multivalued\":true}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" - ] - } - } - ], - "org.keycloak.storage.UserStorageProvider": [ - {% include "ldap.json.j2" %} - ], + {%- include "components/org.keycloak.userprofile.UserProfileProvider.json.j2" -%}, + {%- include "components/org.keycloak.storage.UserStorageProvider.json.j2" -%}, "org.keycloak.keys.KeyProvider": [ { "name": "rsa-enc-generated", diff --git a/roles/web-app-keycloak/templates/import/scopes/nextcloud.json.j2 b/roles/web-app-keycloak/templates/import/scopes/nextcloud.json.j2 new file mode 100644 index 00000000..2d1cfcad --- /dev/null +++ b/roles/web-app-keycloak/templates/import/scopes/nextcloud.json.j2 @@ -0,0 +1,49 @@ + { + "name": "{{ applications | get_app_conf(application_id, 'scopes.nextcloud') }}", + "description": "Optimized mappers for nextcloud oidc_login with ldap.", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "name": "{{ ldap.user.attributes.nextcloud_quota }}", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "{{ ldap.user.attributes.nextcloud_quota }}", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "{{ ldap.user.attributes.nextcloud_quota }}", + "jsonType.label": "int" + } + }, + { + "name": "UID Mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "introspection.token.claim": "true", + "multivalued": "false", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "{{ldap.user.attributes.id}}", + "jsonType.label": "String" + } + } + ] +} \ No newline at end of file diff --git a/roles/web-app-keycloak/templates/import/scopes/rbac.json.j2 b/roles/web-app-keycloak/templates/import/scopes/rbac.json.j2 new file mode 100644 index 00000000..2fefc3e9 --- /dev/null +++ b/roles/web-app-keycloak/templates/import/scopes/rbac.json.j2 @@ -0,0 +1,29 @@ +{ + "name": "{{ KEYCLOAK_OIDC_RBAC_SCOPE_NAME }}", + "description": "RBAC Groups", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "multivalued": "true", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "{{ OIDC.CLAIMS.GROUPS }}" + } + } + ] +} \ No newline at end of file diff --git a/roles/web-app-keycloak/vars/main.yml b/roles/web-app-keycloak/vars/main.yml index 3c2a8947..d9b0992e 100644 --- a/roles/web-app-keycloak/vars/main.yml +++ b/roles/web-app-keycloak/vars/main.yml @@ -10,28 +10,35 @@ KEYCLOAK_REALM: "{{ OIDC.CLIENT.REALM }}" # This is the name KEYCLOAK_REALM_URL: "{{ WEB_PROTOCOL }}://{{ KEYCLOAK_REALM }}" KEYCLOAK_DEBUG_ENABLED: "{{ MODE_DEBUG }}" KEYCLOAK_CLIENT_ID: "{{ OIDC.CLIENT.ID }}" -KEYCLOAK_MASTER_REALM_URL: "{{ KEYCLOAK_SERVER_HOST_URL }}/realms/master" -KEYCLOAK_HOST_IMPORT_DIR: "{{ docker_compose.directories.volumes }}import/" KEYCLOAK_SERVER_INTERNAL_URL: "http://127.0.0.1:8080" +KEYCLOAK_OIDC_RBAC_SCOPE_NAME: "{{ OIDC.CLAIMS.GROUPS }}" +KEYCLOAK_LOAD_DEPENDENCIES: "{{ applications | get_app_conf(application_id, 'load_dependencies') }}" -# Credentials +## Health +KEYCLOAK_HEALTH_ENABLED: true + +## Import +KEYCLOAK_REALM_IMPORT_ENABLED: "{{ applications | get_app_conf(application_id, 'actions.import_realm') }}" +KEYCLOAK_REALM_IMPORT_DIR_HOST: "{{ docker_compose.directories.volumes }}import/" +KEYCLOAK_REALM_IMPORT_DIR_DOCKER: "/opt/keycloak/data/import/" +KEYCLOAK_REALM_IMPORT_FILE_SRC: "import/realm.json.j2" +KEYCLOAK_REALM_IMPORT_FILE_DST: "{{ KEYCLOAK_REALM_IMPORT_DIR_HOST }}/realm.json" + +## Credentials KEYCLOAK_ADMIN: "{{ applications | get_app_conf(application_id, 'users.administrator.username') }}" KEYCLOAK_ADMIN_PASSWORD: "{{ applications | get_app_conf(application_id, 'credentials.administrator_password') }}" ## Docker KEYCLOAK_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.name') }}" # Name of the keycloak docker container -KEYCLOAK_DOCKER_IMPORT_DIR: "/opt/keycloak/data/import/" # Directory in which keycloak import files are placed in the running docker container KEYCLOAK_EXEC_KCADM: "docker exec -i {{ KEYCLOAK_CONTAINER }} /opt/keycloak/bin/kcadm.sh" # Init script for keycloak KEYCLOAK_IMAGE: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.image') }}" # Keycloak docker image KEYCLOAK_VERSION: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.version') }}" # Keycloak docker version ## Server KEYCLOAK_SERVER_HOST: "127.0.0.1:{{ ports.localhost.http[application_id] }}" -KEYCLOAK_SERVER_HOST_URL: "http://{{ KEYCLOAK_SERVER_HOST }}" ## Update KEYCLOAK_REDIRECT_FEATURES: ["features.oauth2","features.oidc"] -KEYCLOAK_IMPORT_REALM_ENABLED: "{{ applications | get_app_conf(application_id, 'actions.import_realm') }}" # Activate realm import KEYCLOAK_FRONTCHANNEL_LOGOUT_URL: "{{ domains | get_url('web-svc-logout', WEB_PROTOCOL) }}/" KEYCLOAK_REDIRECT_URIS: "{{ domains | redirect_uris(applications, WEB_PROTOCOL, '/*', KEYCLOAK_REDIRECT_FEATURES) }}" KEYCLOAK_WEB_ORIGINS: >- @@ -54,7 +61,6 @@ KEYCLOAK_MASTER_API_USER: "{{ applications | get_app_conf(application_ KEYCLOAK_MASTER_API_USER_NAME: "{{ KEYCLOAK_MASTER_API_USER.username }}" # Master Administrator Username KEYCLOAK_MASTER_API_USER_PASSWORD: "{{ KEYCLOAK_MASTER_API_USER.password }}" # Master Administrator Password - # Dictionaries KEYCLOAK_DICTIONARY_REALM_RAW: "{{ lookup('template', 'import/realm.json.j2') }}" KEYCLOAK_DICTIONARY_REALM: >-