diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39a5bd1..be8e397 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,4 +22,4 @@ jobs: root_permission_varname: 'keycloak_install_requires_become' debug_verbosity: "${{ github.event.inputs.debug_verbosity }}" molecule_tests: >- - [ "debian", "quarkus", "quarkus_ha", "quarkus_ha_remote", "quarkus_ha_26.4_below", "default", "quarkus_devmode", "quarkus_upgrade" ] + [ "debian", "quarkus", "quarkus_ha", "quarkus_ha_remote", "quarkus_ha_26.4_below", "default", "quarkus_devmode", "quarkus_upgrade", "keycloak_modules" ] diff --git a/README.md b/README.md index e481cd2..9d44836 100644 --- a/README.md +++ b/README.md @@ -57,25 +57,53 @@ A requirement file is provided to install: ### Included modules -* `keycloak_realm`: module for managing Keycloak realms (create/update/delete). -* `keycloak_client`: module for managing Keycloak clients (create/update/delete). -* `keycloak_role`: module for managing Keycloak roles — realm roles and client roles (create/update/delete). -* `keycloak_user_federation`: module for managing user federations such as LDAP/AD (create/update/delete). -* `keycloak_client_scope`: module for managing client scopes and protocol mappers (create/update/delete). -* `keycloak_authentication_flow`: module for managing authentication flows and execution steps (create/delete, copy existing flows). +All Keycloak administration modules from `community.general` are provided in this collection for Keycloak 17+ (Quarkus). Use `auth_keycloak_url` without the legacy `/auth` context path (for example `http://localhost:8080`). Set `keycloak_context` to `/auth` only when automating WildFly-based Keycloak with the `keycloak` role. + +* `keycloak_authentication`: manage authentication flows and executions using Keycloak Admin REST API. +* `keycloak_authentication_flow`: manage custom authentication flows and flow executions. +* `keycloak_authentication_required_actions`: manage required actions available in realm authentication. +* `keycloak_authentication_v2`: manage authentication flows with newer Keycloak API handling. +* `keycloak_authz_authorization_scope`: manage authorization scopes for a client resource server. +* `keycloak_authz_custom_policy`: manage custom authorization policies for a client resource server. +* `keycloak_authz_permission`: manage authorization permissions for a client resource server. +* `keycloak_authz_permission_info`: retrieve authorization permission information for a client resource server. +* `keycloak_client`: manage Keycloak clients (create/update/delete). +* `keycloak_client_rolemapping`: manage client role mappings for users and groups. +* `keycloak_client_rolescope`: manage client role scope mappings. +* `keycloak_client_scope`: manage client scopes and protocol mappers (replaces `community.general.keycloak_clientscope`). +* `keycloak_clientscope_type`: manage default and optional client scope assignments. +* `keycloak_clientsecret_info`: retrieve client secret information. +* `keycloak_clientsecret_regenerate`: regenerate a client secret. +* `keycloak_clienttemplate`: manage legacy client templates. +* `keycloak_component`: manage realm components. +* `keycloak_component_info`: retrieve realm component information. +* `keycloak_group`: manage realm groups and subgroups. +* `keycloak_identity_provider`: manage identity provider instances and configuration. +* `keycloak_realm`: manage realms (create/update/delete). +* `keycloak_realm_info`: retrieve realm information. +* `keycloak_realm_key`: manage realm key providers. +* `keycloak_realm_keys_metadata_info`: retrieve realm keys metadata. +* `keycloak_realm_localization`: manage realm localization texts. +* `keycloak_realm_rolemapping`: manage realm role mappings for users and groups. +* `keycloak_role`: manage realm and client roles. +* `keycloak_user`: manage users (create/update/delete). +* `keycloak_user_execute_actions_email`: trigger execute-actions emails for users. +* `keycloak_user_federation`: manage user federation providers (for example LDAP/AD). +* `keycloak_user_rolemapping`: manage user role mappings. +* `keycloak_userprofile`: manage user profile configuration. ## Usage +The collection provides roles to install Keycloak and modules to manage realms, clients, users, and related settings via the [Keycloak Admin REST API](https://www.keycloak.org/docs-api/latest/rest-api/index.html). -### Install Playbook - -* [`playbooks/keycloak_quarkus.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_quarkus.yml) installs keycloak >= 17 based on the defined variables (using most defaults). -* [`playbooks/keycloak.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak.yml) installs keycloak legacy based on the defined variables (using most defaults). +For Quarkus-based Keycloak (17+), set `auth_keycloak_url` to the server root URL without the legacy `/auth` path, for example `http://localhost:8080`. When using the legacy `keycloak` role with WildFly-based Keycloak, set `keycloak_context` to `/auth` in the `keycloak_realm` role. -Both playbooks include the `keycloak` role, with different settings, as described in the following sections. +### Install Keycloak -For full service configuration details, refer to the [keycloak role README](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak/README.md). - +* [`playbooks/keycloak_quarkus.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_quarkus.yml) installs Keycloak >= 17 using the `keycloak_quarkus` role. +* [`playbooks/keycloak.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak.yml) installs legacy Keycloak (<= 19) using the `keycloak` role. + +For full service configuration details, refer to the [keycloak_quarkus role README](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak_quarkus/README.md) or the [keycloak role README](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak/README.md). #### Install from controller node (offline) @@ -96,15 +124,15 @@ keycloak_offline_install: true It is possible to perform downloads from alternate sources, using the `keycloak_download_url` variable; make sure the final downloaded filename matches with the source filename (ie. keycloak-legacy-x.y.zip or rh-sso-x.y.z-server-dist.zip). -### Example installation command +#### Example installation command -Execute the following command from the source root directory +Execute the following command from the source root directory: -``` -ansible-playbook -i -e @rhn-creds.yml playbooks/keycloak.yml -e keycloak_admin_password= +```bash +ansible-playbook -i playbooks/keycloak_quarkus.yml -e keycloak_quarkus_bootstrap_admin_password= ``` -- `keycloak_admin_password` Password for the administration console user account. +- `keycloak_quarkus_bootstrap_admin_password` password for the administration console user account. - `ansible_hosts` is the inventory, below is an example inventory for deploying to localhost ``` @@ -114,19 +142,15 @@ ansible-playbook -i -e @rhn-creds.yml playbooks/keycloak.yml -e Note: when deploying clustered configurations, all hosts belonging to the cluster must be present in `ansible_play_batch`; ie. they must be targeted by the same ansible-playbook execution. +### Configure with roles -## Configuration - - -### Config Playbooks * [`playbooks/keycloak_realm.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_realm.yml) creates or updates provided realm, user federation(s), client(s), client role(s) and client user(s). * [`playbooks/keycloak_realm_client.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_realm_client.yml) creates a realm with clients, roles and users using the `keycloak_realm` role. -* [`playbooks/keycloak_client_scope.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_client_scope.yml) creates a client scope with protocol mappers using the `keycloak_client_scope` module. -* [`playbooks/keycloak_authentication_flow.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_authentication_flow.yml) creates a custom authentication flow with execution steps using the `keycloak_authentication_flow` module. +* [`playbooks/keycloak_federation.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_federation.yml) configures user federation providers. -### Example configuration command +#### Example configuration command Execute the following command from the source root directory: @@ -146,6 +170,37 @@ ansible-playbook -i playbooks/keycloak_realm.yml -e keycloak_adm For full configuration details, refer to the [keycloak_realm role README](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak_realm/README.md). +### Configure with modules + +Module playbooks target an already running Keycloak instance. All modules use the `middleware_automation.keycloak` collection namespace. + +* [`playbooks/keycloak_client_scope.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_client_scope.yml) creates a client scope with protocol mappers using the `keycloak_client_scope` module. +* [`playbooks/keycloak_authentication_flow.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_authentication_flow.yml) creates a custom authentication flow with execution steps using the `keycloak_authentication_flow` module. + +Example task using shared authentication defaults: + +```yaml +- hosts: localhost + module_defaults: + group/middleware_automation.keycloak.keycloak: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: "{{ keycloak_admin_password }}" + tasks: + - name: Create a user in a realm + middleware_automation.keycloak.keycloak_user: + realm: TestRealm + username: testuser + first_name: Test + last_name: User + email: testuser@example.com + enabled: true + state: present +``` + +When migrating from `community.general`, replace the collection prefix in playbooks (for example `community.general.keycloak_user` becomes `middleware_automation.keycloak.keycloak_user`) and use `keycloak_client_scope` instead of `keycloak_clientscope`. + ## Support diff --git a/meta/runtime.yml b/meta/runtime.yml index 49c7554..64490eb 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -1,2 +1,45 @@ --- requires_ansible: ">=2.16.0" +action_groups: + keycloak: + - keycloak_authentication + - keycloak_authentication_flow + - keycloak_authentication_required_actions + - keycloak_authentication_v2 + - keycloak_authz_authorization_scope + - keycloak_authz_custom_policy + - keycloak_authz_permission + - keycloak_authz_permission_info + - keycloak_client + - keycloak_client_rolemapping + - keycloak_client_rolescope + - keycloak_client_scope + - keycloak_clientscope_type + - keycloak_clientsecret_info + - keycloak_clientsecret_regenerate + - keycloak_clienttemplate + - keycloak_component + - keycloak_component_info + - keycloak_group + - keycloak_identity_provider + - keycloak_realm + - keycloak_realm_info + - keycloak_realm_key + - keycloak_realm_keys_metadata_info + - keycloak_realm_localization + - keycloak_realm_rolemapping + - keycloak_role + - keycloak_user + - keycloak_user_federation + - keycloak_user_rolemapping + - keycloak_userprofile + - keycloak_user_execute_actions_email +plugin_routing: + modules: + keycloak_clientscope: + redirect: middleware_automation.keycloak.keycloak_client_scope + deprecation: + removal_version: 5.0.0 + warning_text: >- + The module has been renamed to keycloak_client_scope for Keycloak 17+ (Quarkus). + Update playbooks to use middleware_automation.keycloak.keycloak_client_scope. diff --git a/molecule/keycloak_modules/converge.yml b/molecule/keycloak_modules/converge.yml new file mode 100644 index 0000000..571ddff --- /dev/null +++ b/molecule/keycloak_modules/converge.yml @@ -0,0 +1,2 @@ +--- +- import_playbook: ../default/converge.yml diff --git a/molecule/keycloak_modules/molecule.yml b/molecule/keycloak_modules/molecule.yml new file mode 100644 index 0000000..19113b8 --- /dev/null +++ b/molecule/keycloak_modules/molecule.yml @@ -0,0 +1,51 @@ +--- +driver: + name: podman +platforms: + - name: instance + image: registry.access.redhat.com/ubi9/ubi-init:latest + pre_build_image: true + privileged: true + command: "/usr/sbin/init" + port_bindings: + - "8080/tcp" + - "8443/tcp" + - "8009/tcp" + - "9000/tcp" +provisioner: + name: ansible + config_options: + defaults: + interpreter_python: auto_silent + roles_path: ../../roles + ssh_connection: + pipelining: false + playbooks: + prepare: prepare.yml + converge: converge.yml + verify: verify.yml + inventory: + group_vars: + all: + keycloak_install_requires_become: true + host_vars: + localhost: + ansible_python_interpreter: "{{ ansible_playbook_python }}" + env: + ANSIBLE_FORCE_COLOR: "true" + PROXY: "${PROXY}" + NO_PROXY: "${NO_PROXY}" +verifier: + name: ansible +scenario: + test_sequence: + - cleanup + - destroy + - create + - prepare + - converge + - idempotence + - side_effect + - verify + - cleanup + - destroy diff --git a/molecule/keycloak_modules/prepare.yml b/molecule/keycloak_modules/prepare.yml new file mode 100644 index 0000000..8ca8268 --- /dev/null +++ b/molecule/keycloak_modules/prepare.yml @@ -0,0 +1,2 @@ +--- +- import_playbook: ../default/prepare.yml diff --git a/molecule/keycloak_modules/verify.yml b/molecule/keycloak_modules/verify.yml new file mode 100644 index 0000000..8194ade --- /dev/null +++ b/molecule/keycloak_modules/verify.yml @@ -0,0 +1,536 @@ +--- +- name: Verify migrated Keycloak modules + hosts: all + vars: + auth_keycloak_url: http://instance:8080 + auth_realm: master + auth_username: remembertochangeme + auth_password: remembertochangeme + target_realm: TestRealm + role: molecule-role + client_role: molecule-client-role + client: molecule-client + scope: molecule-scope + group: molecule-group + user: molecule-user + flow: molecule-flow + flow_v2: molecule-flow-v2 + auth_copy: molecule-auth-copy + idp: molecule-idp + ephemeral_realm: molecule-realm + template: molecule-template + realm_key: molecule-aes-key + authz_scope: molecule-authz-scope + authz_permission: molecule-authz-perm + federation: molecule-federation + component: molecule-component + migrated_keycloak_modules: + - keycloak_authentication + - keycloak_authentication_flow + - keycloak_authentication_required_actions + - keycloak_authentication_v2 + - keycloak_authz_authorization_scope + - keycloak_authz_custom_policy + - keycloak_authz_permission + - keycloak_authz_permission_info + - keycloak_client + - keycloak_client_rolemapping + - keycloak_client_rolescope + - keycloak_client_scope + - keycloak_clientscope_type + - keycloak_clientsecret_info + - keycloak_clientsecret_regenerate + - keycloak_clienttemplate + - keycloak_component + - keycloak_component_info + - keycloak_group + - keycloak_identity_provider + - keycloak_realm + - keycloak_realm_info + - keycloak_realm_key + - keycloak_realm_keys_metadata_info + - keycloak_realm_localization + - keycloak_realm_rolemapping + - keycloak_role + - keycloak_user + - keycloak_user_execute_actions_email + - keycloak_user_federation + - keycloak_user_rolemapping + - keycloak_userprofile + + tasks: + - name: Populate service facts + ansible.builtin.service_facts: + + - name: Check if keycloak service started + ansible.builtin.assert: + that: + - ansible_facts.services["keycloak.service"]["state"] == "running" + - ansible_facts.services["keycloak.service"]["status"] == "enabled" + fail_msg: Service not running + + - name: Verify migrated modules are discoverable by ansible-doc # noqa command-instead-of-module + ansible.builtin.command: + cmd: "ansible-doc -t module middleware_automation.keycloak.{{ item }}" + loop: "{{ migrated_keycloak_modules }}" + delegate_to: localhost + register: docs_check + changed_when: false + + - name: Ensure module docs check succeeded + ansible.builtin.assert: + that: + - docs_check is not failed + + - name: Provision shared test fixtures + module_defaults: + group/middleware_automation.keycloak.keycloak: + auth_keycloak_url: "{{ auth_keycloak_url }}" + auth_realm: "{{ auth_realm }}" + auth_username: "{{ auth_username }}" + auth_password: "{{ auth_password }}" + block: + - name: Reset fixtures from a previous verify run + middleware_automation.keycloak.keycloak_user: + realm: "{{ target_realm }}" + username: "{{ user }}" + state: absent + failed_when: false + + - name: keycloak_role — create realm role for module tests + middleware_automation.keycloak.keycloak_role: + realm: "{{ target_realm }}" + name: "{{ role }}" + state: present + + - name: keycloak_client — create confidential client for module tests + middleware_automation.keycloak.keycloak_client: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + name: "{{ client }}" + enabled: true + public_client: false + standard_flow_enabled: true + client_authenticator_type: client-secret + secret: molecule-client-secret + state: present + + - name: keycloak_client — enable authorization services on test client + middleware_automation.keycloak.keycloak_client: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + service_accounts_enabled: true + authorization_services_enabled: true + full_scope_allowed: false + state: present + + - name: keycloak_role — create client role for rolemapping tests + middleware_automation.keycloak.keycloak_role: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + name: "{{ client_role }}" + state: present + + - name: keycloak_client_scope — create client scope + middleware_automation.keycloak.keycloak_client_scope: + realm: "{{ target_realm }}" + name: "{{ scope }}" + state: present + + - name: keycloak_group — create group + middleware_automation.keycloak.keycloak_group: + realm: "{{ target_realm }}" + name: "{{ group }}" + state: present + + - name: keycloak_user — create user + middleware_automation.keycloak.keycloak_user: + realm: "{{ target_realm }}" + username: "{{ user }}" + first_name: Molecule + last_name: User + email: molecule-user@example.invalid + enabled: true + state: present + + - name: keycloak_authentication_flow — create browser-style flow + middleware_automation.keycloak.keycloak_authentication_flow: + realm: "{{ target_realm }}" + alias: "{{ flow }}" + description: Molecule module test authentication flow + executions: + - provider_id: auth-cookie + requirement: REQUIRED + state: present + + - name: keycloak_realm_info — query TestRealm (public endpoint, no admin auth) + middleware_automation.keycloak.keycloak_realm_info: + auth_keycloak_url: "{{ auth_keycloak_url }}" + realm: "{{ target_realm }}" + + - name: Exercise migrated modules against running Keycloak + module_defaults: + group/middleware_automation.keycloak.keycloak: + auth_keycloak_url: "{{ auth_keycloak_url }}" + auth_realm: "{{ auth_realm }}" + auth_username: "{{ auth_username }}" + auth_password: "{{ auth_password }}" + block: + - name: keycloak_realm_keys_metadata_info — query realm keys + middleware_automation.keycloak.keycloak_realm_keys_metadata_info: + realm: "{{ target_realm }}" + + - name: keycloak_component_info — list realm components + middleware_automation.keycloak.keycloak_component_info: + realm: "{{ target_realm }}" + + - name: keycloak_realm — create ephemeral realm + middleware_automation.keycloak.keycloak_realm: + id: "{{ ephemeral_realm }}" + realm: "{{ ephemeral_realm }}" + enabled: true + state: present + + - name: keycloak_realm_localization — set locale override + middleware_automation.keycloak.keycloak_realm_localization: + parent_id: "{{ target_realm }}" + locale: en + state: present + overrides: + - key: molecule.module.test + value: molecule module test + + - name: keycloak_realm_key — create generated AES key component + middleware_automation.keycloak.keycloak_realm_key: + parent_id: "{{ target_realm }}" + name: "{{ realm_key }}" + provider_id: aes-generated + state: present + + - name: keycloak_authentication — copy browser flow + middleware_automation.keycloak.keycloak_authentication: + realm: "{{ target_realm }}" + alias: "{{ auth_copy }}" + copyFrom: browser + state: present + + - name: keycloak_authentication_v2 — manage flow with safe-swap semantics + middleware_automation.keycloak.keycloak_authentication_v2: + realm: "{{ target_realm }}" + alias: "{{ flow_v2 }}" + description: Molecule module test flow v2 + authenticationExecutions: + - requirement: REQUIRED + providerId: auth-cookie + state: present + + - name: keycloak_authentication_required_actions — ensure VERIFY_EMAIL is enabled + middleware_automation.keycloak.keycloak_authentication_required_actions: + realm: "{{ target_realm }}" + state: present + required_actions: + - alias: VERIFY_EMAIL + enabled: true + + - name: keycloak_userprofile — set unmanaged attribute policy + middleware_automation.keycloak.keycloak_userprofile: + parent_id: "{{ target_realm }}" + state: present + config: + kc_user_profile_config: + - unmanagedAttributePolicy: ENABLED + + - name: keycloak_identity_provider — create OIDC broker stub + middleware_automation.keycloak.keycloak_identity_provider: + realm: "{{ target_realm }}" + alias: "{{ idp }}" + provider_id: oidc + enabled: false + config: + authorizationUrl: http://localhost:8080/realms/master/protocol/openid-connect/auth + tokenUrl: http://localhost:8080/realms/master/protocol/openid-connect/token + clientId: molecule-idp-client + state: present + + - name: keycloak_clienttemplate — create client template + middleware_automation.keycloak.keycloak_clienttemplate: + realm: "{{ target_realm }}" + name: "{{ template }}" + protocol: openid-connect + state: present + register: clienttemplate_result + failed_when: + - clienttemplate_result is failed + - "'404' not in (clienttemplate_result.msg | default(''))" + - "'Not Found' not in (clienttemplate_result.msg | default(''))" + + - name: keycloak_clientscope_type — attach scope as optional on realm + middleware_automation.keycloak.keycloak_clientscope_type: + realm: "{{ target_realm }}" + optional_clientscopes: + - "{{ scope }}" + + - name: keycloak_user_rolemapping — assign realm role to user + middleware_automation.keycloak.keycloak_user_rolemapping: + realm: "{{ target_realm }}" + target_username: "{{ user }}" + state: present + roles: + - name: "{{ role }}" + + - name: keycloak_realm_rolemapping — assign realm role to group + middleware_automation.keycloak.keycloak_realm_rolemapping: + realm: "{{ target_realm }}" + group_name: "{{ group }}" + state: present + roles: + - name: "{{ role }}" + + - name: keycloak_client_rolemapping — assign client role to group + middleware_automation.keycloak.keycloak_client_rolemapping: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + group_name: "{{ group }}" + state: present + roles: + - name: "{{ client_role }}" + + - name: keycloak_client_rolescope — restrict realm role on client + middleware_automation.keycloak.keycloak_client_rolescope: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + role_names: + - "{{ role }}" + state: present + + - name: keycloak_clientsecret_info — read client secret + middleware_automation.keycloak.keycloak_clientsecret_info: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + no_log: true + + - name: keycloak_clientsecret_regenerate — rotate client secret + middleware_automation.keycloak.keycloak_clientsecret_regenerate: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + no_log: true + + - name: keycloak_authz_authorization_scope — create authorization scope + middleware_automation.keycloak.keycloak_authz_authorization_scope: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + name: "{{ authz_scope }}" + display_name: Molecule module test scope + state: present + + - name: keycloak_authz_permission — create scope permission + middleware_automation.keycloak.keycloak_authz_permission: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + name: "{{ authz_permission }}" + permission_type: scope + scopes: + - "{{ authz_scope }}" + state: present + + - name: keycloak_authz_permission_info — query scope permission + middleware_automation.keycloak.keycloak_authz_permission_info: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + name: "{{ authz_permission }}" + + - name: keycloak_authz_custom_policy — requires deployed policy provider on server + middleware_automation.keycloak.keycloak_authz_custom_policy: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + name: molecule-script-policy + policy_type: script-policy.js + state: present + register: authz_custom_policy_result + failed_when: + - authz_custom_policy_result is failed + - "'No policy provider' not in (authz_custom_policy_result.msg | default(''))" + - "'Policy provider' not in (authz_custom_policy_result.msg | default(''))" + - "'405' not in (authz_custom_policy_result.msg | default(''))" + - "'Method Not Allowed' not in (authz_custom_policy_result.msg | default(''))" + - "'not found' not in (authz_custom_policy_result.msg | default('') | lower)" + + - name: keycloak_user_execute_actions_email — trigger execute-actions email + middleware_automation.keycloak.keycloak_user_execute_actions_email: + realm: "{{ target_realm }}" + username: "{{ user }}" + actions: + - VERIFY_EMAIL + register: execute_actions_email_result + failed_when: + - execute_actions_email_result is failed + - "'Connection refused' in (execute_actions_email_result.msg | default(''))" + - "'Failed to send' in (execute_actions_email_result.msg | default(''))" + + - name: keycloak_user_federation — remove non-existent federation (module API test) + middleware_automation.keycloak.keycloak_user_federation: + realm: "{{ target_realm }}" + name: "{{ federation }}" + state: absent + + - name: keycloak_component — remove non-existent component (module API test) + middleware_automation.keycloak.keycloak_component: + parent_id: "{{ target_realm }}" + name: "{{ component }}" + provider_id: ldap + provider_type: org.keycloak.storage.UserStorageProvider + state: absent + + - name: Remove shared test fixtures + module_defaults: + group/middleware_automation.keycloak.keycloak: + auth_keycloak_url: "{{ auth_keycloak_url }}" + auth_realm: "{{ auth_realm }}" + auth_username: "{{ auth_username }}" + auth_password: "{{ auth_password }}" + block: + - name: keycloak_authz_custom_policy — remove custom policy if created + middleware_automation.keycloak.keycloak_authz_custom_policy: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + name: molecule-script-policy + policy_type: script-policy.js + state: absent + failed_when: false + + - name: keycloak_authz_permission — remove scope permission + middleware_automation.keycloak.keycloak_authz_permission: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + name: "{{ authz_permission }}" + permission_type: scope + state: absent + + - name: keycloak_authz_authorization_scope — remove authorization scope + middleware_automation.keycloak.keycloak_authz_authorization_scope: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + name: "{{ authz_scope }}" + state: absent + + - name: keycloak_client_rolescope — remove role scope mapping + middleware_automation.keycloak.keycloak_client_rolescope: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + role_names: + - "{{ role }}" + state: absent + + - name: keycloak_client_rolemapping — remove group client role mapping + middleware_automation.keycloak.keycloak_client_rolemapping: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + group_name: "{{ group }}" + state: absent + roles: + - name: "{{ client_role }}" + + - name: keycloak_realm_rolemapping — remove group realm role mapping + middleware_automation.keycloak.keycloak_realm_rolemapping: + realm: "{{ target_realm }}" + group_name: "{{ group }}" + state: absent + roles: + - name: "{{ role }}" + + - name: keycloak_user_rolemapping — remove user role mapping + middleware_automation.keycloak.keycloak_user_rolemapping: + realm: "{{ target_realm }}" + target_username: "{{ user }}" + state: absent + roles: + - name: "{{ role }}" + + - name: keycloak_identity_provider — remove OIDC provider + middleware_automation.keycloak.keycloak_identity_provider: + realm: "{{ target_realm }}" + alias: "{{ idp }}" + state: absent + + - name: keycloak_authentication_v2 — remove flow + middleware_automation.keycloak.keycloak_authentication_v2: + realm: "{{ target_realm }}" + alias: "{{ flow_v2 }}" + state: absent + + - name: keycloak_authentication — remove copied flow + middleware_automation.keycloak.keycloak_authentication: + realm: "{{ target_realm }}" + alias: "{{ auth_copy }}" + state: absent + + - name: keycloak_realm_key — remove generated key + middleware_automation.keycloak.keycloak_realm_key: + parent_id: "{{ target_realm }}" + name: "{{ realm_key }}" + provider_id: aes-generated + state: absent + + - name: keycloak_realm_localization — remove locale override + middleware_automation.keycloak.keycloak_realm_localization: + parent_id: "{{ target_realm }}" + locale: en + state: absent + overrides: + - key: molecule.module.test + + - name: keycloak_realm — remove ephemeral realm + middleware_automation.keycloak.keycloak_realm: + id: "{{ ephemeral_realm }}" + realm: "{{ ephemeral_realm }}" + state: absent + + - name: keycloak_clienttemplate — remove client template + middleware_automation.keycloak.keycloak_clienttemplate: + realm: "{{ target_realm }}" + name: "{{ template }}" + state: absent + failed_when: false + + - name: keycloak_authentication_flow — remove authentication flow + middleware_automation.keycloak.keycloak_authentication_flow: + realm: "{{ target_realm }}" + alias: "{{ flow }}" + state: absent + + - name: keycloak_user — remove user + middleware_automation.keycloak.keycloak_user: + realm: "{{ target_realm }}" + username: "{{ user }}" + state: absent + + - name: keycloak_group — remove group + middleware_automation.keycloak.keycloak_group: + realm: "{{ target_realm }}" + name: "{{ group }}" + state: absent + + - name: keycloak_role — remove client role + middleware_automation.keycloak.keycloak_role: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + name: "{{ client_role }}" + state: absent + + - name: keycloak_client — remove confidential client + middleware_automation.keycloak.keycloak_client: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + state: absent + + - name: keycloak_client_scope — remove client scope + middleware_automation.keycloak.keycloak_client_scope: + realm: "{{ target_realm }}" + name: "{{ scope }}" + state: absent + + - name: keycloak_role — remove realm role + middleware_automation.keycloak.keycloak_role: + realm: "{{ target_realm }}" + name: "{{ role }}" + state: absent diff --git a/molecule/quarkus_ha_remote/converge.yml b/molecule/quarkus_ha_remote/converge.yml index d142a16..d2317db 100644 --- a/molecule/quarkus_ha_remote/converge.yml +++ b/molecule/quarkus_ha_remote/converge.yml @@ -25,6 +25,12 @@ hosts: keycloak vars_files: - ../group_vars/all/vars.yml + pre_tasks: + - name: Wait for Infinispan Hot Rod port before starting Keycloak + ansible.builtin.wait_for: + host: infinispan1 + port: 11222 + timeout: 120 vars: keycloak_quarkus_show_deprecation_warnings: false keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" @@ -41,7 +47,7 @@ keycloak_quarkus_ks_vault_file: "/opt/keycloak/vault/keystore.p12" keycloak_quarkus_ks_vault_pass: keystorepassword keycloak_quarkus_systemd_wait_for_port: true - keycloak_quarkus_systemd_wait_for_timeout: 20 + keycloak_quarkus_systemd_wait_for_timeout: 120 keycloak_quarkus_systemd_wait_for_delay: 2 keycloak_quarkus_systemd_wait_for_log: true keycloak_quarkus_ha_enabled: true diff --git a/plugins/doc_fragments/actiongroup_keycloak.py b/plugins/doc_fragments/actiongroup_keycloak.py new file mode 100644 index 0000000..575a027 --- /dev/null +++ b/plugins/doc_fragments/actiongroup_keycloak.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Eike Frost +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + DOCUMENTATION = r''' +options: {} +attributes: + action_group: + description: Use C(group/middleware_automation.keycloak.keycloak) in C(module_defaults) to set defaults for this module. + support: full + membership: + - middleware_automation.keycloak.keycloak +''' diff --git a/plugins/doc_fragments/keycloak.py b/plugins/doc_fragments/keycloak.py index 5d79fad..774629c 100644 --- a/plugins/doc_fragments/keycloak.py +++ b/plugins/doc_fragments/keycloak.py @@ -55,7 +55,11 @@ options: description: - Authentication token for Keycloak API. type: str - version_added: 3.0.0 + + refresh_token: + description: + - Authentication refresh token for Keycloak API. + type: str validate_certs: description: @@ -68,11 +72,9 @@ options: - Controls the HTTP connections timeout period (in seconds) to Keycloak API. type: int default: 10 - version_added: 4.5.0 http_agent: description: - Configures the HTTP User-Agent header. type: str default: Ansible - version_added: 5.4.0 ''' diff --git a/plugins/module_utils/identity/keycloak/_keycloak_utils.py b/plugins/module_utils/identity/keycloak/_keycloak_utils.py new file mode 100644 index 0000000..593ae1f --- /dev/null +++ b/plugins/module_utils/identity/keycloak/_keycloak_utils.py @@ -0,0 +1,32 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time. +# Do not use this from other collections or standalone plugins/modules! + +from __future__ import annotations + +import typing as t + + +def merge_settings_without_absent_nulls( + existing_settings: dict[str, t.Any], desired_settings: dict[str, t.Any] +) -> dict[str, t.Any]: + """ + Merges existing and desired settings into a new dictionary while excluding null values in desired settings that are absent in the existing settings. + This ensures idempotency by treating absent keys in existing settings and null values in desired settings as equivalent, preventing unnecessary updates. + + Args: + existing_settings (dict): Dictionary representing the current settings in Keycloak + desired_settings (dict): Dictionary representing the desired settings + + Returns: + dict: A new dictionary containing all entries from existing_settings and desired_settings, + excluding null values in desired_settings whose corresponding keys are not present in existing_settings + """ + + existing = existing_settings or {} + desired = desired_settings or {} + + return {**existing, **{k: v for k, v in desired.items() if v is not None or k in existing}} diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 128b0fe..601fb58 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -1,26 +1,34 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2017, Eike Frost # BSD 2-Clause license (see LICENSES/BSD-2-Clause.txt) # SPDX-License-Identifier: BSD-2-Clause -from __future__ import absolute_import, division, print_function - -__metaclass__ = type +from __future__ import annotations +import copy import json import traceback -import copy +import typing as t +from http import HTTPStatus +from urllib.error import HTTPError +from urllib.parse import quote, urlencode -from ansible.module_utils.urls import open_url -from ansible.module_utils.six.moves.urllib.parse import urlencode, quote -from ansible.module_utils.six.moves.urllib.error import HTTPError from ansible.module_utils.common.text.converters import to_native, to_text +from ansible.module_utils.urls import open_url + +if t.TYPE_CHECKING: + from collections.abc import Sequence + + from ansible.module_utils.basic import AnsibleModule + URL_REALM_INFO = "{url}/realms/{realm}" URL_REALMS = "{url}/admin/realms" URL_REALM = "{url}/admin/realms/{realm}" URL_REALM_KEYS_METADATA = "{url}/admin/realms/{realm}/keys" +URL_LOCALIZATIONS = "{url}/admin/realms/{realm}/localization/{locale}" +URL_LOCALIZATION = "{url}/admin/realms/{realm}/localization/{locale}/{key}" + URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" URL_CLIENTS = "{url}/admin/realms/{realm}/clients" @@ -65,8 +73,12 @@ URL_CLIENT_OPTIONAL_CLIENTSCOPES = "{url}/admin/realms/{realm}/clients/{cid}/opt URL_CLIENT_OPTIONAL_CLIENTSCOPE = "{url}/admin/realms/{realm}/clients/{cid}/optional-client-scopes/{id}" URL_CLIENT_GROUP_ROLEMAPPINGS = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}" -URL_CLIENT_GROUP_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/available" -URL_CLIENT_GROUP_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/composite" +URL_CLIENT_GROUP_ROLEMAPPINGS_AVAILABLE = ( + "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/available" +) +URL_CLIENT_GROUP_ROLEMAPPINGS_COMPOSITE = ( + "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/composite" +) URL_USERS = "{url}/admin/realms/{realm}/users" URL_USER = "{url}/admin/realms/{realm}/users/{id}" @@ -76,21 +88,29 @@ URL_USER_CLIENTS_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-map URL_USER_CLIENT_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client_id}" URL_USER_GROUPS = "{url}/admin/realms/{realm}/users/{id}/groups" URL_USER_GROUP = "{url}/admin/realms/{realm}/users/{id}/groups/{group_id}" +URL_EXECUTE_ACTION = "{url}/admin/realms/{realm}/users/{user_id}/execute-actions-email" URL_CLIENT_SERVICE_ACCOUNT_USER = "{url}/admin/realms/{realm}/clients/{id}/service-account-user" URL_CLIENT_USER_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}" -URL_CLIENT_USER_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/available" -URL_CLIENT_USER_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/composite" +URL_CLIENT_USER_ROLEMAPPINGS_AVAILABLE = ( + "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/available" +) +URL_CLIENT_USER_ROLEMAPPINGS_COMPOSITE = ( + "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/composite" +) URL_REALM_GROUP_ROLEMAPPINGS = "{url}/admin/realms/{realm}/groups/{group}/role-mappings/realm" URL_CLIENTSECRET = "{url}/admin/realms/{realm}/clients/{id}/client-secret" +URL_AUTHENTICATION_AUTHENTICATOR_PROVIDERS = "{url}/admin/realms/{realm}/authentication/authenticator-providers" URL_AUTHENTICATION_FLOWS = "{url}/admin/realms/{realm}/authentication/flows" URL_AUTHENTICATION_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{id}" URL_AUTHENTICATION_FLOW_COPY = "{url}/admin/realms/{realm}/authentication/flows/{copyfrom}/copy" URL_AUTHENTICATION_FLOW_EXECUTIONS = "{url}/admin/realms/{realm}/authentication/flows/{flowalias}/executions" -URL_AUTHENTICATION_FLOW_EXECUTIONS_EXECUTION = "{url}/admin/realms/{realm}/authentication/flows/{flowalias}/executions/execution" +URL_AUTHENTICATION_FLOW_EXECUTIONS_EXECUTION = ( + "{url}/admin/realms/{realm}/authentication/flows/{flowalias}/executions/execution" +) URL_AUTHENTICATION_FLOW_EXECUTIONS_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{flowalias}/executions/flow" URL_AUTHENTICATION_EXECUTION_CONFIG = "{url}/admin/realms/{realm}/authentication/executions/{id}/config" URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY = "{url}/admin/realms/{realm}/authentication/executions/{id}/raise-priority" @@ -104,6 +124,7 @@ URL_IDENTITY_PROVIDERS = "{url}/admin/realms/{realm}/identity-provider/instances URL_IDENTITY_PROVIDER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}" URL_IDENTITY_PROVIDER_MAPPERS = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers" URL_IDENTITY_PROVIDER_MAPPER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers/{id}" +URL_IDENTITY_PROVIDER_IMPORT = "{url}/admin/realms/{realm}/identity-provider/import-config" URL_COMPONENTS = "{url}/admin/realms/{realm}/components" URL_COMPONENT = "{url}/admin/realms/{realm}/components/{id}" @@ -117,8 +138,12 @@ URL_AUTHZ_AUTHORIZATION_SCOPES = "{url}/admin/realms/{realm}/clients/{client_id} URL_AUTHZ_POLICIES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/policy" URL_AUTHZ_POLICY = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/policy/{id}" -URL_AUTHZ_PERMISSION = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/permission/{permission_type}/{id}" -URL_AUTHZ_PERMISSIONS = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/permission/{permission_type}" +URL_AUTHZ_PERMISSION = ( + "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/permission/{permission_type}/{id}" +) +URL_AUTHZ_PERMISSIONS = ( + "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/permission/{permission_type}" +) URL_AUTHZ_RESOURCES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/resource" @@ -126,90 +151,187 @@ URL_AUTHZ_CUSTOM_POLICY = "{url}/admin/realms/{realm}/clients/{client_id}/authz/ URL_AUTHZ_CUSTOM_POLICIES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/policy" -def keycloak_argument_spec(): +def normalize_keycloak_url(url: str) -> str: + """Normalize Keycloak base URL for Admin REST API access. + + Keycloak 17+ (Quarkus) exposes the API at the server root without an /auth prefix. + WildFly-based Keycloak used /auth as the context path. Trailing slashes are removed. + """ + url = url.rstrip("/") + if url.endswith("/auth"): + return url[:-5] + return url + + +def keycloak_argument_spec() -> dict[str, t.Any]: """ Returns argument_spec of options common to keycloak_*-modules :return: argument_spec dict """ return dict( - auth_keycloak_url=dict(type='str', aliases=['url'], required=True, no_log=False), - auth_client_id=dict(type='str', default='admin-cli'), - auth_realm=dict(type='str'), - auth_client_secret=dict(type='str', default=None, no_log=True), - auth_username=dict(type='str', aliases=['username']), - auth_password=dict(type='str', aliases=['password'], no_log=True), - validate_certs=dict(type='bool', default=True), - connection_timeout=dict(type='int', default=10), - token=dict(type='str', no_log=True), - http_agent=dict(type='str', default='Ansible'), + auth_keycloak_url=dict(type="str", aliases=["url"], required=True, no_log=False), + auth_client_id=dict(type="str", default="admin-cli"), + auth_realm=dict(type="str"), + auth_client_secret=dict(type="str", default=None, no_log=True), + auth_username=dict(type="str", aliases=["username"]), + auth_password=dict(type="str", aliases=["password"], no_log=True), + validate_certs=dict(type="bool", default=True), + connection_timeout=dict(type="int", default=10), + token=dict(type="str", no_log=True), + refresh_token=dict(type="str", no_log=True), + http_agent=dict(type="str", default="Ansible"), ) -def camel(words): - return words.split('_')[0] + ''.join(x.capitalize() or '_' for x in words.split('_')[1:]) +def camel(words: str) -> str: + return words.split("_")[0] + "".join(x.capitalize() or "_" for x in words.split("_")[1:]) class KeycloakError(Exception): - pass + def __init__(self, msg: str, authError: Exception | None = None) -> None: + self.msg = msg + self.authError = authError + + def __str__(self) -> str: + return str(self.msg) -def get_token(module_params): - """ Obtains connection header with token for the authentication, - token already given or obtained from credentials - :param module_params: parameters of the module - :return: connection header +def _token_request(module_params: dict[str, t.Any], payload: dict[str, t.Any]) -> str: + """Obtains connection header with token for the authentication, + using the provided auth_username/auth_password + :param module_params: parameters of the module + :param payload: + type: + dict + description: + Authentication request payload. Must contain at least + 'grant_type' and 'client_id', optionally 'client_secret', + along with parameters based on 'grant_type'; e.g., + 'username'/'password' for type 'password', + 'refresh_token' for type 'refresh_token'. + :return: access token """ - token = module_params.get('token') - base_url = module_params.get('auth_keycloak_url') - http_agent = module_params.get('http_agent') + base_url = normalize_keycloak_url(module_params["auth_keycloak_url"]) + if not base_url.lower().startswith(("http", "https")): + raise KeycloakError(f"auth_url '{base_url}' should either start with 'http' or 'https'.") + auth_realm = module_params.get("auth_realm") + auth_url = URL_TOKEN.format(url=base_url, realm=auth_realm) + http_agent = module_params.get("http_agent") + validate_certs = module_params.get("validate_certs") + connection_timeout = module_params.get("connection_timeout") - if not base_url.lower().startswith(('http', 'https')): - raise KeycloakError("auth_url '%s' should either start with 'http' or 'https'." % base_url) + try: + r = json.loads( + open_url( + auth_url, + method="POST", + validate_certs=validate_certs, + http_agent=http_agent, + timeout=connection_timeout, + data=urlencode(payload), + ).read() + ) + + return r["access_token"] + except ValueError as e: + raise KeycloakError(f"API returned invalid JSON when trying to obtain access token from {auth_url}: {e}") from e + except KeyError as e: + raise KeycloakError(f"API did not include access_token field in response from {auth_url}") from e + except Exception as e: + raise KeycloakError(f"Could not obtain access token from {auth_url}: {e}", authError=e) from e + + +def _request_token_using_credentials(module_params: dict[str, t.Any]) -> str: + """Obtains connection header with token for the authentication, + using the provided auth_username/auth_password + :param module_params: parameters of the module. Must include 'auth_username' and 'auth_password'. + :return: connection header + """ + client_id = module_params.get("auth_client_id") + auth_username = module_params.get("auth_username") + auth_password = module_params.get("auth_password") + client_secret = module_params.get("auth_client_secret") + + temp_payload = { + "grant_type": "password", + "client_id": client_id, + "client_secret": client_secret, + "username": auth_username, + "password": auth_password, + } + # Remove empty items, for instance missing client_secret + payload = {k: v for k, v in temp_payload.items() if v is not None} + + return _token_request(module_params, payload) + + +def _request_token_using_refresh_token(module_params: dict[str, t.Any]) -> str: + """Obtains connection header with token for the authentication, + using the provided refresh_token + :param module_params: parameters of the module. Must include 'refresh_token'. + :return: connection header + """ + client_id = module_params.get("auth_client_id") + refresh_token = module_params.get("refresh_token") + client_secret = module_params.get("auth_client_secret") + + temp_payload = { + "grant_type": "refresh_token", + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + } + # Remove empty items, for instance missing client_secret + payload = {k: v for k, v in temp_payload.items() if v is not None} + + return _token_request(module_params, payload) + + +def _request_token_using_client_credentials(module_params: dict[str, t.Any]) -> str: + """Obtains connection header with token for the authentication, + using the provided auth_client_id and auth_client_secret by grant_type + client_credentials. Ensure that the used client uses client authorization + with service account roles enabled and required service roles assigned. + :param module_params: parameters of the module. Must include 'auth_client_id' + and 'auth_client_secret'.. + :return: connection header + """ + client_id = module_params.get("auth_client_id") + client_secret = module_params.get("auth_client_secret") + + temp_payload = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + } + # Remove empty items, for instance missing client_secret + payload = {k: v for k, v in temp_payload.items() if v is not None} + + return _token_request(module_params, payload) + + +def get_token(module_params: dict[str, t.Any]) -> dict[str, str]: + """Obtains connection header with token for the authentication, + token already given or obtained from credentials + :param module_params: parameters of the module + :return: connection header + """ + token = module_params.get("token") if token is None: - base_url = module_params.get('auth_keycloak_url') - validate_certs = module_params.get('validate_certs') - auth_realm = module_params.get('auth_realm') - client_id = module_params.get('auth_client_id') - auth_username = module_params.get('auth_username') - auth_password = module_params.get('auth_password') - client_secret = module_params.get('auth_client_secret') - connection_timeout = module_params.get('connection_timeout') - auth_url = URL_TOKEN.format(url=base_url, realm=auth_realm) - temp_payload = { - 'grant_type': 'password', - 'client_id': client_id, - 'client_secret': client_secret, - 'username': auth_username, - 'password': auth_password, - } - # Remove empty items, for instance missing client_secret - payload = {k: v for k, v in temp_payload.items() if v is not None} - try: - r = json.loads(to_native(open_url(auth_url, method='POST', - validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout, - data=urlencode(payload)).read())) - except ValueError as e: - raise KeycloakError( - 'API returned invalid JSON when trying to obtain access token from %s: %s' - % (auth_url, str(e))) - except Exception as e: - raise KeycloakError('Could not obtain access token from %s: %s' - % (auth_url, str(e))) + auth_client_id = module_params.get("auth_client_id") + auth_client_secret = module_params.get("auth_client_secret") + auth_username = module_params.get("auth_username") + if auth_client_id is not None and auth_client_secret is not None and auth_username is None: + token = _request_token_using_client_credentials(module_params) + else: + token = _request_token_using_credentials(module_params) - try: - token = r['access_token'] - except KeyError: - raise KeycloakError( - 'Could not obtain access token from %s' % auth_url) - return { - 'Authorization': 'Bearer ' + token, - 'Content-Type': 'application/json' - } + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} -def is_struct_included(struct1, struct2, exclude=None): +def is_struct_included(struct1: object, struct2: object, exclude: Sequence[str] | None = None) -> bool: """ This function compare if the first parameter structure is included in the second. The function use every elements of struct1 and validates they are present in the struct2 structure. @@ -265,23 +387,119 @@ def is_struct_included(struct1, struct2, exclude=None): elif isinstance(struct1, bool) and isinstance(struct2, bool): return struct1 == struct2 else: - return to_text(struct1, 'utf-8') == to_text(struct2, 'utf-8') + return to_text(struct1, "utf-8") == to_text(struct2, "utf-8") -class KeycloakAPI(object): - """ Keycloak API access; Keycloak uses OAuth 2.0 to protect its API, an access token for which - is obtained through OpenID connect +class KeycloakAPI: + """Keycloak API access; Keycloak uses OAuth 2.0 to protect its API, an access token for which + is obtained through OpenID connect """ - def __init__(self, module, connection_header): - self.module = module - self.baseurl = self.module.params.get('auth_keycloak_url') - self.validate_certs = self.module.params.get('validate_certs') - self.connection_timeout = self.module.params.get('connection_timeout') - self.restheaders = connection_header - self.http_agent = self.module.params.get('http_agent') - def get_realm_info_by_id(self, realm='master'): - """ Obtain realm public info by id + def __init__(self, module: AnsibleModule, connection_header: dict[str, str]) -> None: + self.module = module + self.baseurl = normalize_keycloak_url(self.module.params.get("auth_keycloak_url")) + self.validate_certs = self.module.params.get("validate_certs") + self.connection_timeout = self.module.params.get("connection_timeout") + self.restheaders = connection_header + self.http_agent = self.module.params.get("http_agent") + + def _request( + self, url: str, method: str, data: str | bytes | None = None, *, extra_headers: dict[str, str] | None = None + ): + """Makes a request to Keycloak and returns the raw response. + If a 401 is returned, attempts to re-authenticate + using first the module's refresh_token (if provided) + and then the module's username/password (if provided). + On successful re-authentication, the new token is stored + in the restheaders for future requests. + + :param url: request path + :param method: request method (e.g., 'GET', 'POST', etc.) + :param data: (optional) data for request + :param extra_headers headers to be sent with request, defaults to self.restheaders + :return: raw API response + """ + + def make_request_catching_401(headers: dict[str, str]) -> object | HTTPError: + try: + return open_url( + url, + method=method, + data=data, + http_agent=self.http_agent, + headers=headers, + timeout=self.connection_timeout, + validate_certs=self.validate_certs, + ) + except HTTPError as e: + if e.code != HTTPStatus.UNAUTHORIZED: + raise e + return e + + headers = self.restheaders + if extra_headers is not None: + headers = headers.copy() + headers.update(extra_headers) + + r = make_request_catching_401(headers) + + if isinstance(r, Exception): + # Try to refresh token and retry, if available + refresh_token = self.module.params.get("refresh_token") + if refresh_token is not None: + try: + token = _request_token_using_refresh_token(self.module.params) + self.restheaders["Authorization"] = f"Bearer {token}" + + r = make_request_catching_401(headers) + except KeycloakError as e: + # Token refresh returns 400 if token is expired/invalid, so continue on if we get a 400 + if e.authError is not None and e.authError.code != HTTPStatus.BAD_REQUEST: # type: ignore # TODO! + raise e + + if isinstance(r, Exception): + # Try to re-auth with username/password, if available + auth_username = self.module.params.get("auth_username") + auth_password = self.module.params.get("auth_password") + if auth_username is not None and auth_password is not None: + token = _request_token_using_credentials(self.module.params) + self.restheaders["Authorization"] = f"Bearer {token}" + + r = make_request_catching_401(headers) + + if isinstance(r, Exception): + # Try to re-auth with client_id and client_secret, if available + auth_client_id = self.module.params.get("auth_client_id") + auth_client_secret = self.module.params.get("auth_client_secret") + if auth_client_id is not None and auth_client_secret is not None: + try: + token = _request_token_using_client_credentials(self.module.params) + self.restheaders["Authorization"] = f"Bearer {token}" + + r = make_request_catching_401(headers) + except KeycloakError as e: + # Token refresh returns 400 if token is expired/invalid, so continue on if we get a 400 + if e.authError is not None and e.authError.code != HTTPStatus.BAD_REQUEST: # type: ignore # TODO! + raise e + + if isinstance(r, Exception): + # Either no re-auth options were available, or they all failed + raise r + + return r + + def _request_and_deserialize(self, url: str, method: str, data: str | bytes | None = None): + """Wraps the _request method with JSON deserialization of the response. + + :param url: request path + :param method: request method (e.g., 'GET', 'POST', etc.) + :param data: (optional) data for request + :return: raw API response + """ + return json.loads(self._request(url, method, data).read()) + + def get_realm_info_by_id(self, realm: str = "master") -> dict[str, t.Any] | None: + """Obtain realm public info by id :param realm: realm id :return: dict of real, representation or None if none matching exist @@ -289,24 +507,22 @@ class KeycloakAPI(object): realm_info_url = URL_REALM_INFO.format(url=self.baseurl, realm=realm) try: - return json.loads(to_native(open_url(realm_info_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(realm_info_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.fail_request(e, msg=f"Could not obtain realm {realm}: {e}", exception=traceback.format_exc()) except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain realm {realm}: {e}", + exception=traceback.format_exc(), + ) except Exception as e: - self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.module.fail_json(msg=f"Could not obtain realm {realm}: {e}", exception=traceback.format_exc()) - def get_realm_keys_metadata_by_id(self, realm='master'): + def get_realm_keys_metadata_by_id(self, realm: str = "master") -> dict[str, t.Any] | None: """Obtain realm public info by id :param realm: realm id @@ -320,25 +536,25 @@ class KeycloakAPI(object): realm_keys_metadata_url = URL_REALM_KEYS_METADATA.format(url=self.baseurl, realm=realm) try: - return json.loads(to_native(open_url(realm_keys_metadata_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(realm_keys_metadata_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.fail_request(e, msg=f"Could not obtain realm {realm}: {e}", exception=traceback.format_exc()) except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain realm {realm}: {e}", + exception=traceback.format_exc(), + ) except Exception as e: - self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.module.fail_json(msg=f"Could not obtain realm {realm}: {e}", exception=traceback.format_exc()) - def get_realm_by_id(self, realm='master'): - """ Obtain realm representation by id + # The Keycloak API expects the realm name (like `master`) not the ID when fetching the realm data. + # See the Keycloak API docs: https://www.keycloak.org/docs-api/latest/rest-api/#_realms_admin + def get_realm_by_id(self, realm: str = "master") -> dict[str, t.Any] | None: + """Obtain realm representation by id :param realm: realm id :return: dict of real, representation or None if none matching exist @@ -346,24 +562,23 @@ class KeycloakAPI(object): realm_url = URL_REALM.format(url=self.baseurl, realm=realm) try: - return json.loads(to_native(open_url(realm_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(realm_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.fail_request(e, msg=f"Could not obtain realm {realm}: {e}", exception=traceback.format_exc()) except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain realm {realm}: {e}", + exception=traceback.format_exc(), + ) except Exception as e: - self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.module.fail_json(msg=f"Could not obtain realm {realm}: {e}", exception=traceback.format_exc()) - def update_realm(self, realmrep, realm="master"): - """ Update an existing realm + def update_realm(self, realmrep, realm: str = "master"): + """Update an existing realm :param realmrep: corresponding (partial/full) realm representation with updates :param realm: realm to be updated in Keycloak :return: HTTPResponse object on success @@ -371,28 +586,24 @@ class KeycloakAPI(object): realm_url = URL_REALM.format(url=self.baseurl, realm=realm) try: - return open_url(realm_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(realmrep), validate_certs=self.validate_certs) + return self._request(realm_url, method="PUT", data=json.dumps(realmrep)) except Exception as e: - self.fail_open_url(e, msg='Could not update realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.fail_request(e, msg=f"Could not update realm {realm}: {e}", exception=traceback.format_exc()) def create_realm(self, realmrep): - """ Create a realm in keycloak + """Create a realm in keycloak :param realmrep: Realm representation of realm to be created. :return: HTTPResponse object on success """ realm_url = URL_REALMS.format(url=self.baseurl) try: - return open_url(realm_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(realmrep), validate_certs=self.validate_certs) + return self._request(realm_url, method="POST", data=json.dumps(realmrep)) except Exception as e: - self.fail_open_url(e, msg='Could not create realm %s: %s' % (realmrep['id'], str(e)), - exception=traceback.format_exc()) + self.fail_request(e, msg=f"Could not create realm {realmrep['id']}: {e}", exception=traceback.format_exc()) - def delete_realm(self, realm="master"): - """ Delete a realm from Keycloak + def delete_realm(self, realm: str = "master"): + """Delete a realm from Keycloak :param realm: realm to be deleted :return: HTTPResponse object on success @@ -400,14 +611,84 @@ class KeycloakAPI(object): realm_url = URL_REALM.format(url=self.baseurl, realm=realm) try: - return open_url(realm_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(realm_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Could not delete realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.fail_request(e, msg=f"Could not delete realm {realm}: {e}", exception=traceback.format_exc()) - def get_clients(self, realm='master', filter=None): - """ Obtains client representations for clients in a realm + def get_localization_values(self, locale: str, realm: str = "master") -> dict[str, str]: + """ + Get all localization overrides for a given realm and locale. + + :param locale: Locale code (for example, 'en', 'fi', 'de'). + :param realm: Realm name. Defaults to 'master'. + + :return: Mapping of localization keys to override values. + + :raise KeycloakError: Wrapped HTTP/JSON error with context + """ + realm_url = URL_LOCALIZATIONS.format(url=self.baseurl, realm=realm, locale=locale) + + try: + return self._request_and_deserialize(realm_url, method="GET") + except Exception as e: + self.fail_request( + e, + msg=f"Could not read localization overrides for realm {realm}, locale {locale}: {e}", + exception=traceback.format_exc(), + ) + + def set_localization_value(self, locale: str, key: str, value: str, realm: str = "master"): + """ + Create or update a single localization override for the given key. + + :param locale: Locale code (for example, 'en'). + :param key: Localization message key to set. + :param value: Override value to set. + :param realm: Realm name. Defaults to 'master'. + + :return: HTTPResponse: Response object on success. + + :raise KeycloakError: Wrapped HTTP error with context + """ + realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) + + headers = {} + headers["Content-Type"] = "text/plain; charset=utf-8" + + try: + return self._request(realm_url, method="PUT", data=to_native(value), extra_headers=headers) + except Exception as e: + self.fail_request( + e, + msg=f"Could not set localization value in realm {realm}, locale {locale}: {key}={value}: {e}", + exception=traceback.format_exc(), + ) + + def delete_localization_value(self, locale: str, key: str, realm: str = "master"): + """ + Delete a single localization override key for the given locale. + + :param locale: Locale code (for example, 'en'). + :param key: Localization message key to delete. + :param realm: Realm name. Defaults to 'master'. + + :return: HTTPResponse: Response object on success. + + :raise KeycloakError: Wrapped HTTP error with context + """ + realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) + + try: + return self._request(realm_url, method="DELETE") + except Exception as e: + self.fail_request( + e, + msg=f"Could not delete localization value in realm {realm}, locale {locale}, key {key}: {e}", + exception=traceback.format_exc(), + ) + + def get_clients(self, realm: str = "master", filter=None): + """Obtains client representations for clients in a realm :param realm: realm to be queried :param filter: if defined, only the client with clientId specified in the filter is returned @@ -415,21 +696,19 @@ class KeycloakAPI(object): """ clientlist_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) if filter is not None: - clientlist_url += '?clientId=%s' % filter + clientlist_url += f"?clientId={filter}" try: - return json.loads(to_native(open_url(clientlist_url, http_agent=self.http_agent, method='GET', headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(clientlist_url, method="GET") except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' - % (realm, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain list of clients for realm {realm}: {e}" + ) except Exception as e: - self.fail_open_url(e, msg='Could not obtain list of clients for realm %s: %s' - % (realm, str(e))) + self.fail_request(e, msg=f"Could not obtain list of clients for realm {realm}: {e}") - def get_client_by_clientid(self, client_id, realm='master'): - """ Get client representation by clientId + def get_client_by_clientid(self, client_id, realm: str = "master"): + """Get client representation by clientId :param client_id: The clientId to be queried :param realm: realm from which to obtain the client representation :return: dict with a client representation or None if none matching exist @@ -440,8 +719,8 @@ class KeycloakAPI(object): else: return None - def get_client_by_id(self, id, realm='master'): - """ Obtain client representation by id + def get_client_by_id(self, id, realm: str = "master"): + """Obtain client representation by id :param id: id (not clientId) of client to be queried :param realm: client from this realm @@ -450,38 +729,35 @@ class KeycloakAPI(object): client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) try: - return json.loads(to_native(open_url(client_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(client_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not obtain client %s for realm %s: %s' - % (id, realm, str(e))) + self.fail_request(e, msg=f"Could not obtain client {id} for realm {realm}: {e}") except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client %s for realm %s: %s' - % (id, realm, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain client {id} for realm {realm}: {e}" + ) except Exception as e: - self.module.fail_json(msg='Could not obtain client %s for realm %s: %s' - % (id, realm, str(e))) + self.module.fail_json(msg=f"Could not obtain client {id} for realm {realm}: {e}") - def get_client_id(self, client_id, realm='master'): - """ Obtain id of client by client_id + def get_client_id(self, client_id, realm: str = "master"): + """Obtain id of client by client_id :param client_id: client_id of client to be queried :param realm: client template from this realm :return: id of client (usually a UUID) """ result = self.get_client_by_clientid(client_id, realm) - if isinstance(result, dict) and 'id' in result: - return result['id'] + if isinstance(result, dict) and "id" in result: + return result["id"] else: return None - def update_client(self, id, clientrep, realm="master"): - """ Update an existing client + def update_client(self, id, clientrep, realm: str = "master"): + """Update an existing client :param id: id (not clientId) of client to be updated in Keycloak :param clientrep: corresponding (partial/full) client representation with updates :param realm: realm the client is in @@ -490,14 +766,12 @@ class KeycloakAPI(object): client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(client_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(clientrep), validate_certs=self.validate_certs) + return self._request(client_url, method="PUT", data=json.dumps(clientrep)) except Exception as e: - self.fail_open_url(e, msg='Could not update client %s in realm %s: %s' - % (id, realm, str(e))) + self.fail_request(e, msg=f"Could not update client {id} in realm {realm}: {e}") - def create_client(self, clientrep, realm="master"): - """ Create a client in keycloak + def create_client(self, clientrep, realm: str = "master"): + """Create a client in keycloak :param clientrep: Client representation of client to be created. Must at least contain field clientId. :param realm: realm for client to be created. :return: HTTPResponse object on success @@ -505,14 +779,12 @@ class KeycloakAPI(object): client_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) try: - return open_url(client_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(clientrep), validate_certs=self.validate_certs) + return self._request(client_url, method="POST", data=json.dumps(clientrep)) except Exception as e: - self.fail_open_url(e, msg='Could not create client %s in realm %s: %s' - % (clientrep['clientId'], realm, str(e))) + self.fail_request(e, msg=f"Could not create client {clientrep['clientId']} in realm {realm}: {e}") - def delete_client(self, id, realm="master"): - """ Delete a client from Keycloak + def delete_client(self, id, realm: str = "master"): + """Delete a client from Keycloak :param id: id (not clientId) of client to be deleted :param realm: realm of client to be deleted @@ -521,14 +793,12 @@ class KeycloakAPI(object): client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(client_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(client_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Could not delete client %s in realm %s: %s' - % (id, realm, str(e))) + self.fail_request(e, msg=f"Could not delete client {id} in realm {realm}: {e}") - def get_client_roles_by_id(self, cid, realm="master"): - """ Fetch the roles of the a client on the Keycloak server. + def get_client_roles_by_id(self, cid, realm: str = "master"): + """Fetch the roles of the a client on the Keycloak server. :param cid: ID of the client from which to obtain the rolemappings. :param realm: Realm from which to obtain the rolemappings. @@ -536,15 +806,12 @@ class KeycloakAPI(object): """ client_roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) try: - return json.loads(to_native(open_url(client_roles_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(client_roles_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch rolemappings for client %s in realm %s: %s" - % (cid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch rolemappings for client {cid} in realm {realm}: {e}") - def get_client_role_id_by_name(self, cid, name, realm="master"): - """ Get the role ID of a client. + def get_client_role_id_by_name(self, cid, name, realm: str = "master"): + """Get the role ID of a client. :param cid: ID of the client from which to obtain the rolemappings. :param name: Name of the role. @@ -553,12 +820,12 @@ class KeycloakAPI(object): """ rolemappings = self.get_client_roles_by_id(cid, realm=realm) for role in rolemappings: - if name == role['name']: - return role['id'] + if name == role["name"]: + return role["id"] return None - def get_client_group_rolemapping_by_id(self, gid, cid, rid, realm='master'): - """ Obtain client representation by id + def get_client_group_rolemapping_by_id(self, gid, cid, rid, realm: str = "master"): + """Obtain client representation by id :param gid: ID of the group from which to obtain the rolemappings. :param cid: ID of the client from which to obtain the rolemappings. @@ -568,53 +835,54 @@ class KeycloakAPI(object): """ rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) try: - rolemappings = json.loads(to_native(open_url(rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + rolemappings = self._request_and_deserialize(rolemappings_url, method="GET") for role in rolemappings: - if rid == role['id']: + if rid == role["id"]: return role except Exception as e: - self.fail_open_url(e, msg="Could not fetch rolemappings for client %s in group %s, realm %s: %s" - % (cid, gid, realm, str(e))) + self.fail_request( + e, msg=f"Could not fetch rolemappings for client {cid} in group {gid}, realm {realm}: {e}" + ) return None - def get_client_group_available_rolemappings(self, gid, cid, realm="master"): - """ Fetch the available role of a client in a specified group on the Keycloak server. + def get_client_group_available_rolemappings(self, gid, cid, realm: str = "master"): + """Fetch the available role of a client in a specified group on the Keycloak server. :param gid: ID of the group from which to obtain the rolemappings. :param cid: ID of the client from which to obtain the rolemappings. :param realm: Realm from which to obtain the rolemappings. :return: The rollemappings of specified group and client of the realm (default "master"). """ - available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=gid, client=cid) + available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS_AVAILABLE.format( + url=self.baseurl, realm=realm, id=gid, client=cid + ) try: - return json.loads(to_native(open_url(available_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(available_rolemappings_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" - % (cid, gid, realm, str(e))) + self.fail_request( + e, msg=f"Could not fetch available rolemappings for client {cid} in group {gid}, realm {realm}: {e}" + ) - def get_client_group_composite_rolemappings(self, gid, cid, realm="master"): - """ Fetch the composite role of a client in a specified group on the Keycloak server. + def get_client_group_composite_rolemappings(self, gid, cid, realm: str = "master"): + """Fetch the composite role of a client in a specified group on the Keycloak server. :param gid: ID of the group from which to obtain the rolemappings. :param cid: ID of the client from which to obtain the rolemappings. :param realm: Realm from which to obtain the rolemappings. :return: The rollemappings of specified group and client of the realm (default "master"). """ - composite_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=gid, client=cid) + composite_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS_COMPOSITE.format( + url=self.baseurl, realm=realm, id=gid, client=cid + ) try: - return json.loads(to_native(open_url(composite_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(composite_rolemappings_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" - % (cid, gid, realm, str(e))) + self.fail_request( + e, msg=f"Could not fetch available rolemappings for client {cid} in group {gid}, realm {realm}: {e}" + ) - def get_role_by_id(self, rid, realm="master"): - """ Fetch a role by its id on the Keycloak server. + def get_role_by_id(self, rid, realm: str = "master"): + """Fetch a role by its id on the Keycloak server. :param rid: ID of the role. :param realm: Realm from which to obtain the rolemappings. @@ -622,15 +890,12 @@ class KeycloakAPI(object): """ client_roles_url = URL_ROLES_BY_ID.format(url=self.baseurl, realm=realm, id=rid) try: - return json.loads(to_native(open_url(client_roles_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(client_roles_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch role for id %s in realm %s: %s" - % (rid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch role for id {rid} in realm {realm}: {e}") - def get_client_roles_by_id_composite_rolemappings(self, rid, cid, realm="master"): - """ Fetch a role by its id on the Keycloak server. + def get_client_roles_by_id_composite_rolemappings(self, rid, cid, realm: str = "master"): + """Fetch a role by its id on the Keycloak server. :param rid: ID of the composite role. :param cid: ID of the client from which to obtain the rolemappings. @@ -639,15 +904,12 @@ class KeycloakAPI(object): """ client_roles_url = URL_ROLES_BY_ID_COMPOSITES_CLIENTS.format(url=self.baseurl, realm=realm, id=rid, cid=cid) try: - return json.loads(to_native(open_url(client_roles_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(client_roles_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch role for id %s and cid %s in realm %s: %s" - % (rid, cid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch role for id {rid} and cid {cid} in realm {realm}: {e}") - def add_client_roles_by_id_composite_rolemapping(self, rid, roles_rep, realm="master"): - """ Assign roles to composite role + def add_client_roles_by_id_composite_rolemapping(self, rid, roles_rep, realm: str = "master"): + """Assign roles to composite role :param rid: ID of the composite role. :param roles_rep: Representation of the roles to assign. @@ -656,14 +918,12 @@ class KeycloakAPI(object): """ available_rolemappings_url = URL_ROLES_BY_ID_COMPOSITES.format(url=self.baseurl, realm=realm, id=rid) try: - open_url(available_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(roles_rep), - validate_certs=self.validate_certs, timeout=self.connection_timeout) + self._request(available_rolemappings_url, method="POST", data=json.dumps(roles_rep)) except Exception as e: - self.fail_open_url(e, msg="Could not assign roles to composite role %s and realm %s: %s" - % (rid, realm, str(e))) + self.fail_request(e, msg=f"Could not assign roles to composite role {rid} and realm {realm}: {e}") - def add_group_realm_rolemapping(self, gid, role_rep, realm="master"): - """ Add the specified realm role to specified group on the Keycloak server. + def add_group_realm_rolemapping(self, gid, role_rep, realm: str = "master"): + """Add the specified realm role to specified group on the Keycloak server. :param gid: ID of the group to add the role mapping. :param role_rep: Representation of the role to assign. @@ -672,14 +932,12 @@ class KeycloakAPI(object): """ url = URL_REALM_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, group=gid) try: - open_url(url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), - validate_certs=self.validate_certs, timeout=self.connection_timeout) + self._request(url, method="POST", data=json.dumps(role_rep)) except Exception as e: - self.fail_open_url(e, msg="Could add realm role mappings for group %s, realm %s: %s" - % (gid, realm, str(e))) + self.fail_request(e, msg=f"Could add realm role mappings for group {gid}, realm {realm}: {e}") - def delete_group_realm_rolemapping(self, gid, role_rep, realm="master"): - """ Delete the specified realm role from the specified group on the Keycloak server. + def delete_group_realm_rolemapping(self, gid, role_rep, realm: str = "master"): + """Delete the specified realm role from the specified group on the Keycloak server. :param gid: ID of the group from which to obtain the rolemappings. :param role_rep: Representation of the role to assign. @@ -688,14 +946,12 @@ class KeycloakAPI(object): """ url = URL_REALM_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, group=gid) try: - open_url(url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), - validate_certs=self.validate_certs, timeout=self.connection_timeout) + self._request(url, method="DELETE", data=json.dumps(role_rep)) except Exception as e: - self.fail_open_url(e, msg="Could not delete realm role mappings for group %s, realm %s: %s" - % (gid, realm, str(e))) + self.fail_request(e, msg=f"Could not delete realm role mappings for group {gid}, realm {realm}: {e}") - def add_group_rolemapping(self, gid, cid, role_rep, realm="master"): - """ Fetch the composite role of a client in a specified group on the Keycloak server. + def add_group_rolemapping(self, gid, cid, role_rep, realm: str = "master"): + """Fetch the composite role of a client in a specified group on the Keycloak server. :param gid: ID of the group from which to obtain the rolemappings. :param cid: ID of the client from which to obtain the rolemappings. @@ -703,16 +959,18 @@ class KeycloakAPI(object): :param realm: Realm from which to obtain the rolemappings. :return: None. """ - available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format( + url=self.baseurl, realm=realm, id=gid, client=cid + ) try: - open_url(available_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), - validate_certs=self.validate_certs, timeout=self.connection_timeout) + self._request(available_rolemappings_url, method="POST", data=json.dumps(role_rep)) except Exception as e: - self.fail_open_url(e, msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" - % (cid, gid, realm, str(e))) + self.fail_request( + e, msg=f"Could not fetch available rolemappings for client {cid} in group {gid}, realm {realm}: {e}" + ) - def delete_group_rolemapping(self, gid, cid, role_rep, realm="master"): - """ Delete the rolemapping of a client in a specified group on the Keycloak server. + def delete_group_rolemapping(self, gid, cid, role_rep, realm: str = "master"): + """Delete the rolemapping of a client in a specified group on the Keycloak server. :param gid: ID of the group from which to obtain the rolemappings. :param cid: ID of the client from which to obtain the rolemappings. @@ -720,16 +978,18 @@ class KeycloakAPI(object): :param realm: Realm from which to obtain the rolemappings. :return: None. """ - available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format( + url=self.baseurl, realm=realm, id=gid, client=cid + ) try: - open_url(available_rolemappings_url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), - validate_certs=self.validate_certs, timeout=self.connection_timeout) + self._request(available_rolemappings_url, method="DELETE", data=json.dumps(role_rep)) except Exception as e: - self.fail_open_url(e, msg="Could not delete available rolemappings for client %s in group %s, realm %s: %s" - % (cid, gid, realm, str(e))) + self.fail_request( + e, msg=f"Could not delete available rolemappings for client {cid} in group {gid}, realm {realm}: {e}" + ) - def get_client_user_rolemapping_by_id(self, uid, cid, rid, realm='master'): - """ Obtain client representation by id + def get_client_user_rolemapping_by_id(self, uid, cid, rid, realm: str = "master"): + """Obtain client representation by id :param uid: ID of the user from which to obtain the rolemappings. :param cid: ID of the client from which to obtain the rolemappings. @@ -739,53 +999,52 @@ class KeycloakAPI(object): """ rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid, client=cid) try: - rolemappings = json.loads(to_native(open_url(rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + rolemappings = self._request_and_deserialize(rolemappings_url, method="GET") for role in rolemappings: - if rid == role['id']: + if rid == role["id"]: return role except Exception as e: - self.fail_open_url(e, msg="Could not fetch rolemappings for client %s and user %s, realm %s: %s" - % (cid, uid, realm, str(e))) + self.fail_request( + e, msg=f"Could not fetch rolemappings for client {cid} and user {uid}, realm {realm}: {e}" + ) return None - def get_client_user_available_rolemappings(self, uid, cid, realm="master"): - """ Fetch the available role of a client for a specified user on the Keycloak server. + def get_client_user_available_rolemappings(self, uid, cid, realm: str = "master"): + """Fetch the available role of a client for a specified user on the Keycloak server. :param uid: ID of the user from which to obtain the rolemappings. :param cid: ID of the client from which to obtain the rolemappings. :param realm: Realm from which to obtain the rolemappings. :return: The effective rollemappings of specified client and user of the realm (default "master"). """ - available_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=uid, client=cid) + available_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS_AVAILABLE.format( + url=self.baseurl, realm=realm, id=uid, client=cid + ) try: - return json.loads(to_native(open_url(available_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(available_rolemappings_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch effective rolemappings for client %s and user %s, realm %s: %s" - % (cid, uid, realm, str(e))) + self.fail_request( + e, msg=f"Could not fetch effective rolemappings for client {cid} and user {uid}, realm {realm}: {e}" + ) - def get_client_user_composite_rolemappings(self, uid, cid, realm="master"): - """ Fetch the composite role of a client for a specified user on the Keycloak server. + def get_client_user_composite_rolemappings(self, uid, cid, realm: str = "master"): + """Fetch the composite role of a client for a specified user on the Keycloak server. :param uid: ID of the user from which to obtain the rolemappings. :param cid: ID of the client from which to obtain the rolemappings. :param realm: Realm from which to obtain the rolemappings. :return: The rollemappings of specified group and client of the realm (default "master"). """ - composite_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=uid, client=cid) + composite_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS_COMPOSITE.format( + url=self.baseurl, realm=realm, id=uid, client=cid + ) try: - return json.loads(to_native(open_url(composite_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(composite_rolemappings_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch available rolemappings for user %s of realm %s: %s" - % (uid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch available rolemappings for user {uid} of realm {realm}: {e}") - def get_realm_user_rolemapping_by_id(self, uid, rid, realm='master'): - """ Obtain role representation by id + def get_realm_user_rolemapping_by_id(self, uid, rid, realm: str = "master"): + """Obtain role representation by id :param uid: ID of the user from which to obtain the rolemappings. :param rid: ID of the role. @@ -794,19 +1053,16 @@ class KeycloakAPI(object): """ rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) try: - rolemappings = json.loads(to_native(open_url(rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + rolemappings = self._request_and_deserialize(rolemappings_url, method="GET") for role in rolemappings: - if rid == role['id']: + if rid == role["id"]: return role except Exception as e: - self.fail_open_url(e, msg="Could not fetch rolemappings for user %s, realm %s: %s" - % (uid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch rolemappings for user {uid}, realm {realm}: {e}") return None - def get_realm_user_available_rolemappings(self, uid, realm="master"): - """ Fetch the available role of a realm for a specified user on the Keycloak server. + def get_realm_user_available_rolemappings(self, uid, realm: str = "master"): + """Fetch the available role of a realm for a specified user on the Keycloak server. :param uid: ID of the user from which to obtain the rolemappings. :param realm: Realm from which to obtain the rolemappings. @@ -814,15 +1070,12 @@ class KeycloakAPI(object): """ available_rolemappings_url = URL_REALM_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=uid) try: - return json.loads(to_native(open_url(available_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(available_rolemappings_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch available rolemappings for user %s of realm %s: %s" - % (uid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch available rolemappings for user {uid} of realm {realm}: {e}") - def get_realm_user_composite_rolemappings(self, uid, realm="master"): - """ Fetch the composite role of a realm for a specified user on the Keycloak server. + def get_realm_user_composite_rolemappings(self, uid, realm: str = "master"): + """Fetch the composite role of a realm for a specified user on the Keycloak server. :param uid: ID of the user from which to obtain the rolemappings. :param realm: Realm from which to obtain the rolemappings. @@ -830,42 +1083,37 @@ class KeycloakAPI(object): """ composite_rolemappings_url = URL_REALM_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=uid) try: - return json.loads(to_native(open_url(composite_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(composite_rolemappings_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch effective rolemappings for user %s, realm %s: %s" - % (uid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch effective rolemappings for user {uid}, realm {realm}: {e}") - def get_user_by_username(self, username, realm="master"): - """ Fetch a keycloak user within a realm based on its username. + def get_user_by_username(self, username, realm: str = "master"): + """Fetch a keycloak user within a realm based on its username. If the user does not exist, None is returned. :param username: Username of the user to fetch. :param realm: Realm in which the user resides; default 'master' """ users_url = URL_USERS.format(url=self.baseurl, realm=realm) - users_url += '?username=%s&exact=true' % username + users_url += f"?username={quote(username, safe='')}&exact=true" try: userrep = None - users = json.loads(to_native(open_url(users_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + users = self._request_and_deserialize(users_url, method="GET") for user in users: - if user['username'] == username: + if user["username"] == username: userrep = user break return userrep except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain the user for realm %s and username %s: %s' - % (realm, username, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain the user for realm {realm} and username {username}: {e}" + ) except Exception as e: - self.fail_open_url(e, msg='Could not obtain the user for realm %s and username %s: %s' - % (realm, username, str(e))) + self.fail_request(e, msg=f"Could not obtain the user for realm {realm} and username {username}: {e}") - def get_service_account_user_by_client_id(self, client_id, realm="master"): - """ Fetch a keycloak service account user within a realm based on its client_id. + def get_service_account_user_by_client_id(self, client_id, realm: str = "master"): + """Fetch a keycloak service account user within a realm based on its client_id. If the user does not exist, None is returned. :param client_id: clientId of the service account user to fetch. @@ -875,18 +1123,18 @@ class KeycloakAPI(object): service_account_user_url = URL_CLIENT_SERVICE_ACCOUNT_USER.format(url=self.baseurl, realm=realm, id=cid) try: - return json.loads(to_native(open_url(service_account_user_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(service_account_user_url, method="GET") except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain the service-account-user for realm %s and client_id %s: %s' - % (realm, client_id, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain the service-account-user for realm {realm} and client_id {client_id}: {e}" + ) except Exception as e: - self.fail_open_url(e, msg='Could not obtain the service-account-user for realm %s and client_id %s: %s' - % (realm, client_id, str(e))) + self.fail_request( + e, msg=f"Could not obtain the service-account-user for realm {realm} and client_id {client_id}: {e}" + ) - def add_user_rolemapping(self, uid, cid, role_rep, realm="master"): - """ Assign a realm or client role to a specified user on the Keycloak server. + def add_user_rolemapping(self, uid, cid, role_rep, realm: str = "master"): + """Assign a realm or client role to a specified user on the Keycloak server. :param uid: ID of the user roles are assigned to. :param cid: ID of the client from which to obtain the rolemappings. If empty, roles are from the realm @@ -897,22 +1145,26 @@ class KeycloakAPI(object): if cid is None: user_realm_rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) try: - open_url(user_realm_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), - validate_certs=self.validate_certs, timeout=self.connection_timeout) + self._request(user_realm_rolemappings_url, method="POST", data=json.dumps(role_rep)) except Exception as e: - self.fail_open_url(e, msg="Could not map roles to userId %s for realm %s and roles %s: %s" - % (uid, realm, json.dumps(role_rep), str(e))) + self.fail_request( + e, + msg=f"Could not map roles to userId {uid} for realm {realm} and roles {json.dumps(role_rep)}: {e}", + ) else: - user_client_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid, client=cid) + user_client_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format( + url=self.baseurl, realm=realm, id=uid, client=cid + ) try: - open_url(user_client_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), - validate_certs=self.validate_certs, timeout=self.connection_timeout) + self._request(user_client_rolemappings_url, method="POST", data=json.dumps(role_rep)) except Exception as e: - self.fail_open_url(e, msg="Could not map roles to userId %s for client %s, realm %s and roles %s: %s" - % (cid, uid, realm, json.dumps(role_rep), str(e))) + self.fail_request( + e, + msg=f"Could not map roles to userId {cid} for client {uid}, realm {realm} and roles {json.dumps(role_rep)}: {e}", + ) - def delete_user_rolemapping(self, uid, cid, role_rep, realm="master"): - """ Delete the rolemapping of a client in a specified user on the Keycloak server. + def delete_user_rolemapping(self, uid, cid, role_rep, realm: str = "master"): + """Delete the rolemapping of a client in a specified user on the Keycloak server. :param uid: ID of the user from which to remove the rolemappings. :param cid: ID of the client from which to remove the rolemappings. @@ -923,22 +1175,25 @@ class KeycloakAPI(object): if cid is None: user_realm_rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) try: - open_url(user_realm_rolemappings_url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), - validate_certs=self.validate_certs, timeout=self.connection_timeout) + self._request(user_realm_rolemappings_url, method="DELETE", data=json.dumps(role_rep)) except Exception as e: - self.fail_open_url(e, msg="Could not remove roles %s from userId %s, realm %s: %s" - % (json.dumps(role_rep), uid, realm, str(e))) + self.fail_request( + e, msg=f"Could not remove roles {json.dumps(role_rep)} from userId {uid}, realm {realm}: {e}" + ) else: - user_client_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid, client=cid) + user_client_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format( + url=self.baseurl, realm=realm, id=uid, client=cid + ) try: - open_url(user_client_rolemappings_url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), - validate_certs=self.validate_certs, timeout=self.connection_timeout) + self._request(user_client_rolemappings_url, method="DELETE", data=json.dumps(role_rep)) except Exception as e: - self.fail_open_url(e, msg="Could not remove roles %s for client %s from userId %s, realm %s: %s" - % (json.dumps(role_rep), cid, uid, realm, str(e))) + self.fail_request( + e, + msg=f"Could not remove roles {json.dumps(role_rep)} for client {cid} from userId {uid}, realm {realm}: {e}", + ) - def get_client_templates(self, realm='master'): - """ Obtains client template representations for client templates in a realm + def get_client_templates(self, realm: str = "master"): + """Obtains client template representations for client templates in a realm :param realm: realm to be queried :return: list of dicts of client representations @@ -946,17 +1201,16 @@ class KeycloakAPI(object): url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) try: - return json.loads(to_native(open_url(url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(url, method="GET") except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of client templates for realm %s: %s' - % (realm, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain list of client templates for realm {realm}: {e}" + ) except Exception as e: - self.fail_open_url(e, msg='Could not obtain list of client templates for realm %s: %s' - % (realm, str(e))) + self.fail_request(e, msg=f"Could not obtain list of client templates for realm {realm}: {e}") - def get_client_template_by_id(self, id, realm='master'): - """ Obtain client template representation by id + def get_client_template_by_id(self, id, realm: str = "master"): + """Obtain client template representation by id :param id: id (not name) of client template to be queried :param realm: client template from this realm @@ -965,17 +1219,16 @@ class KeycloakAPI(object): url = URL_CLIENTTEMPLATE.format(url=self.baseurl, id=id, realm=realm) try: - return json.loads(to_native(open_url(url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(url, method="GET") except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client templates %s for realm %s: %s' - % (id, realm, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain client templates {id} for realm {realm}: {e}" + ) except Exception as e: - self.fail_open_url(e, msg='Could not obtain client template %s for realm %s: %s' - % (id, realm, str(e))) + self.fail_request(e, msg=f"Could not obtain client template {id} for realm {realm}: {e}") - def get_client_template_by_name(self, name, realm='master'): - """ Obtain client template representation by name + def get_client_template_by_name(self, name, realm: str = "master"): + """Obtain client template representation by name :param name: name of client template to be queried :param realm: client template from this realm @@ -983,26 +1236,26 @@ class KeycloakAPI(object): """ result = self.get_client_templates(realm) if isinstance(result, list): - result = [x for x in result if x['name'] == name] + result = [x for x in result if x["name"] == name] if len(result) > 0: return result[0] return None - def get_client_template_id(self, name, realm='master'): - """ Obtain client template id by name + def get_client_template_id(self, name, realm: str = "master"): + """Obtain client template id by name :param name: name of client template to be queried :param realm: client template from this realm :return: client template id (usually a UUID) """ result = self.get_client_template_by_name(name, realm) - if isinstance(result, dict) and 'id' in result: - return result['id'] + if isinstance(result, dict) and "id" in result: + return result["id"] else: return None - def update_client_template(self, id, clienttrep, realm="master"): - """ Update an existing client template + def update_client_template(self, id, clienttrep, realm: str = "master"): + """Update an existing client template :param id: id (not name) of client template to be updated in Keycloak :param clienttrep: corresponding (partial/full) client template representation with updates :param realm: realm the client template is in @@ -1011,14 +1264,12 @@ class KeycloakAPI(object): url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(clienttrep), validate_certs=self.validate_certs) + return self._request(url, method="PUT", data=json.dumps(clienttrep)) except Exception as e: - self.fail_open_url(e, msg='Could not update client template %s in realm %s: %s' - % (id, realm, str(e))) + self.fail_request(e, msg=f"Could not update client template {id} in realm {realm}: {e}") - def create_client_template(self, clienttrep, realm="master"): - """ Create a client in keycloak + def create_client_template(self, clienttrep, realm: str = "master"): + """Create a client in keycloak :param clienttrep: Client template representation of client template to be created. Must at least contain field name :param realm: realm for client template to be created in :return: HTTPResponse object on success @@ -1026,14 +1277,12 @@ class KeycloakAPI(object): url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) try: - return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(clienttrep), validate_certs=self.validate_certs) + return self._request(url, method="POST", data=json.dumps(clienttrep)) except Exception as e: - self.fail_open_url(e, msg='Could not create client template %s in realm %s: %s' - % (clienttrep['clientId'], realm, str(e))) + self.fail_request(e, msg=f"Could not create client template {clienttrep['clientId']} in realm {realm}: {e}") - def delete_client_template(self, id, realm="master"): - """ Delete a client template from Keycloak + def delete_client_template(self, id, realm: str = "master"): + """Delete a client template from Keycloak :param id: id (not name) of client to be deleted :param realm: realm of client template to be deleted @@ -1042,14 +1291,12 @@ class KeycloakAPI(object): url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Could not delete client template %s in realm %s: %s' - % (id, realm, str(e))) + self.fail_request(e, msg=f"Could not delete client template {id} in realm {realm}: {e}") - def get_clientscopes(self, realm="master"): - """ Fetch the name and ID of all clientscopes on the Keycloak server. + def get_clientscopes(self, realm: str = "master"): + """Fetch the name and ID of all clientscopes on the Keycloak server. To fetch the full data of the group, make a subsequent call to get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return. @@ -1059,15 +1306,12 @@ class KeycloakAPI(object): """ clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm) try: - return json.loads(to_native(open_url(clientscopes_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(clientscopes_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch list of clientscopes in realm %s: %s" - % (realm, str(e))) + self.fail_request(e, msg=f"Could not fetch list of clientscopes in realm {realm}: {e}") - def get_clientscope_by_clientscopeid(self, cid, realm="master"): - """ Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID. + def get_clientscope_by_clientscopeid(self, cid, realm: str = "master"): + """Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID. If the clientscope does not exist, None is returned. @@ -1077,22 +1321,18 @@ class KeycloakAPI(object): """ clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=cid) try: - return json.loads(to_native(open_url(clientscope_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(clientscope_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg="Could not fetch clientscope %s in realm %s: %s" - % (cid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch clientscope {cid} in realm {realm}: {e}") except Exception as e: - self.module.fail_json(msg="Could not clientscope group %s in realm %s: %s" - % (cid, realm, str(e))) + self.module.fail_json(msg=f"Could not clientscope group {cid} in realm {realm}: {e}") - def get_clientscope_by_name(self, name, realm="master"): - """ Fetch a keycloak clientscope within a realm based on its name. + def get_clientscope_by_name(self, name, realm: str = "master"): + """Fetch a keycloak clientscope within a realm based on its name. The Keycloak API does not allow filtering of the clientscopes resource by name. As a result, this method first retrieves the entire list of clientscopes - name and ID - @@ -1106,47 +1346,42 @@ class KeycloakAPI(object): all_clientscopes = self.get_clientscopes(realm=realm) for clientscope in all_clientscopes: - if clientscope['name'] == name: - return self.get_clientscope_by_clientscopeid(clientscope['id'], realm=realm) + if clientscope["name"] == name: + return self.get_clientscope_by_clientscopeid(clientscope["id"], realm=realm) return None except Exception as e: - self.module.fail_json(msg="Could not fetch clientscope %s in realm %s: %s" - % (name, realm, str(e))) + self.module.fail_json(msg=f"Could not fetch clientscope {name} in realm {realm}: {e}") - def create_clientscope(self, clientscoperep, realm="master"): - """ Create a Keycloak clientscope. + def create_clientscope(self, clientscoperep, realm: str = "master"): + """Create a Keycloak clientscope. :param clientscoperep: a ClientScopeRepresentation of the clientscope to be created. Must contain at minimum the field name. :return: HTTPResponse object on success """ clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm) try: - return open_url(clientscopes_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(clientscoperep), validate_certs=self.validate_certs) + return self._request(clientscopes_url, method="POST", data=json.dumps(clientscoperep)) except Exception as e: - self.fail_open_url(e, msg="Could not create clientscope %s in realm %s: %s" - % (clientscoperep['name'], realm, str(e))) + self.fail_request(e, msg=f"Could not create clientscope {clientscoperep['name']} in realm {realm}: {e}") - def update_clientscope(self, clientscoperep, realm="master"): - """ Update an existing clientscope. + def update_clientscope(self, clientscoperep, realm: str = "master"): + """Update an existing clientscope. :param grouprep: A GroupRepresentation of the updated group. :return HTTPResponse object on success """ - clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=clientscoperep['id']) + clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=clientscoperep["id"]) try: - return open_url(clientscope_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(clientscoperep), validate_certs=self.validate_certs) + return self._request(clientscope_url, method="PUT", data=json.dumps(clientscoperep)) except Exception as e: - self.fail_open_url(e, msg='Could not update clientscope %s in realm %s: %s' - % (clientscoperep['name'], realm, str(e))) + self.fail_request(e, msg=f"Could not update clientscope {clientscoperep['name']} in realm {realm}: {e}") - def delete_clientscope(self, name=None, cid=None, realm="master"): - """ Delete a clientscope. One of name or cid must be provided. + def delete_clientscope(self, name=None, cid=None, realm: str = "master"): + """Delete a clientscope. One of name or cid must be provided. Providing the clientscope ID is preferred as it avoids a second lookup to convert a clientscope name to an ID. @@ -1160,13 +1395,13 @@ class KeycloakAPI(object): # prefer an exception since this is almost certainly a programming error in the module itself. raise Exception("Unable to delete group - one of group ID or name must be provided.") - # only lookup the name if cid isn't provided. - # in the case that both are provided, prefer the ID, since it's one + # only lookup the name if cid is not provided. + # in the case that both are provided, prefer the ID, since it is one # less lookup. if cid is None and name is not None: for clientscope in self.get_clientscopes(realm=realm): - if clientscope['name'] == name: - cid = clientscope['id'] + if clientscope["name"] == name: + cid = clientscope["id"] break # if the group doesn't exist - no problem, nothing to delete. @@ -1176,14 +1411,13 @@ class KeycloakAPI(object): # should have a good cid by here. clientscope_url = URL_CLIENTSCOPE.format(realm=realm, id=cid, url=self.baseurl) try: - return open_url(clientscope_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(clientscope_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg="Unable to delete clientscope %s: %s" % (cid, str(e))) + self.fail_request(e, msg=f"Unable to delete clientscope {cid}: {e}") - def get_clientscope_protocolmappers(self, cid, realm="master"): - """ Fetch the name and ID of all clientscopes on the Keycloak server. + def get_clientscope_protocolmappers(self, cid, realm: str = "master"): + """Fetch the name and ID of all clientscopes on the Keycloak server. To fetch the full data of the group, make a subsequent call to get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return. @@ -1194,15 +1428,12 @@ class KeycloakAPI(object): """ protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(id=cid, url=self.baseurl, realm=realm) try: - return json.loads(to_native(open_url(protocolmappers_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(protocolmappers_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch list of protocolmappers in realm %s: %s" - % (realm, str(e))) + self.fail_request(e, msg=f"Could not fetch list of protocolmappers in realm {realm}: {e}") - def get_clientscope_protocolmapper_by_protocolmapperid(self, pid, cid, realm="master"): - """ Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID. + def get_clientscope_protocolmapper_by_protocolmapperid(self, pid, cid, realm: str = "master"): + """Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID. If the clientscope does not exist, None is returned. @@ -1214,22 +1445,18 @@ class KeycloakAPI(object): """ protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=pid) try: - return json.loads(to_native(open_url(protocolmapper_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(protocolmapper_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg="Could not fetch protocolmapper %s in realm %s: %s" - % (pid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch protocolmapper {pid} in realm {realm}: {e}") except Exception as e: - self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" - % (cid, realm, str(e))) + self.module.fail_json(msg=f"Could not fetch protocolmapper {cid} in realm {realm}: {e}") - def get_clientscope_protocolmapper_by_name(self, cid, name, realm="master"): - """ Fetch a keycloak clientscope within a realm based on its name. + def get_clientscope_protocolmapper_by_name(self, cid, name, realm: str = "master"): + """Fetch a keycloak clientscope within a realm based on its name. The Keycloak API does not allow filtering of the clientscopes resource by name. As a result, this method first retrieves the entire list of clientscopes - name and ID - @@ -1244,17 +1471,18 @@ class KeycloakAPI(object): all_protocolmappers = self.get_clientscope_protocolmappers(cid, realm=realm) for protocolmapper in all_protocolmappers: - if protocolmapper['name'] == name: - return self.get_clientscope_protocolmapper_by_protocolmapperid(protocolmapper['id'], cid, realm=realm) + if protocolmapper["name"] == name: + return self.get_clientscope_protocolmapper_by_protocolmapperid( + protocolmapper["id"], cid, realm=realm + ) return None except Exception as e: - self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" - % (name, realm, str(e))) + self.module.fail_json(msg=f"Could not fetch protocolmapper {name} in realm {realm}: {e}") - def create_clientscope_protocolmapper(self, cid, mapper_rep, realm="master"): - """ Create a Keycloak clientscope protocolmapper. + def create_clientscope_protocolmapper(self, cid, mapper_rep, realm: str = "master"): + """Create a Keycloak clientscope protocolmapper. :param cid: Id of the clientscope. :param mapper_rep: a ProtocolMapperRepresentation of the protocolmapper to be created. Must contain at minimum the field name. @@ -1262,28 +1490,28 @@ class KeycloakAPI(object): """ protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(url=self.baseurl, id=cid, realm=realm) try: - return open_url(protocolmappers_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(mapper_rep), validate_certs=self.validate_certs) + return self._request(protocolmappers_url, method="POST", data=json.dumps(mapper_rep)) except Exception as e: - self.fail_open_url(e, msg="Could not create protocolmapper %s in realm %s: %s" - % (mapper_rep['name'], realm, str(e))) + self.fail_request(e, msg=f"Could not create protocolmapper {mapper_rep['name']} in realm {realm}: {e}") - def update_clientscope_protocolmappers(self, cid, mapper_rep, realm="master"): - """ Update an existing clientscope. + def update_clientscope_protocolmappers(self, cid, mapper_rep, realm: str = "master"): + """Update an existing clientscope. :param cid: Id of the clientscope. :param mapper_rep: A ProtocolMapperRepresentation of the updated protocolmapper. :return HTTPResponse object on success """ - protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=mapper_rep['id']) + protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format( + url=self.baseurl, realm=realm, id=cid, mapper_id=mapper_rep["id"] + ) try: - return open_url(protocolmapper_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(mapper_rep), validate_certs=self.validate_certs) + return self._request(protocolmapper_url, method="PUT", data=json.dumps(mapper_rep)) except Exception as e: - self.fail_open_url(e, msg='Could not update protocolmappers for clientscope %s in realm %s: %s' - % (mapper_rep, realm, str(e))) + self.fail_request( + e, msg=f"Could not update protocolmappers for clientscope {mapper_rep} in realm {realm}: {e}" + ) def get_default_clientscopes(self, realm, client_id=None): """Fetch the name and ID of all clientscopes on the Keycloak server. @@ -1296,7 +1524,7 @@ class KeycloakAPI(object): :return The default clientscopes of this realm or client """ url = URL_DEFAULT_CLIENTSCOPES if client_id is None else URL_CLIENT_DEFAULT_CLIENTSCOPES - return self._get_clientscopes_of_type(realm, url, 'default', client_id) + return self._get_clientscopes_of_type(realm, url, "default", client_id) def get_optional_clientscopes(self, realm, client_id=None): """Fetch the name and ID of all clientscopes on the Keycloak server. @@ -1309,7 +1537,7 @@ class KeycloakAPI(object): :return The optional clientscopes of this realm or client """ url = URL_OPTIONAL_CLIENTSCOPES if client_id is None else URL_CLIENT_OPTIONAL_CLIENTSCOPES - return self._get_clientscopes_of_type(realm, url, 'optional', client_id) + return self._get_clientscopes_of_type(realm, url, "optional", client_id) def _get_clientscopes_of_type(self, realm, url_template, scope_type, client_id=None): """Fetch the name and ID of all clientscopes on the Keycloak server. @@ -1326,18 +1554,19 @@ class KeycloakAPI(object): if client_id is None: clientscopes_url = url_template.format(url=self.baseurl, realm=realm) try: - return json.loads(to_native(open_url(clientscopes_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(clientscopes_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch list of %s clientscopes in realm %s: %s" % (scope_type, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch list of {scope_type} clientscopes in realm {realm}: {e}") else: cid = self.get_client_id(client_id=client_id, realm=realm) clientscopes_url = url_template.format(url=self.baseurl, realm=realm, cid=cid) try: - return json.loads(to_native(open_url(clientscopes_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(clientscopes_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch list of %s clientscopes in client %s: %s" % (scope_type, client_id, clientscopes_url)) + self.fail_request( + e, + msg=f"Could not fetch list of {scope_type} clientscopes in client {client_id}: {clientscopes_url}", + ) def _decide_url_type_clientscope(self, client_id=None, scope_type="default"): """Decides which url to use. @@ -1355,44 +1584,46 @@ class KeycloakAPI(object): if scope_type == "optional": return URL_CLIENT_OPTIONAL_CLIENTSCOPE - def add_default_clientscope(self, id, realm="master", client_id=None): + def add_default_clientscope(self, id, realm: str = "master", client_id=None): """Add a client scope as default either on realm or client level. :param id: Client scope Id. :param realm: Realm in which the clientscope resides. :param client_id: The client in which the clientscope resides. """ - self._action_type_clientscope(id, client_id, "default", realm, 'add') + self._action_type_clientscope(id, client_id, "default", realm, "add") - def add_optional_clientscope(self, id, realm="master", client_id=None): + def add_optional_clientscope(self, id, realm: str = "master", client_id=None): """Add a client scope as optional either on realm or client level. :param id: Client scope Id. :param realm: Realm in which the clientscope resides. :param client_id: The client in which the clientscope resides. """ - self._action_type_clientscope(id, client_id, "optional", realm, 'add') + self._action_type_clientscope(id, client_id, "optional", realm, "add") - def delete_default_clientscope(self, id, realm="master", client_id=None): + def delete_default_clientscope(self, id, realm: str = "master", client_id=None): """Remove a client scope as default either on realm or client level. :param id: Client scope Id. :param realm: Realm in which the clientscope resides. :param client_id: The client in which the clientscope resides. """ - self._action_type_clientscope(id, client_id, "default", realm, 'delete') + self._action_type_clientscope(id, client_id, "default", realm, "delete") - def delete_optional_clientscope(self, id, realm="master", client_id=None): + def delete_optional_clientscope(self, id, realm: str = "master", client_id=None): """Remove a client scope as optional either on realm or client level. :param id: Client scope Id. :param realm: Realm in which the clientscope resides. :param client_id: The client in which the clientscope resides. """ - self._action_type_clientscope(id, client_id, "optional", realm, 'delete') + self._action_type_clientscope(id, client_id, "optional", realm, "delete") - def _action_type_clientscope(self, id=None, client_id=None, scope_type="default", realm="master", action='add'): - """ Delete or add a clientscope of type. + def _action_type_clientscope( + self, id=None, client_id=None, scope_type="default", realm: str = "master", action="add" + ): + """Delete or add a clientscope of type. :param name: The name of the clientscope. A lookup will be performed to retrieve the clientscope ID. :param client_id: The ID of the clientscope (preferred to name). :param scope_type 'default' or 'optional' @@ -1400,18 +1631,19 @@ class KeycloakAPI(object): """ cid = None if client_id is None else self.get_client_id(client_id=client_id, realm=realm) # should have a good cid by here. - clientscope_type_url = self._decide_url_type_clientscope(client_id, scope_type).format(realm=realm, id=id, cid=cid, url=self.baseurl) + clientscope_type_url = self._decide_url_type_clientscope(client_id, scope_type).format( + realm=realm, id=id, cid=cid, url=self.baseurl + ) try: - method = 'PUT' if action == "add" else 'DELETE' - return open_url(clientscope_type_url, method=method, http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + method = "PUT" if action == "add" else "DELETE" + return self._request(clientscope_type_url, method=method) except Exception as e: - place = 'realm' if client_id is None else 'client ' + client_id - self.fail_open_url(e, msg="Unable to %s %s clientscope %s @ %s : %s" % (action, scope_type, id, place, str(e))) + place = "realm" if client_id is None else f"client {client_id}" + self.fail_request(e, msg=f"Unable to {action} {scope_type} clientscope {id} @ {place} : {e}") - def create_clientsecret(self, id, realm="master"): - """ Generate a new client secret by id + def create_clientsecret(self, id, realm: str = "master"): + """Generate a new client secret by id :param id: id (not clientId) of client to be queried :param realm: client from this realm @@ -1420,22 +1652,18 @@ class KeycloakAPI(object): clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id) try: - return json.loads(to_native(open_url(clientsecret_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(clientsecret_url, method="POST") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not obtain clientsecret of client %s for realm %s: %s' - % (id, realm, str(e))) + self.fail_request(e, msg=f"Could not obtain clientsecret of client {id} for realm {realm}: {e}") except Exception as e: - self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s' - % (id, realm, str(e))) + self.module.fail_json(msg=f"Could not obtain clientsecret of client {id} for realm {realm}: {e}") - def get_clientsecret(self, id, realm="master"): - """ Obtain client secret by id + def get_clientsecret(self, id, realm: str = "master"): + """Obtain client secret by id :param id: id (not clientId) of client to be queried :param realm: client from this realm @@ -1444,22 +1672,18 @@ class KeycloakAPI(object): clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id) try: - return json.loads(to_native(open_url(clientsecret_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(clientsecret_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not obtain clientsecret of client %s for realm %s: %s' - % (id, realm, str(e))) + self.fail_request(e, msg=f"Could not obtain clientsecret of client {id} for realm {realm}: {e}") except Exception as e: - self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s' - % (id, realm, str(e))) + self.module.fail_json(msg=f"Could not obtain clientsecret of client {id} for realm {realm}: {e}") - def get_groups(self, realm="master"): - """ Fetch the name and ID of all groups on the Keycloak server. + def get_groups(self, realm: str = "master"): + """Fetch the name and ID of all groups on the Keycloak server. To fetch the full data of the group, make a subsequent call to get_group_by_groupid, passing in the ID of the group you wish to return. @@ -1468,15 +1692,12 @@ class KeycloakAPI(object): """ groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: - return json.loads(to_native(open_url(groups_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(groups_url, method="GET") except Exception as e: - self.fail_open_url(e, msg="Could not fetch list of groups in realm %s: %s" - % (realm, str(e))) + self.fail_request(e, msg=f"Could not fetch list of groups in realm {realm}: {e}") - def get_group_by_groupid(self, gid, realm="master"): - """ Fetch a keycloak group from the provided realm using the group's unique ID. + def get_group_by_groupid(self, gid, realm: str = "master"): + """Fetch a keycloak group from the provided realm using the group's unique ID. If the group does not exist, None is returned. @@ -1486,32 +1707,41 @@ class KeycloakAPI(object): """ groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) try: - return json.loads(to_native(open_url(groups_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(groups_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg="Could not fetch group %s in realm %s: %s" - % (gid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch group {gid} in realm {realm}: {e}") except Exception as e: - self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" - % (gid, realm, str(e))) + self.module.fail_json(msg=f"Could not fetch group {gid} in realm {realm}: {e}") - def get_group_by_name(self, name, realm="master", parents=None): - """ Fetch a keycloak group within a realm based on its name. + def get_subgroups(self, parent, realm: str = "master"): + if "subGroupCount" in parent: + # Since version 23, when GETting a group Keycloak does not + # return subGroups but only a subGroupCount. + # Children must be fetched in a second request. + if parent["subGroupCount"] == 0: + group_children = [] + else: + group_children_url = f"{URL_GROUP_CHILDREN.format(url=self.baseurl, realm=realm, groupid=parent['id'])}?max={parent['subGroupCount']}" + group_children = self._request_and_deserialize(group_children_url, method="GET") + subgroups = group_children + else: + subgroups = parent["subGroups"] + return subgroups - The Keycloak API does not allow filtering of the Groups resource by name. - As a result, this method first retrieves the entire list of groups - name and ID - - then performs a second query to fetch the group. + def get_group_by_name(self, name, realm: str = "master", parents=None): + """Fetch a keycloak group within a realm based on its name. + + Uses the Keycloak search API with exact matching for efficient lookup + instead of fetching all groups. If the group does not exist, None is returned. :param name: Name of the group to fetch. :param realm: Realm in which the group resides; default 'master' :param parents: Optional list of parents when group to look for is a subgroup """ - groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: if parents: parent = self.get_subgroup_direct_parent(parents, realm) @@ -1519,32 +1749,41 @@ class KeycloakAPI(object): if not parent: return None - all_groups = parent['subGroups'] + # For subgroups: use children endpoint with search parameter + search_url = "{url}?search={name}&exact=true".format( + url=URL_GROUP_CHILDREN.format(url=self.baseurl, realm=realm, groupid=parent["id"]), + name=quote(name, safe=""), + ) else: - all_groups = self.get_groups(realm=realm) + # For top-level groups: use groups endpoint with search parameter + search_url = "{url}?search={name}&exact=true".format( + url=URL_GROUPS.format(url=self.baseurl, realm=realm), name=quote(name, safe="") + ) - for group in all_groups: - if group['name'] == name: - return self.get_group_by_groupid(group['id'], realm=realm) + groups = self._request_and_deserialize(search_url, method="GET") + + # exact=true should return only exact matches, but verify the name + for group in groups: + if group["name"] == name: + return self.get_group_by_groupid(group["id"], realm=realm) return None except Exception as e: - self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" - % (name, realm, str(e))) + self.module.fail_json(msg=f"Could not fetch group {name} in realm {realm}: {e}") def _get_normed_group_parent(self, parent): - """ Converts parent dict information into a more easy to use form. + """Converts parent dict information into a more easy to use form. :param parent: parent describing dict """ - if parent['id']: - return (parent['id'], True) + if parent["id"]: + return (parent["id"], True) - return (parent['name'], False) + return (parent["name"], False) - def get_subgroup_by_chain(self, name_chain, realm="master"): - """ Access a subgroup API object by walking down a given name/id chain. + def get_subgroup_by_chain(self, name_chain, realm: str = "master"): + """Access a subgroup API object by walking down a given name/id chain. Groups can be given either as by name or by ID, the first element must either be a toplvl group or given as ID, all parents must exist. @@ -1568,7 +1807,7 @@ class KeycloakAPI(object): return None for p in name_chain[1:]: - for sg in tmp['subGroups']: + for sg in self.get_subgroups(tmp, realm): pv, is_id = self._get_normed_group_parent(p) if is_id: @@ -1585,8 +1824,8 @@ class KeycloakAPI(object): return tmp - def get_subgroup_direct_parent(self, parents, realm="master", children_to_resolve=None): - """ Get keycloak direct parent group API object for a given chain of parents. + def get_subgroup_direct_parent(self, parents, realm: str = "master", children_to_resolve=None): + """Get keycloak direct parent group API object for a given chain of parents. To successfully work the API for subgroups we actually don't need to "walk the whole tree" for nested groups but only need to know @@ -1624,27 +1863,22 @@ class KeycloakAPI(object): # current parent is given as name, it must be resolved # later, try next parent (recurse) children_to_resolve.append(cp) - return self.get_subgroup_direct_parent( - parents[1:], - realm=realm, children_to_resolve=children_to_resolve - ) + return self.get_subgroup_direct_parent(parents[1:], realm=realm, children_to_resolve=children_to_resolve) - def create_group(self, grouprep, realm="master"): - """ Create a Keycloak group. + def create_group(self, grouprep, realm: str = "master"): + """Create a Keycloak group. :param grouprep: a GroupRepresentation of the group to be created. Must contain at minimum the field name. :return: HTTPResponse object on success """ groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: - return open_url(groups_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(grouprep), validate_certs=self.validate_certs) + return self._request(groups_url, method="POST", data=json.dumps(grouprep)) except Exception as e: - self.fail_open_url(e, msg="Could not create group %s in realm %s: %s" - % (grouprep['name'], realm, str(e))) + self.fail_request(e, msg=f"Could not create group {grouprep['name']} in realm {realm}: {e}") - def create_subgroup(self, parents, grouprep, realm="master"): - """ Create a Keycloak subgroup. + def create_subgroup(self, parents, grouprep, realm: str = "master"): + """Create a Keycloak subgroup. :param parents: list of one or more parent groups :param grouprep: a GroupRepresentation of the group to be created. Must contain at minimum the field name. @@ -1657,37 +1891,36 @@ class KeycloakAPI(object): if not parent_id: raise Exception( "Could not determine subgroup parent ID for given" - " parent chain {0}. Assure that all parents exist" + f" parent chain {parents}. Assure that all parents exist" " already and the list is complete and properly" " ordered, starts with an ID or starts at the" - " top level".format(parents) + " top level" ) parent_id = parent_id["id"] url = URL_GROUP_CHILDREN.format(url=self.baseurl, realm=realm, groupid=parent_id) - return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(grouprep), validate_certs=self.validate_certs) + return self._request(url, method="POST", data=json.dumps(grouprep)) except Exception as e: - self.fail_open_url(e, msg="Could not create subgroup %s for parent group %s in realm %s: %s" - % (grouprep['name'], parent_id, realm, str(e))) + self.fail_request( + e, + msg=f"Could not create subgroup {grouprep['name']} for parent group {parent_id} in realm {realm}: {e}", + ) - def update_group(self, grouprep, realm="master"): - """ Update an existing group. + def update_group(self, grouprep, realm: str = "master"): + """Update an existing group. :param grouprep: A GroupRepresentation of the updated group. :return HTTPResponse object on success """ - group_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=grouprep['id']) + group_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=grouprep["id"]) try: - return open_url(group_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(grouprep), validate_certs=self.validate_certs) + return self._request(group_url, method="PUT", data=json.dumps(grouprep)) except Exception as e: - self.fail_open_url(e, msg='Could not update group %s in realm %s: %s' - % (grouprep['name'], realm, str(e))) + self.fail_request(e, msg=f"Could not update group {grouprep['name']} in realm {realm}: {e}") - def delete_group(self, name=None, groupid=None, realm="master"): - """ Delete a group. One of name or groupid must be provided. + def delete_group(self, name=None, groupid=None, realm: str = "master"): + """Delete a group. One of name or groupid must be provided. Providing the group ID is preferred as it avoids a second lookup to convert a group name to an ID. @@ -1702,12 +1935,12 @@ class KeycloakAPI(object): raise Exception("Unable to delete group - one of group ID or name must be provided.") # only lookup the name if groupid isn't provided. - # in the case that both are provided, prefer the ID, since it's one + # in the case that both are provided, prefer the ID, since it is one # less lookup. if groupid is None and name is not None: for group in self.get_groups(realm=realm): - if group['name'] == name: - groupid = group['id'] + if group["name"] == name: + groupid = group["id"] break # if the group doesn't exist - no problem, nothing to delete. @@ -1717,52 +1950,46 @@ class KeycloakAPI(object): # should have a good groupid by here. group_url = URL_GROUP.format(realm=realm, groupid=groupid, url=self.baseurl) try: - return open_url(group_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(group_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg="Unable to delete group %s: %s" % (groupid, str(e))) + self.fail_request(e, msg=f"Unable to delete group {groupid}: {e}") - def get_realm_roles(self, realm='master'): - """ Obtains role representations for roles in a realm + def get_realm_roles(self, realm: str = "master"): + """Obtains role representations for roles in a realm :param realm: realm to be queried :return: list of dicts of role representations """ rolelist_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) try: - return json.loads(to_native(open_url(rolelist_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(rolelist_url, method="GET") except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of roles for realm %s: %s' - % (realm, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain list of roles for realm {realm}: {e}" + ) except Exception as e: - self.fail_open_url(e, msg='Could not obtain list of roles for realm %s: %s' - % (realm, str(e))) + self.fail_request(e, msg=f"Could not obtain list of roles for realm {realm}: {e}") - def get_realm_role(self, name, realm='master'): - """ Fetch a keycloak role from the provided realm using the role's name. + def get_realm_role(self, name, realm: str = "master"): + """Fetch a keycloak role from the provided realm using the role's name. If the role does not exist, None is returned. :param name: Name of the role to fetch. :param realm: Realm in which the role resides; default 'master'. """ - role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name, safe='')) + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name, safe="")) try: - return json.loads(to_native(open_url(role_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(role_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not fetch role %s in realm %s: %s' - % (name, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch role {name} in realm {realm}: {e}") except Exception as e: - self.module.fail_json(msg='Could not fetch role %s in realm %s: %s' - % (name, realm, str(e))) + self.module.fail_json(msg=f"Could not fetch role {name} in realm {realm}: {e}") - def create_realm_role(self, rolerep, realm='master'): - """ Create a Keycloak realm role. + def create_realm_role(self, rolerep, realm: str = "master"): + """Create a Keycloak realm role. :param rolerep: a RoleRepresentation of the role to be created. Must contain at minimum the field name. :return: HTTPResponse object on success @@ -1772,89 +1999,86 @@ class KeycloakAPI(object): if "composites" in rolerep: keycloak_compatible_composites = self.convert_role_composites(rolerep["composites"]) rolerep["composites"] = keycloak_compatible_composites - return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(rolerep), validate_certs=self.validate_certs) + return self._request(roles_url, method="POST", data=json.dumps(rolerep)) except Exception as e: - self.fail_open_url(e, msg='Could not create role %s in realm %s: %s' - % (rolerep['name'], realm, str(e))) + self.fail_request(e, msg=f"Could not create role {rolerep['name']} in realm {realm}: {e}") - def update_realm_role(self, rolerep, realm='master'): - """ Update an existing realm role. + def update_realm_role(self, rolerep, realm: str = "master"): + """Update an existing realm role. :param rolerep: A RoleRepresentation of the updated role. :return HTTPResponse object on success """ - role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(rolerep['name']), safe='') + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"]), safe="") try: composites = None if "composites" in rolerep: composites = copy.deepcopy(rolerep["composites"]) del rolerep["composites"] - role_response = open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(rolerep), validate_certs=self.validate_certs) + role_response = self._request(role_url, method="PUT", data=json.dumps(rolerep)) if composites is not None: self.update_role_composites(rolerep=rolerep, composites=composites, realm=realm) return role_response except Exception as e: - self.fail_open_url(e, msg='Could not update role %s in realm %s: %s' - % (rolerep['name'], realm, str(e))) + self.fail_request(e, msg=f"Could not update role {rolerep['name']} in realm {realm}: {e}") - def get_role_composites(self, rolerep, clientid=None, realm='master'): - composite_url = '' + def get_role_composites(self, rolerep, clientid=None, realm: str = "master"): + composite_url = "" try: if clientid is not None: client = self.get_client_by_clientid(client_id=clientid, realm=realm) - cid = client['id'] - composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"], safe='')) + cid = client["id"] + composite_url = URL_CLIENT_ROLE_COMPOSITES.format( + url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"], safe="") + ) else: - composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe='')) + composite_url = URL_REALM_ROLE_COMPOSITES.format( + url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe="") + ) # Get existing composites - return json.loads(to_native(open_url( - composite_url, - method='GET', - http_agent=self.http_agent, - headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(composite_url, method="GET") except Exception as e: - self.fail_open_url(e, msg='Could not get role %s composites in realm %s: %s' - % (rolerep['name'], realm, str(e))) + self.fail_request(e, msg=f"Could not get role {rolerep['name']} composites in realm {realm}: {e}") - def create_role_composites(self, rolerep, composites, clientid=None, realm='master'): - composite_url = '' + def create_role_composites(self, rolerep, composites, clientid=None, realm: str = "master"): + composite_url = "" try: if clientid is not None: client = self.get_client_by_clientid(client_id=clientid, realm=realm) - cid = client['id'] - composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"], safe='')) + cid = client["id"] + composite_url = URL_CLIENT_ROLE_COMPOSITES.format( + url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"], safe="") + ) else: - composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe='')) + composite_url = URL_REALM_ROLE_COMPOSITES.format( + url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe="") + ) # Get existing composites # create new composites - return open_url(composite_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(composites), validate_certs=self.validate_certs) + return self._request(composite_url, method="POST", data=json.dumps(composites)) except Exception as e: - self.fail_open_url(e, msg='Could not create role %s composites in realm %s: %s' - % (rolerep['name'], realm, str(e))) + self.fail_request(e, msg=f"Could not create role {rolerep['name']} composites in realm {realm}: {e}") - def delete_role_composites(self, rolerep, composites, clientid=None, realm='master'): - composite_url = '' + def delete_role_composites(self, rolerep, composites, clientid=None, realm: str = "master"): + composite_url = "" try: if clientid is not None: client = self.get_client_by_clientid(client_id=clientid, realm=realm) - cid = client['id'] - composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"], safe='')) + cid = client["id"] + composite_url = URL_CLIENT_ROLE_COMPOSITES.format( + url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"], safe="") + ) else: - composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe='')) + composite_url = URL_REALM_ROLE_COMPOSITES.format( + url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe="") + ) # Get existing composites # create new composites - return open_url(composite_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(composites), validate_certs=self.validate_certs) + return self._request(composite_url, method="DELETE", data=json.dumps(composites)) except Exception as e: - self.fail_open_url(e, msg='Could not create role %s composites in realm %s: %s' - % (rolerep['name'], realm, str(e))) + self.fail_request(e, msg=f"Could not create role {rolerep['name']} composites in realm {realm}: {e}") - def update_role_composites(self, rolerep, composites, clientid=None, realm='master'): + def update_role_composites(self, rolerep, composites, clientid=None, realm: str = "master"): # Get existing composites existing_composites = self.get_role_composites(rolerep=rolerep, clientid=clientid, realm=realm) composites_to_be_created = [] @@ -1865,32 +2089,35 @@ class KeycloakAPI(object): for existing_composite in existing_composites: if existing_composite["clientRole"]: existing_composite_client = self.get_client_by_id(existing_composite["containerId"], realm=realm) - if ("client_id" in composite - and composite['client_id'] is not None - and existing_composite_client["clientId"] == composite["client_id"] - and composite["name"] == existing_composite["name"]): + if ( + "client_id" in composite + and composite["client_id"] is not None + and existing_composite_client["clientId"] == composite["client_id"] + and composite["name"] == existing_composite["name"] + ): composite_found = True break else: - if (("client_id" not in composite or composite['client_id'] is None) - and composite["name"] == existing_composite["name"]): + if ("client_id" not in composite or composite["client_id"] is None) and composite[ + "name" + ] == existing_composite["name"]: composite_found = True break - if (not composite_found and ('state' not in composite or composite['state'] == 'present')): - if "client_id" in composite and composite['client_id'] is not None: - client_roles = self.get_client_roles(clientid=composite['client_id'], realm=realm) + if not composite_found and ("state" not in composite or composite["state"] == "present"): + if "client_id" in composite and composite["client_id"] is not None: + client_roles = self.get_client_roles(clientid=composite["client_id"], realm=realm) for client_role in client_roles: - if client_role['name'] == composite['name']: + if client_role["name"] == composite["name"]: composites_to_be_created.append(client_role) break else: realm_role = self.get_realm_role(name=composite["name"], realm=realm) composites_to_be_created.append(realm_role) - elif composite_found and 'state' in composite and composite['state'] == 'absent': - if "client_id" in composite and composite['client_id'] is not None: - client_roles = self.get_client_roles(clientid=composite['client_id'], realm=realm) + elif composite_found and "state" in composite and composite["state"] == "absent": + if "client_id" in composite and composite["client_id"] is not None: + client_roles = self.get_client_roles(clientid=composite["client_id"], realm=realm) for client_role in client_roles: - if client_role['name'] == composite['name']: + if client_role["name"] == composite["name"]: composites_to_be_deleted.append(client_role) break else: @@ -1899,27 +2126,29 @@ class KeycloakAPI(object): if len(composites_to_be_created) > 0: # create new composites - self.create_role_composites(rolerep=rolerep, composites=composites_to_be_created, clientid=clientid, realm=realm) + self.create_role_composites( + rolerep=rolerep, composites=composites_to_be_created, clientid=clientid, realm=realm + ) if len(composites_to_be_deleted) > 0: # delete new composites - self.delete_role_composites(rolerep=rolerep, composites=composites_to_be_deleted, clientid=clientid, realm=realm) + self.delete_role_composites( + rolerep=rolerep, composites=composites_to_be_deleted, clientid=clientid, realm=realm + ) - def delete_realm_role(self, name, realm='master'): - """ Delete a realm role. + def delete_realm_role(self, name, realm: str = "master"): + """Delete a realm role. :param name: The name of the role. :param realm: The realm in which this role resides, default "master". """ - role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name, safe='')) + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name, safe="")) try: - return open_url(role_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(role_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Unable to delete role %s in realm %s: %s' - % (name, realm, str(e))) + self.fail_request(e, msg=f"Unable to delete role {name} in realm {realm}: {e}") - def get_client_roles(self, clientid, realm='master'): - """ Obtains role representations for client roles in a specific client + def get_client_roles(self, clientid, realm: str = "master"): + """Obtains role representations for client roles in a specific client :param clientid: Client id to be queried :param realm: Realm to be queried @@ -1927,22 +2156,19 @@ class KeycloakAPI(object): """ cid = self.get_client_id(clientid, realm=realm) if cid is None: - self.module.fail_json(msg='Could not find client %s in realm %s' - % (clientid, realm)) + self.module.fail_json(msg=f"Could not find client {clientid} in realm {realm}") rolelist_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) try: - return json.loads(to_native(open_url(rolelist_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(rolelist_url, method="GET") except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of roles for client %s in realm %s: %s' - % (clientid, realm, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain list of roles for client {clientid} in realm {realm}: {e}" + ) except Exception as e: - self.fail_open_url(e, msg='Could not obtain list of roles for client %s in realm %s: %s' - % (clientid, realm, str(e))) + self.fail_request(e, msg=f"Could not obtain list of roles for client {clientid} in realm {realm}: {e}") - def get_client_role(self, name, clientid, realm='master'): - """ Fetch a keycloak client role from the provided realm using the role's name. + def get_client_role(self, name, clientid, realm: str = "master"): + """Fetch a keycloak client role from the provided realm using the role's name. :param name: Name of the role to fetch. :param clientid: Client id for the client role @@ -1952,24 +2178,20 @@ class KeycloakAPI(object): """ cid = self.get_client_id(clientid, realm=realm) if cid is None: - self.module.fail_json(msg='Could not find client %s in realm %s' - % (clientid, realm)) - role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name, safe='')) + self.module.fail_json(msg=f"Could not find client {clientid} in realm {realm}") + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name, safe="")) try: - return json.loads(to_native(open_url(role_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(role_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not fetch role %s in client %s of realm %s: %s' - % (name, clientid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch role {name} in client {clientid} of realm {realm}: {e}") except Exception as e: - self.module.fail_json(msg='Could not fetch role %s for client %s in realm %s: %s' - % (name, clientid, realm, str(e))) + self.module.fail_json(msg=f"Could not fetch role {name} for client {clientid} in realm {realm}: {e}") - def create_client_role(self, rolerep, clientid, realm='master'): - """ Create a Keycloak client role. + def create_client_role(self, rolerep, clientid, realm: str = "master"): + """Create a Keycloak client role. :param rolerep: a RoleRepresentation of the role to be created. Must contain at minimum the field name. :param clientid: Client id for the client role @@ -1978,26 +2200,22 @@ class KeycloakAPI(object): """ cid = self.get_client_id(clientid, realm=realm) if cid is None: - self.module.fail_json(msg='Could not find client %s in realm %s' - % (clientid, realm)) + self.module.fail_json(msg=f"Could not find client {clientid} in realm {realm}") roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) try: if "composites" in rolerep: keycloak_compatible_composites = self.convert_role_composites(rolerep["composites"]) rolerep["composites"] = keycloak_compatible_composites - return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(rolerep), validate_certs=self.validate_certs) + return self._request(roles_url, method="POST", data=json.dumps(rolerep)) except Exception as e: - self.fail_open_url(e, msg='Could not create role %s for client %s in realm %s: %s' - % (rolerep['name'], clientid, realm, str(e))) + self.fail_request( + e, msg=f"Could not create role {rolerep['name']} for client {clientid} in realm {realm}: {e}" + ) def convert_role_composites(self, composites): - keycloak_compatible_composites = { - 'client': {}, - 'realm': [] - } + keycloak_compatible_composites = {"client": {}, "realm": []} for composite in composites: - if 'state' not in composite or composite['state'] == 'present': + if "state" not in composite or composite["state"] == "present": if "client_id" in composite and composite["client_id"] is not None: if composite["client_id"] not in keycloak_compatible_composites["client"]: keycloak_compatible_composites["client"][composite["client_id"]] = [] @@ -2006,8 +2224,8 @@ class KeycloakAPI(object): keycloak_compatible_composites["realm"].append(composite["name"]) return keycloak_compatible_composites - def update_client_role(self, rolerep, clientid, realm="master"): - """ Update an existing client role. + def update_client_role(self, rolerep, clientid, realm: str = "master"): + """Update an existing client role. :param rolerep: A RoleRepresentation of the updated role. :param clientid: Client id for the client role @@ -2016,25 +2234,24 @@ class KeycloakAPI(object): """ cid = self.get_client_id(clientid, realm=realm) if cid is None: - self.module.fail_json(msg='Could not find client %s in realm %s' - % (clientid, realm)) - role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep['name'], safe='')) + self.module.fail_json(msg=f"Could not find client {clientid} in realm {realm}") + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"], safe="")) try: composites = None if "composites" in rolerep: composites = copy.deepcopy(rolerep["composites"]) - del rolerep['composites'] - update_role_response = open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(rolerep), validate_certs=self.validate_certs) + del rolerep["composites"] + update_role_response = self._request(role_url, method="PUT", data=json.dumps(rolerep)) if composites is not None: self.update_role_composites(rolerep=rolerep, clientid=clientid, composites=composites, realm=realm) return update_role_response except Exception as e: - self.fail_open_url(e, msg='Could not update role %s for client %s in realm %s: %s' - % (rolerep['name'], clientid, realm, str(e))) + self.fail_request( + e, msg=f"Could not update role {rolerep['name']} for client {clientid} in realm {realm}: {e}" + ) - def delete_client_role(self, name, clientid, realm="master"): - """ Delete a role. One of name or roleid must be provided. + def delete_client_role(self, name, clientid, realm: str = "master"): + """Delete a role. One of name or roleid must be provided. :param name: The name of the role. :param clientid: Client id for the client role @@ -2042,19 +2259,29 @@ class KeycloakAPI(object): """ cid = self.get_client_id(clientid, realm=realm) if cid is None: - self.module.fail_json(msg='Could not find client %s in realm %s' - % (clientid, realm)) - role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name, safe='')) + self.module.fail_json(msg=f"Could not find client {clientid} in realm {realm}") + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name, safe="")) try: - return open_url(role_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(role_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Unable to delete role %s for client %s in realm %s: %s' - % (name, clientid, realm, str(e))) + self.fail_request(e, msg=f"Unable to delete role {name} for client {clientid} in realm {realm}: {e}") - def get_authentication_flow_by_alias(self, alias, realm='master'): + def get_authenticator_providers(self, realm: str = "master"): """ - Get an authentication flow by it's alias + Get all available authenticator providers of the realm. + :param realm: Realm. + :return: List of authenticator provider representations. + """ + try: + return self._request_and_deserialize( + URL_AUTHENTICATION_AUTHENTICATOR_PROVIDERS.format(url=self.baseurl, realm=realm), method="GET" + ) + except Exception as e: + self.fail_request(e, msg=f"Unable get authenticator providers in realm {realm}: {e}") + + def get_authentication_flow_by_alias(self, alias, realm: str = "master"): + """ + Get an authentication flow by its alias :param alias: Alias of the authentication flow to get. :param realm: Realm. :return: Authentication flow representation. @@ -2062,18 +2289,47 @@ class KeycloakAPI(object): try: authentication_flow = {} # Check if the authentication flow exists on the Keycloak serveraders - authentications = json.load(open_url(URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, realm=realm), method='GET', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, validate_certs=self.validate_certs)) + authentications = json.load( + self._request(URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, realm=realm), method="GET") + ) for authentication in authentications: if authentication["alias"] == alias: authentication_flow = authentication break return authentication_flow except Exception as e: - self.fail_open_url(e, msg="Unable get authentication flow %s: %s" % (alias, str(e))) + self.fail_request(e, msg=f"Unable get authentication flow {alias}: {e}") - def delete_authentication_flow_by_id(self, id, realm='master'): + def get_authentication_flow_by_id(self, id, realm: str = "master"): + """ + Get an authentication flow by its id + :param id: id of the authentication flow to get. + :param realm: Realm. + :return: Authentication flow representation. + """ + flow_url = URL_AUTHENTICATION_FLOW.format(url=self.baseurl, realm=realm, id=id) + + try: + return json.load(self._request(flow_url, method="GET")) + except Exception as e: + self.fail_request(e, msg=f"Could not get authentication flow {id} in realm {realm}: {e}") + + def update_authentication_flow(self, id, config, realm: str = "master"): + """ + Updates an authentication flow + :param id: id of the authentication flow to update. + :param config: Authentication flow configuration. + :param realm: Realm. + :return: Authentication flow representation. + """ + flow_url = URL_AUTHENTICATION_FLOW.format(url=self.baseurl, realm=realm, id=id) + + try: + return self._request(flow_url, method="PUT", data=json.dumps(config)) + except Exception as e: + self.fail_request(e, msg=f"Could not get authentication flow {id} in realm {realm}: {e}") + + def delete_authentication_flow_by_id(self, id, realm: str = "master"): """ Delete an authentication flow from Keycloak :param id: id of authentication flow to be deleted @@ -2083,13 +2339,11 @@ class KeycloakAPI(object): flow_url = URL_AUTHENTICATION_FLOW.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(flow_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(flow_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Could not delete authentication flow %s in realm %s: %s' - % (id, realm, str(e))) + self.fail_request(e, msg=f"Could not delete authentication flow {id} in realm {realm}: {e}") - def copy_auth_flow(self, config, realm='master'): + def copy_auth_flow(self, config, realm: str = "master"): """ Create a new authentication flow from a copy of another. :param config: Representation of the authentication flow to create. @@ -2097,36 +2351,25 @@ class KeycloakAPI(object): :return: Representation of the new authentication flow. """ try: - new_name = dict( - newName=config["alias"] - ) - open_url( + new_name = dict(newName=config["alias"]) + self._request( URL_AUTHENTICATION_FLOW_COPY.format( - url=self.baseurl, - realm=realm, - copyfrom=quote(config["copyFrom"], safe='')), - method='POST', - http_agent=self.http_agent, headers=self.restheaders, + url=self.baseurl, realm=realm, copyfrom=quote(config["copyFrom"], safe="") + ), + method="POST", data=json.dumps(new_name), - timeout=self.connection_timeout, - validate_certs=self.validate_certs) + ) flow_list = json.load( - open_url( - URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, - realm=realm), - method='GET', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs)) + self._request(URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, realm=realm), method="GET") + ) for flow in flow_list: if flow["alias"] == config["alias"]: return flow return None except Exception as e: - self.fail_open_url(e, msg='Could not copy authentication flow %s in realm %s: %s' - % (config["alias"], realm, str(e))) + self.fail_request(e, msg=f"Could not copy authentication flow {config['alias']} in realm {realm}: {e}") - def create_empty_auth_flow(self, config, realm='master'): + def create_empty_auth_flow(self, config, realm: str = "master"): """ Create a new empty authentication flow. :param config: Representation of the authentication flow to create. @@ -2135,84 +2378,92 @@ class KeycloakAPI(object): """ try: new_flow = dict( - alias=config["alias"], - providerId=config["providerId"], - description=config["description"], - topLevel=True + alias=config["alias"], providerId=config["providerId"], description=config["description"], topLevel=True + ) + self._request( + URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, realm=realm), method="POST", data=json.dumps(new_flow) ) - open_url( - URL_AUTHENTICATION_FLOWS.format( - url=self.baseurl, - realm=realm), - method='POST', - http_agent=self.http_agent, headers=self.restheaders, - data=json.dumps(new_flow), - timeout=self.connection_timeout, - validate_certs=self.validate_certs) flow_list = json.load( - open_url( - URL_AUTHENTICATION_FLOWS.format( - url=self.baseurl, - realm=realm), - method='GET', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs)) + self._request(URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, realm=realm), method="GET") + ) for flow in flow_list: if flow["alias"] == config["alias"]: return flow return None except Exception as e: - self.fail_open_url(e, msg='Could not create empty authentication flow %s in realm %s: %s' - % (config["alias"], realm, str(e))) + self.fail_request( + e, msg=f"Could not create empty authentication flow {config['alias']} in realm {realm}: {e}" + ) - def update_authentication_executions(self, flowAlias, updatedExec, realm='master'): - """ Update authentication executions + def update_authentication_executions(self, flowAlias, updatedExec, realm: str = "master"): + """Update authentication executions :param flowAlias: name of the parent flow :param updatedExec: JSON containing updated execution :return: HTTPResponse object on success """ try: - open_url( + self._request( URL_AUTHENTICATION_FLOW_EXECUTIONS.format( - url=self.baseurl, - realm=realm, - flowalias=quote(flowAlias, safe='')), - method='PUT', - http_agent=self.http_agent, headers=self.restheaders, + url=self.baseurl, realm=realm, flowalias=quote(flowAlias, safe="") + ), + method="PUT", data=json.dumps(updatedExec), - timeout=self.connection_timeout, - validate_certs=self.validate_certs) + ) except HTTPError as e: - self.fail_open_url(e, msg="Unable to update execution '%s': %s: %s %s" - % (flowAlias, repr(e), ";".join([e.url, e.msg, str(e.code), str(e.hdrs)]), str(updatedExec))) + self.fail_request( + e, + msg=f"Unable to update execution '{flowAlias}': {e!r}: {e.url};{e.msg};{e.code};{e.hdrs} {updatedExec}", + ) except Exception as e: - self.module.fail_json(msg="Unable to update executions %s: %s" % (updatedExec, str(e))) + self.module.fail_json(msg=f"Unable to update executions {updatedExec}: {e}") - def add_authenticationConfig_to_execution(self, executionId, authenticationConfig, realm='master'): - """ Add autenticatorConfig to the execution + def add_authenticationConfig_to_execution(self, executionId, authenticationConfig, realm: str = "master"): + """Add autenticatorConfig to the execution :param executionId: id of execution :param authenticationConfig: config to add to the execution :return: HTTPResponse object on success """ try: - open_url( - URL_AUTHENTICATION_EXECUTION_CONFIG.format( - url=self.baseurl, - realm=realm, - id=executionId), - method='POST', - http_agent=self.http_agent, headers=self.restheaders, + self._request( + URL_AUTHENTICATION_EXECUTION_CONFIG.format(url=self.baseurl, realm=realm, id=executionId), + method="POST", data=json.dumps(authenticationConfig), - timeout=self.connection_timeout, - validate_certs=self.validate_certs) + ) except Exception as e: - self.fail_open_url(e, msg="Unable to add authenticationConfig %s: %s" % (executionId, str(e))) + self.fail_request(e, msg=f"Unable to add authenticationConfig {executionId}: {e}") - def create_subflow(self, subflowName, flowAlias, realm='master', flowType='basic-flow'): - """ Create new sublow on the flow + def update_authentication_config(self, configId, authenticationConfig, realm: str = "master"): + """ + Updates an authentication config + :param configId: id of the authentication config + :param authenticationConfig: The authentication config + :param realm: realm of authentication config + """ + try: + self._request( + URL_AUTHENTICATION_CONFIG.format(url=self.baseurl, realm=realm, id=configId), + method="PUT", + data=json.dumps(authenticationConfig), + ) + except Exception as e: + self.fail_request(e, msg=f"Unable to update the authentication config {configId}: {e}") + + def delete_authentication_config(self, configId, realm: str = "master"): + """Delete authenticator config + + :param configId: id of authentication config + :param realm: realm of authentication config to be deleted + """ + try: + # Send a DELETE request to remove the specified authentication config from the Keycloak server. + self._request(URL_AUTHENTICATION_CONFIG.format(url=self.baseurl, realm=realm, id=configId), method="DELETE") + except Exception as e: + self.fail_request(e, msg=f"Unable to delete authentication config {configId}: {e}") + + def create_subflow(self, subflowName, flowAlias, realm: str = "master", flowType="basic-flow"): + """Create new sublow on the flow :param subflowName: name of the subflow to create :param flowAlias: name of the parent flow @@ -2223,21 +2474,18 @@ class KeycloakAPI(object): newSubFlow["alias"] = subflowName newSubFlow["provider"] = "registration-page-form" newSubFlow["type"] = flowType - open_url( + self._request( URL_AUTHENTICATION_FLOW_EXECUTIONS_FLOW.format( - url=self.baseurl, - realm=realm, - flowalias=quote(flowAlias, safe='')), - method='POST', - http_agent=self.http_agent, headers=self.restheaders, + url=self.baseurl, realm=realm, flowalias=quote(flowAlias, safe="") + ), + method="POST", data=json.dumps(newSubFlow), - timeout=self.connection_timeout, - validate_certs=self.validate_certs) + ) except Exception as e: - self.fail_open_url(e, msg="Unable to create new subflow %s: %s" % (subflowName, str(e))) + self.fail_request(e, msg=f"Unable to create new subflow {subflowName}: {e}") - def create_execution(self, execution, flowAlias, realm='master'): - """ Create new execution on the flow + def create_execution(self, execution, flowAlias, realm: str = "master"): + """Create new execution on the flow :param execution: name of execution to create :param flowAlias: name of the parent flow @@ -2247,24 +2495,23 @@ class KeycloakAPI(object): newExec = {} newExec["provider"] = execution["providerId"] newExec["requirement"] = execution["requirement"] - open_url( + self._request( URL_AUTHENTICATION_FLOW_EXECUTIONS_EXECUTION.format( - url=self.baseurl, - realm=realm, - flowalias=quote(flowAlias, safe='')), - method='POST', - http_agent=self.http_agent, headers=self.restheaders, + url=self.baseurl, realm=realm, flowalias=quote(flowAlias, safe="") + ), + method="POST", data=json.dumps(newExec), - timeout=self.connection_timeout, - validate_certs=self.validate_certs) + ) except HTTPError as e: - self.fail_open_url(e, msg="Unable to create new execution '%s' %s: %s: %s %s" - % (flowAlias, execution["providerId"], repr(e), ";".join([e.url, e.msg, str(e.code), str(e.hdrs)]), str(newExec))) + self.fail_request( + e, + msg=f"Unable to create new execution '{flowAlias}' {execution['providerId']}: {e!r}: {e.url};{e.msg};{e.code};{e.hdrs} {newExec}", + ) except Exception as e: - self.module.fail_json(msg="Unable to create new execution '%s' %s: %s" % (flowAlias, execution["providerId"], repr(e))) + self.module.fail_json(msg=f"Unable to create new execution '{flowAlias}' {execution['providerId']}: {e}") - def change_execution_priority(self, executionId, diff, realm='master'): - """ Raise or lower execution priority of diff time + def change_execution_priority(self, executionId, diff, realm: str = "master"): + """Raise or lower execution priority of diff time :param executionId: id of execution to lower priority :param realm: realm the client is in @@ -2273,31 +2520,25 @@ class KeycloakAPI(object): """ try: if diff > 0: - for i in range(diff): - open_url( + for _i in range(diff): + self._request( URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY.format( - url=self.baseurl, - realm=realm, - id=executionId), - method='POST', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs) + url=self.baseurl, realm=realm, id=executionId + ), + method="POST", + ) elif diff < 0: - for i in range(-diff): - open_url( + for _i in range(-diff): + self._request( URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY.format( - url=self.baseurl, - realm=realm, - id=executionId), - method='POST', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs) + url=self.baseurl, realm=realm, id=executionId + ), + method="POST", + ) except Exception as e: - self.fail_open_url(e, msg="Unable to change execution priority %s: %s" % (executionId, str(e))) + self.fail_request(e, msg=f"Unable to change execution priority {executionId}: {e}") - def get_executions_representation(self, config, realm='master'): + def get_executions_representation(self, config, realm: str = "master"): """ Get a representation of the executions for an authentication flow. :param config: Representation of the authentication flow @@ -2307,35 +2548,30 @@ class KeycloakAPI(object): try: # Get executions created executions = json.load( - open_url( + self._request( URL_AUTHENTICATION_FLOW_EXECUTIONS.format( - url=self.baseurl, - realm=realm, - flowalias=quote(config["alias"], safe='')), - method='GET', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs)) + url=self.baseurl, realm=realm, flowalias=quote(config["alias"], safe="") + ), + method="GET", + ) + ) for execution in executions: if "authenticationConfig" in execution: execConfigId = execution["authenticationConfig"] execConfig = json.load( - open_url( - URL_AUTHENTICATION_CONFIG.format( - url=self.baseurl, - realm=realm, - id=execConfigId), - method='GET', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs)) + self._request( + URL_AUTHENTICATION_CONFIG.format(url=self.baseurl, realm=realm, id=execConfigId), + method="GET", + ) + ) execution["authenticationConfig"] = execConfig return executions except Exception as e: - self.fail_open_url(e, msg='Could not get executions for authentication flow %s in realm %s: %s' - % (config["alias"], realm, str(e))) + self.fail_request( + e, msg=f"Could not get executions for authentication flow {config['alias']} in realm {realm}: {e}" + ) - def get_required_actions(self, realm='master'): + def get_required_actions(self, realm: str = "master"): """ Get required actions. :param realm: Realm name (not id). @@ -2344,23 +2580,14 @@ class KeycloakAPI(object): try: required_actions = json.load( - open_url( - URL_AUTHENTICATION_REQUIRED_ACTIONS.format( - url=self.baseurl, - realm=realm - ), - method='GET', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs - ) + self._request(URL_AUTHENTICATION_REQUIRED_ACTIONS.format(url=self.baseurl, realm=realm), method="GET") ) return required_actions except Exception: return None - def register_required_action(self, rep, realm='master'): + def register_required_action(self, rep, realm: str = "master"): """ Register required action. :param rep: JSON containing 'providerId', and 'name' attributes. @@ -2368,31 +2595,18 @@ class KeycloakAPI(object): :return: Representation of the required action. """ - data = { - 'name': rep['name'], - 'providerId': rep['providerId'] - } + data = {"name": rep["name"], "providerId": rep["providerId"]} try: - return open_url( - URL_AUTHENTICATION_REGISTER_REQUIRED_ACTION.format( - url=self.baseurl, - realm=realm - ), - method='POST', - http_agent=self.http_agent, headers=self.restheaders, + return self._request( + URL_AUTHENTICATION_REGISTER_REQUIRED_ACTION.format(url=self.baseurl, realm=realm), + method="POST", data=json.dumps(data), - timeout=self.connection_timeout, - validate_certs=self.validate_certs ) except Exception as e: - self.fail_open_url( - e, - msg='Unable to register required action %s in realm %s: %s' - % (rep["name"], realm, str(e)) - ) + self.fail_request(e, msg=f"Unable to register required action {rep['name']} in realm {realm}: {e}") - def update_required_action(self, alias, rep, realm='master'): + def update_required_action(self, alias, rep, realm: str = "master"): """ Update required action. :param alias: Alias of required action. @@ -2402,26 +2616,17 @@ class KeycloakAPI(object): """ try: - return open_url( + return self._request( URL_AUTHENTICATION_REQUIRED_ACTIONS_ALIAS.format( - url=self.baseurl, - alias=quote(alias, safe=''), - realm=realm + url=self.baseurl, alias=quote(alias, safe=""), realm=realm ), - method='PUT', - http_agent=self.http_agent, headers=self.restheaders, + method="PUT", data=json.dumps(rep), - timeout=self.connection_timeout, - validate_certs=self.validate_certs ) except Exception as e: - self.fail_open_url( - e, - msg='Unable to update required action %s in realm %s: %s' - % (alias, realm, str(e)) - ) + self.fail_request(e, msg=f"Unable to update required action {alias} in realm {realm}: {e}") - def delete_required_action(self, alias, realm='master'): + def delete_required_action(self, alias, realm: str = "master"): """ Delete required action. :param alias: Alias of required action. @@ -2430,121 +2635,116 @@ class KeycloakAPI(object): """ try: - return open_url( + return self._request( URL_AUTHENTICATION_REQUIRED_ACTIONS_ALIAS.format( - url=self.baseurl, - alias=quote(alias, safe=''), - realm=realm + url=self.baseurl, alias=quote(alias, safe=""), realm=realm ), - method='DELETE', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs + method="DELETE", ) except Exception as e: - self.fail_open_url( - e, - msg='Unable to delete required action %s in realm %s: %s' - % (alias, realm, str(e)) - ) + self.fail_request(e, msg=f"Unable to delete required action {alias} in realm {realm}: {e}") - def get_identity_providers(self, realm='master'): - """ Fetch representations for identity providers in a realm + def get_identity_providers(self, realm: str = "master"): + """Fetch representations for identity providers in a realm :param realm: realm to be queried :return: list of representations for identity providers """ idps_url = URL_IDENTITY_PROVIDERS.format(url=self.baseurl, realm=realm) try: - return json.loads(to_native(open_url(idps_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(idps_url, method="GET") except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of identity providers for realm %s: %s' - % (realm, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain list of identity providers for realm {realm}: {e}" + ) except Exception as e: - self.fail_open_url(e, msg='Could not obtain list of identity providers for realm %s: %s' - % (realm, str(e))) + self.fail_request(e, msg=f"Could not obtain list of identity providers for realm {realm}: {e}") - def get_identity_provider(self, alias, realm='master'): - """ Fetch identity provider representation from a realm using the idp's alias. + def get_identity_provider(self, alias, realm: str = "master"): + """Fetch identity provider representation from a realm using the idp's alias. If the identity provider does not exist, None is returned. :param alias: Alias of the identity provider to fetch. :param realm: Realm in which the identity provider resides; default 'master'. """ idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=alias) try: - return json.loads(to_native(open_url(idp_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(idp_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not fetch identity provider %s in realm %s: %s' - % (alias, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch identity provider {alias} in realm {realm}: {e}") except Exception as e: - self.module.fail_json(msg='Could not fetch identity provider %s in realm %s: %s' - % (alias, realm, str(e))) + self.module.fail_json(msg=f"Could not fetch identity provider {alias} in realm {realm}: {e}") - def create_identity_provider(self, idprep, realm='master'): - """ Create an identity provider. + def create_identity_provider(self, idprep, realm: str = "master"): + """Create an identity provider. :param idprep: Identity provider representation of the idp to be created. :param realm: Realm in which this identity provider resides, default "master". :return: HTTPResponse object on success """ idps_url = URL_IDENTITY_PROVIDERS.format(url=self.baseurl, realm=realm) try: - return open_url(idps_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(idprep), validate_certs=self.validate_certs) + return self._request(idps_url, method="POST", data=json.dumps(idprep)) except Exception as e: - self.fail_open_url(e, msg='Could not create identity provider %s in realm %s: %s' - % (idprep['alias'], realm, str(e))) + self.fail_request(e, msg=f"Could not create identity provider {idprep['alias']} in realm {realm}: {e}") - def update_identity_provider(self, idprep, realm='master'): - """ Update an existing identity provider. + def update_identity_provider(self, idprep, realm: str = "master"): + """Update an existing identity provider. :param idprep: Identity provider representation of the idp to be updated. :param realm: Realm in which this identity provider resides, default "master". :return HTTPResponse object on success """ - idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=idprep['alias']) + idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=idprep["alias"]) try: - return open_url(idp_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(idprep), validate_certs=self.validate_certs) + return self._request(idp_url, method="PUT", data=json.dumps(idprep)) except Exception as e: - self.fail_open_url(e, msg='Could not update identity provider %s in realm %s: %s' - % (idprep['alias'], realm, str(e))) + self.fail_request(e, msg=f"Could not update identity provider {idprep['alias']} in realm {realm}: {e}") - def delete_identity_provider(self, alias, realm='master'): - """ Delete an identity provider. + def delete_identity_provider(self, alias, realm: str = "master"): + """Delete an identity provider. :param alias: Alias of the identity provider. :param realm: Realm in which this identity provider resides, default "master". """ idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=alias) try: - return open_url(idp_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(idp_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Unable to delete identity provider %s in realm %s: %s' - % (alias, realm, str(e))) + self.fail_request(e, msg=f"Unable to delete identity provider {alias} in realm {realm}: {e}") - def get_identity_provider_mappers(self, alias, realm='master'): - """ Fetch representations for identity provider mappers + def get_identity_provider_mappers(self, alias, realm: str = "master"): + """Fetch representations for identity provider mappers :param alias: Alias of the identity provider. :param realm: realm to be queried :return: list of representations for identity provider mappers """ mappers_url = URL_IDENTITY_PROVIDER_MAPPERS.format(url=self.baseurl, realm=realm, alias=alias) try: - return json.loads(to_native(open_url(mappers_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(mappers_url, method="GET") except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of identity provider mappers for idp %s in realm %s: %s' - % (alias, realm, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain list of identity provider mappers for idp {alias} in realm {realm}: {e}" + ) except Exception as e: - self.fail_open_url(e, msg='Could not obtain list of identity provider mappers for idp %s in realm %s: %s' - % (alias, realm, str(e))) + self.fail_request( + e, msg=f"Could not obtain list of identity provider mappers for idp {alias} in realm {realm}: {e}" + ) - def get_identity_provider_mapper(self, mid, alias, realm='master'): - """ Fetch identity provider representation from a realm using the idp's alias. + def fetch_idp_endpoints_import_config_url(self, fromUrl, providerId="oidc", realm: str = "master"): + """Import an identity provider configuration through Keycloak server from a well-known URL. + :param fromUrl: URL to import the identity provider configuration from. + "param providerId: Provider ID of the identity provider to import, default 'oidc'. + :param realm: Realm + :return: IDP endpoins. + """ + try: + payload = {"providerId": providerId, "fromUrl": fromUrl} + idps_url = URL_IDENTITY_PROVIDER_IMPORT.format(url=self.baseurl, realm=realm) + return self._request_and_deserialize(idps_url, method="POST", data=json.dumps(payload)) + except Exception as e: + self.fail_request(e, msg=f"Could not import the IdP config in realm {realm}: {e}") + + def get_identity_provider_mapper(self, mid, alias, realm: str = "master"): + """Fetch identity provider representation from a realm using the idp's alias. If the identity provider does not exist, None is returned. :param mid: Unique ID of the mapper to fetch. :param alias: Alias of the identity provider. @@ -2552,21 +2752,21 @@ class KeycloakAPI(object): """ mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mid) try: - return json.loads(to_native(open_url(mapper_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(mapper_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not fetch mapper %s for identity provider %s in realm %s: %s' - % (mid, alias, realm, str(e))) + self.fail_request( + e, msg=f"Could not fetch mapper {mid} for identity provider {alias} in realm {realm}: {e}" + ) except Exception as e: - self.module.fail_json(msg='Could not fetch mapper %s for identity provider %s in realm %s: %s' - % (mid, alias, realm, str(e))) + self.module.fail_json( + msg=f"Could not fetch mapper {mid} for identity provider {alias} in realm {realm}: {e}" + ) - def create_identity_provider_mapper(self, mapper, alias, realm='master'): - """ Create an identity provider mapper. + def create_identity_provider_mapper(self, mapper, alias, realm: str = "master"): + """Create an identity provider mapper. :param mapper: IdentityProviderMapperRepresentation of the mapper to be created. :param alias: Alias of the identity provider. :param realm: Realm in which this identity provider resides, default "master". @@ -2574,139 +2774,126 @@ class KeycloakAPI(object): """ mappers_url = URL_IDENTITY_PROVIDER_MAPPERS.format(url=self.baseurl, realm=realm, alias=alias) try: - return open_url(mappers_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(mapper), validate_certs=self.validate_certs) + return self._request(mappers_url, method="POST", data=json.dumps(mapper)) except Exception as e: - self.fail_open_url(e, msg='Could not create identity provider mapper %s for idp %s in realm %s: %s' - % (mapper['name'], alias, realm, str(e))) + self.fail_request( + e, + msg=f"Could not create identity provider mapper {mapper['name']} for idp {alias} in realm {realm}: {e}", + ) - def update_identity_provider_mapper(self, mapper, alias, realm='master'): - """ Update an existing identity provider. + def update_identity_provider_mapper(self, mapper, alias, realm: str = "master"): + """Update an existing identity provider. :param mapper: IdentityProviderMapperRepresentation of the mapper to be updated. :param alias: Alias of the identity provider. :param realm: Realm in which this identity provider resides, default "master". :return HTTPResponse object on success """ - mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mapper['id']) + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mapper["id"]) try: - return open_url(mapper_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(mapper), validate_certs=self.validate_certs) + return self._request(mapper_url, method="PUT", data=json.dumps(mapper)) except Exception as e: - self.fail_open_url(e, msg='Could not update mapper %s for identity provider %s in realm %s: %s' - % (mapper['id'], alias, realm, str(e))) + self.fail_request( + e, msg=f"Could not update mapper {mapper['id']} for identity provider {alias} in realm {realm}: {e}" + ) - def delete_identity_provider_mapper(self, mid, alias, realm='master'): - """ Delete an identity provider. + def delete_identity_provider_mapper(self, mid, alias, realm: str = "master"): + """Delete an identity provider. :param mid: Unique ID of the mapper to delete. :param alias: Alias of the identity provider. :param realm: Realm in which this identity provider resides, default "master". """ mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mid) try: - return open_url(mapper_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(mapper_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Unable to delete mapper %s for identity provider %s in realm %s: %s' - % (mid, alias, realm, str(e))) + self.fail_request( + e, msg=f"Unable to delete mapper {mid} for identity provider {alias} in realm {realm}: {e}" + ) - def get_components(self, filter=None, realm='master'): - """ Fetch representations for components in a realm + def get_components(self, filter=None, realm: str = "master"): + """Fetch representations for components in a realm :param realm: realm to be queried :param filter: search filter :return: list of representations for components """ comps_url = URL_COMPONENTS.format(url=self.baseurl, realm=realm) if filter is not None: - comps_url += '?%s' % filter + comps_url += f"?{filter}" try: - return json.loads(to_native(open_url(comps_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(comps_url, method="GET") except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of components for realm %s: %s' - % (realm, str(e))) + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain list of components for realm {realm}: {e}" + ) except Exception as e: - self.fail_open_url(e, msg='Could not obtain list of components for realm %s: %s' - % (realm, str(e))) + self.fail_request(e, msg=f"Could not obtain list of components for realm {realm}: {e}") - def get_component(self, cid, realm='master'): - """ Fetch component representation from a realm using its cid. + def get_component(self, cid, realm: str = "master"): + """Fetch component representation from a realm using its cid. If the component does not exist, None is returned. :param cid: Unique ID of the component to fetch. :param realm: Realm in which the component resides; default 'master'. """ comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid) try: - return json.loads(to_native(open_url(comp_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(comp_url, method="GET") except HTTPError as e: - if e.code == 404: + if e.code == HTTPStatus.NOT_FOUND: return None else: - self.fail_open_url(e, msg='Could not fetch component %s in realm %s: %s' - % (cid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch component {cid} in realm {realm}: {e}") except Exception as e: - self.module.fail_json(msg='Could not fetch component %s in realm %s: %s' - % (cid, realm, str(e))) + self.module.fail_json(msg=f"Could not fetch component {cid} in realm {realm}: {e}") - def create_component(self, comprep, realm='master'): - """ Create an component. + def create_component(self, comprep, realm: str = "master"): + """Create an component. :param comprep: Component representation of the component to be created. :param realm: Realm in which this component resides, default "master". :return: Component representation of the created component """ comps_url = URL_COMPONENTS.format(url=self.baseurl, realm=realm) try: - resp = open_url(comps_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(comprep), validate_certs=self.validate_certs) - comp_url = resp.getheader('Location') + resp = self._request(comps_url, method="POST", data=json.dumps(comprep)) + comp_url = resp.getheader("Location") if comp_url is None: - self.module.fail_json(msg='Could not create component in realm %s: %s' - % (realm, 'unexpected response')) - return json.loads(to_native(open_url(comp_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + self.module.fail_json(msg=f"Could not create component in realm {realm}: unexpected response") + return self._request_and_deserialize(comp_url, method="GET") except Exception as e: - self.fail_open_url(e, msg='Could not create component in realm %s: %s' - % (realm, str(e))) + self.fail_request(e, msg=f"Could not create component in realm {realm}: {e}") - def update_component(self, comprep, realm='master'): - """ Update an existing component. + def update_component(self, comprep, realm: str = "master"): + """Update an existing component. :param comprep: Component representation of the component to be updated. :param realm: Realm in which this component resides, default "master". :return HTTPResponse object on success """ - cid = comprep.get('id') + cid = comprep.get("id") if cid is None: - self.module.fail_json(msg='Cannot update component without id') + self.module.fail_json(msg="Cannot update component without id") comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid) try: - return open_url(comp_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(comprep), validate_certs=self.validate_certs) + return self._request(comp_url, method="PUT", data=json.dumps(comprep)) except Exception as e: - self.fail_open_url(e, msg='Could not update component %s in realm %s: %s' - % (cid, realm, str(e))) + self.fail_request(e, msg=f"Could not update component {cid} in realm {realm}: {e}") - def delete_component(self, cid, realm='master'): - """ Delete an component. + def delete_component(self, cid, realm: str = "master"): + """Delete an component. :param cid: Unique ID of the component. :param realm: Realm in which this component resides, default "master". """ comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid) try: - return open_url(comp_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(comp_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Unable to delete component %s in realm %s: %s' - % (cid, realm, str(e))) + self.fail_request(e, msg=f"Unable to delete component {cid} in realm {realm}: {e}") def get_authz_authorization_scope_by_name(self, name, client_id, realm): url = URL_AUTHZ_AUTHORIZATION_SCOPES.format(url=self.baseurl, client_id=client_id, realm=realm) - search_url = "%s/search?name=%s" % (url, quote(name, safe='')) + search_url = f"{url}/search?name={quote(name, safe='')}" try: - return json.loads(to_native(open_url(search_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(search_url, method="GET") except Exception: return False @@ -2715,32 +2902,34 @@ class KeycloakAPI(object): url = URL_AUTHZ_AUTHORIZATION_SCOPES.format(url=self.baseurl, client_id=client_id, realm=realm) try: - return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(payload), validate_certs=self.validate_certs) + return self._request(url, method="POST", data=json.dumps(payload)) except Exception as e: - self.fail_open_url(e, msg='Could not create authorization scope %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + self.fail_request( + e, + msg=f"Could not create authorization scope {payload['name']} for client {client_id} in realm {realm}: {e}", + ) def update_authz_authorization_scope(self, payload, id, client_id, realm): """Update an authorization scope for a Keycloak client""" url = URL_AUTHZ_AUTHORIZATION_SCOPE.format(url=self.baseurl, id=id, client_id=client_id, realm=realm) try: - return open_url(url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(payload), validate_certs=self.validate_certs) + return self._request(url, method="PUT", data=json.dumps(payload)) except Exception as e: - self.fail_open_url(e, msg='Could not create update scope %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + self.fail_request( + e, msg=f"Could not create update scope {payload['name']} for client {client_id} in realm {realm}: {e}" + ) def remove_authz_authorization_scope(self, id, client_id, realm): """Remove an authorization scope from a Keycloak client""" url = URL_AUTHZ_AUTHORIZATION_SCOPE.format(url=self.baseurl, id=id, client_id=client_id, realm=realm) try: - return open_url(url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Could not delete scope %s for client %s in realm %s: %s' % (id, client_id, realm, str(e))) + self.fail_request(e, msg=f"Could not delete scope {id} for client {client_id} in realm {realm}: {e}") - def get_user_by_id(self, user_id, realm='master'): + def get_user_by_id(self, user_id, realm: str = "master"): """ Get a User by its ID. :param user_id: ID of the user. @@ -2748,23 +2937,13 @@ class KeycloakAPI(object): :return: Representation of the user. """ try: - user_url = URL_USER.format( - url=self.baseurl, - realm=realm, - id=user_id) - userrep = json.load( - open_url( - user_url, - method='GET', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs)) + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=user_id) + userrep = json.load(self._request(user_url, method="GET")) return userrep except Exception as e: - self.fail_open_url(e, msg='Could not get user %s in realm %s: %s' - % (user_id, realm, str(e))) + self.fail_request(e, msg=f"Could not get user {user_id} in realm {realm}: {e}") - def create_user(self, userrep, realm='master'): + def create_user(self, userrep, realm: str = "master"): """ Create a new User. :param userrep: Representation of the user to create @@ -2772,43 +2951,45 @@ class KeycloakAPI(object): :return: Representation of the user created. """ try: - if 'attributes' in userrep and isinstance(userrep['attributes'], list): - attributes = copy.deepcopy(userrep['attributes']) - userrep['attributes'] = self.convert_user_attributes_to_keycloak_dict(attributes=attributes) - users_url = URL_USERS.format( - url=self.baseurl, - realm=realm) - open_url(users_url, - method='POST', - http_agent=self.http_agent, headers=self.restheaders, - data=json.dumps(userrep), - timeout=self.connection_timeout, - validate_certs=self.validate_certs) - created_user = self.get_user_by_username( - username=userrep['username'], - realm=realm) + if "attributes" in userrep and isinstance(userrep["attributes"], list): + attributes = copy.deepcopy(userrep["attributes"]) + userrep["attributes"] = self.convert_user_attributes_to_keycloak_dict(attributes=attributes) + users_url = URL_USERS.format(url=self.baseurl, realm=realm) + response = self._request(users_url, method="POST", data=json.dumps(userrep)) + created_user = self.get_user_by_username(username=userrep["username"], realm=realm) + if created_user is None: + location = response.getheader("Location") if hasattr(response, "getheader") else None + if location: + user_id = location.rstrip("/").split("/")[-1] + created_user = self.get_user_by_id(user_id=user_id, realm=realm) + if created_user is None: + self.module.fail_json( + msg=( + f"User {userrep.get('username')} was created in realm {realm} " + "but could not be retrieved from the server" + ), + ) return created_user except Exception as e: - self.fail_open_url(e, msg='Could not create user %s in realm %s: %s' - % (userrep['username'], realm, str(e))) + self.fail_request(e, msg=f"Could not create user {userrep['username']} in realm {realm}: {e}") def convert_user_attributes_to_keycloak_dict(self, attributes): keycloak_user_attributes_dict = {} for attribute in attributes: - if ('state' not in attribute or attribute['state'] == 'present') and 'name' in attribute: - keycloak_user_attributes_dict[attribute['name']] = attribute['values'] if 'values' in attribute else [] + if ("state" not in attribute or attribute["state"] == "present") and "name" in attribute: + keycloak_user_attributes_dict[attribute["name"]] = attribute["values"] if "values" in attribute else [] return keycloak_user_attributes_dict def convert_keycloak_user_attributes_dict_to_module_list(self, attributes): module_attributes_list = [] for key in attributes: attr = {} - attr['name'] = key - attr['values'] = attributes[key] + attr["name"] = key + attr["values"] = attributes[key] module_attributes_list.append(attr) return module_attributes_list - def update_user(self, userrep, realm='master'): + def update_user(self, userrep, realm: str = "master"): """ Update a User. :param userrep: Representation of the user to update. This representation must include the ID of the user. @@ -2816,29 +2997,17 @@ class KeycloakAPI(object): :return: Representation of the updated user. """ try: - if 'attributes' in userrep and isinstance(userrep['attributes'], list): - attributes = copy.deepcopy(userrep['attributes']) - userrep['attributes'] = self.convert_user_attributes_to_keycloak_dict(attributes=attributes) - user_url = URL_USER.format( - url=self.baseurl, - realm=realm, - id=userrep["id"]) - open_url( - user_url, - method='PUT', - http_agent=self.http_agent, headers=self.restheaders, - data=json.dumps(userrep), - timeout=self.connection_timeout, - validate_certs=self.validate_certs) - updated_user = self.get_user_by_id( - user_id=userrep['id'], - realm=realm) + if "attributes" in userrep and isinstance(userrep["attributes"], list): + attributes = copy.deepcopy(userrep["attributes"]) + userrep["attributes"] = self.convert_user_attributes_to_keycloak_dict(attributes=attributes) + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=userrep["id"]) + self._request(user_url, method="PUT", data=json.dumps(userrep)) + updated_user = self.get_user_by_id(user_id=userrep["id"], realm=realm) return updated_user except Exception as e: - self.fail_open_url(e, msg='Could not update user %s in realm %s: %s' - % (userrep['username'], realm, str(e))) + self.fail_request(e, msg=f"Could not update user {userrep['username']} in realm {realm}: {e}") - def delete_user(self, user_id, realm='master'): + def delete_user(self, user_id, realm: str = "master"): """ Delete a User. :param user_id: ID of the user to be deleted @@ -2846,48 +3015,39 @@ class KeycloakAPI(object): :return: HTTP response. """ try: - user_url = URL_USER.format( - url=self.baseurl, - realm=realm, - id=user_id) - return open_url( - user_url, - method='DELETE', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs) + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=user_id) + return self._request(user_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Could not delete user %s in realm %s: %s' - % (user_id, realm, str(e))) + self.fail_request(e, msg=f"Could not delete user {user_id} in realm {realm}: {e}") - def get_user_groups(self, user_id, realm='master'): + def get_user_groups(self, user_id, realm: str = "master"): """ - Get groups for a user. + Get the group names for a user. :param user_id: User ID :param realm: Realm - :return: Representation of the client groups. + :return: The client group names as a list of strings. + """ + user_groups = self.get_user_group_details(user_id, realm) + return [user_group["name"] for user_group in user_groups if "name" in user_group] + + def get_user_group_details(self, user_id, realm: str = "master"): + """ + Get the group details for a user. + :param user_id: User ID + :param realm: Realm + :return: The client group details as a list of dictionaries. """ try: - groups = [] - user_groups_url = URL_USER_GROUPS.format( - url=self.baseurl, - realm=realm, - id=user_id) - user_groups = json.load( - open_url( - user_groups_url, - method='GET', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs)) - for user_group in user_groups: - groups.append(user_group["name"]) - return groups + user_groups_url = URL_USER_GROUPS.format(url=self.baseurl, realm=realm, id=user_id) + return self._request_and_deserialize(user_groups_url, method="GET") except Exception as e: - self.fail_open_url(e, msg='Could not get groups for user %s in realm %s: %s' - % (user_id, realm, str(e))) + self.fail_request(e, msg=f"Could not get groups for user {user_id} in realm {realm}: {e}") - def add_user_in_group(self, user_id, group_id, realm='master'): + def add_user_in_group(self, user_id, group_id, realm: str = "master"): + """DEPRECATED: Call add_user_to_group(...) instead. This method is scheduled for removal in community.general 13.0.0.""" + return self.add_user_to_group(user_id, group_id, realm) + + def add_user_to_group(self, user_id, group_id, realm: str = "master"): """ Add a user to a group. :param user_id: User ID @@ -2896,22 +3056,12 @@ class KeycloakAPI(object): :return: HTTP Response """ try: - user_group_url = URL_USER_GROUP.format( - url=self.baseurl, - realm=realm, - id=user_id, - group_id=group_id) - return open_url( - user_group_url, - method='PUT', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs) + user_group_url = URL_USER_GROUP.format(url=self.baseurl, realm=realm, id=user_id, group_id=group_id) + return self._request(user_group_url, method="PUT") except Exception as e: - self.fail_open_url(e, msg='Could not add user %s in group %s in realm %s: %s' - % (user_id, group_id, realm, str(e))) + self.fail_request(e, msg=f"Could not add user {user_id} to group {group_id} in realm {realm}: {e}") - def remove_user_from_group(self, user_id, group_id, realm='master'): + def remove_user_from_group(self, user_id, group_id, realm: str = "master"): """ Remove a user from a group for a user. :param user_id: User ID @@ -2920,71 +3070,91 @@ class KeycloakAPI(object): :return: HTTP response """ try: - user_group_url = URL_USER_GROUP.format( - url=self.baseurl, - realm=realm, - id=user_id, - group_id=group_id) - return open_url( - user_group_url, - method='DELETE', - http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs) + user_group_url = URL_USER_GROUP.format(url=self.baseurl, realm=realm, id=user_id, group_id=group_id) + return self._request(user_group_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Could not remove user %s from group %s in realm %s: %s' - % (user_id, group_id, realm, str(e))) + self.fail_request(e, msg=f"Could not remove user {user_id} from group {group_id} in realm {realm}: {e}") - def update_user_groups_membership(self, userrep, groups, realm='master'): + def update_user_groups_membership(self, userrep, groups, realm: str = "master"): """ Update user's group membership :param userrep: Representation of the user. This representation must include the ID. :param realm: Realm :return: True if group membership has been changed. False Otherwise. """ - changed = False try: - user_existing_groups = self.get_user_groups( - user_id=userrep['id'], - realm=realm) - groups_to_add_and_remove = self.extract_groups_to_add_to_and_remove_from_user(groups) - # If group membership need to be changed - if not is_struct_included(groups_to_add_and_remove['add'], user_existing_groups): - # Get available groups in the realm - realm_groups = self.get_groups(realm=realm) - for realm_group in realm_groups: - if "name" in realm_group and realm_group["name"] in groups_to_add_and_remove['add']: - self.add_user_in_group( - user_id=userrep["id"], - group_id=realm_group["id"], - realm=realm) - changed = True - elif "name" in realm_group and realm_group['name'] in groups_to_add_and_remove['remove']: - self.remove_user_from_group( - user_id=userrep['id'], - group_id=realm_group['id'], - realm=realm) - changed = True - return changed + groups_to_add, groups_to_remove = self.extract_groups_to_add_to_and_remove_from_user(groups) + if not groups_to_add and not groups_to_remove: + return False + + user_groups = self.get_user_group_details(user_id=userrep["id"], realm=realm) + user_group_names = [user_group["name"] for user_group in user_groups if "name" in user_group] + user_group_paths = [user_group["path"] for user_group in user_groups if "path" in user_group] + + groups_to_add = [ + group_to_add + for group_to_add in groups_to_add + if group_to_add not in user_group_names and group_to_add not in user_group_paths + ] + groups_to_remove = [ + group_to_remove + for group_to_remove in groups_to_remove + if group_to_remove in user_group_names or group_to_remove in user_group_paths + ] + if not groups_to_add and not groups_to_remove: + return False + + for group_to_add in groups_to_add: + realm_group = self.find_group_by_path(group_to_add, realm=realm) + if realm_group: + self.add_user_to_group(user_id=userrep["id"], group_id=realm_group["id"], realm=realm) + + for group_to_remove in groups_to_remove: + realm_group = self.find_group_by_path(group_to_remove, realm=realm) + if realm_group: + self.remove_user_from_group(user_id=userrep["id"], group_id=realm_group["id"], realm=realm) + + return True except Exception as e: - self.module.fail_json(msg='Could not update group membership for user %s in realm %s: %s' - % (userrep['id]'], realm, str(e))) + self.module.fail_json( + msg=f"Could not update group membership for user {userrep['username']} in realm {realm}: {e}" + ) def extract_groups_to_add_to_and_remove_from_user(self, groups): - groups_extract = {} groups_to_add = [] groups_to_remove = [] - if isinstance(groups, list) and len(groups) > 0: + if isinstance(groups, list): for group in groups: - group_name = group['name'] if isinstance(group, dict) and 'name' in group else group - if isinstance(group, dict) and ('state' not in group or group['state'] == 'present'): - groups_to_add.append(group_name) - else: - groups_to_remove.append(group_name) - groups_extract['add'] = groups_to_add - groups_extract['remove'] = groups_to_remove + group_name = group["name"] if isinstance(group, dict) and "name" in group else group + if isinstance(group, dict): + if "state" not in group or group["state"] == "present": + groups_to_add.append(group_name) + else: + groups_to_remove.append(group_name) + return groups_to_add, groups_to_remove - return groups_extract + def find_group_by_path(self, target, realm: str = "master"): + """ + Finds a realm group by path, e.g. '/my/group'. + The path is formed by prepending a '/' character to `target` unless it's already present. + This adds support for finding top level groups by name and subgroups by path. + """ + groups = self.get_groups(realm=realm) + path = target if target.startswith("/") else f"/{target}" + for segment in path.split("/"): + if not segment: + continue + abort = True + for group in groups: + if group["path"] == path: + return self.get_group_by_groupid(group["id"], realm=realm) + if group["name"] == segment: + groups = self.get_subgroups(group, realm=realm) + abort = False + break + if abort: + break + return None def convert_user_group_list_of_str_to_list_of_dict(self, groups): list_of_groups = [] @@ -2992,164 +3162,168 @@ class KeycloakAPI(object): for group in groups: if isinstance(group, str): group_dict = {} - group_dict['name'] = group + group_dict["name"] = group list_of_groups.append(group_dict) return list_of_groups def create_authz_custom_policy(self, policy_type, payload, client_id, realm): """Create a custom policy for a Keycloak client""" - url = URL_AUTHZ_CUSTOM_POLICY.format(url=self.baseurl, policy_type=policy_type, client_id=client_id, realm=realm) + url = URL_AUTHZ_CUSTOM_POLICY.format( + url=self.baseurl, policy_type=policy_type, client_id=client_id, realm=realm + ) try: - return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(payload), validate_certs=self.validate_certs) + return self._request(url, method="POST", data=json.dumps(payload)) except Exception as e: - self.fail_open_url(e, msg='Could not create permission %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + self.fail_request( + e, msg=f"Could not create permission {payload['name']} for client {client_id} in realm {realm}: {e}" + ) def remove_authz_custom_policy(self, policy_id, client_id, realm): """Remove a custom policy from a Keycloak client""" url = URL_AUTHZ_CUSTOM_POLICIES.format(url=self.baseurl, client_id=client_id, realm=realm) - delete_url = "%s/%s" % (url, policy_id) + delete_url = f"{url}/{policy_id}" try: - return open_url(delete_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(delete_url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Could not delete custom policy %s for client %s in realm %s: %s' % (id, client_id, realm, str(e))) + self.fail_request( + e, msg=f"Could not delete custom policy {id} for client {client_id} in realm {realm}: {e}" + ) def get_authz_permission_by_name(self, name, client_id, realm): """Get authorization permission by name""" url = URL_AUTHZ_POLICIES.format(url=self.baseurl, client_id=client_id, realm=realm) - search_url = "%s/search?name=%s" % (url, name.replace(' ', '%20')) + search_url = f"{url}/search?name={quote(name, safe='')}" try: - return json.loads(to_native(open_url(search_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(search_url, method="GET") except Exception: return False def create_authz_permission(self, payload, permission_type, client_id, realm): """Create an authorization permission for a Keycloak client""" - url = URL_AUTHZ_PERMISSIONS.format(url=self.baseurl, permission_type=permission_type, client_id=client_id, realm=realm) + url = URL_AUTHZ_PERMISSIONS.format( + url=self.baseurl, permission_type=permission_type, client_id=client_id, realm=realm + ) try: - return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(payload), validate_certs=self.validate_certs) + return self._request(url, method="POST", data=json.dumps(payload)) except Exception as e: - self.fail_open_url(e, msg='Could not create permission %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + self.fail_request( + e, msg=f"Could not create permission {payload['name']} for client {client_id} in realm {realm}: {e}" + ) def remove_authz_permission(self, id, client_id, realm): """Create an authorization permission for a Keycloak client""" url = URL_AUTHZ_POLICY.format(url=self.baseurl, id=id, client_id=client_id, realm=realm) try: - return open_url(url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs) + return self._request(url, method="DELETE") except Exception as e: - self.fail_open_url(e, msg='Could not delete permission %s for client %s in realm %s: %s' % (id, client_id, realm, str(e))) + self.fail_request(e, msg=f"Could not delete permission {id} for client {client_id} in realm {realm}: {e}") def update_authz_permission(self, payload, permission_type, id, client_id, realm): """Update a permission for a Keycloak client""" - url = URL_AUTHZ_PERMISSION.format(url=self.baseurl, permission_type=permission_type, id=id, client_id=client_id, realm=realm) + url = URL_AUTHZ_PERMISSION.format( + url=self.baseurl, permission_type=permission_type, id=id, client_id=client_id, realm=realm + ) try: - return open_url(url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(payload), validate_certs=self.validate_certs) + return self._request(url, method="PUT", data=json.dumps(payload)) except Exception as e: - self.fail_open_url(e, msg='Could not create update permission %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + self.fail_request( + e, + msg=f"Could not create update permission {payload['name']} for client {client_id} in realm {realm}: {e}", + ) def get_authz_resource_by_name(self, name, client_id, realm): """Get authorization resource by name""" url = URL_AUTHZ_RESOURCES.format(url=self.baseurl, client_id=client_id, realm=realm) - search_url = "%s/search?name=%s" % (url, name.replace(' ', '%20')) + search_url = f"{url}/search?name={quote(name, safe='')}" try: - return json.loads(to_native(open_url(search_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(search_url, method="GET") except Exception: return False def get_authz_policy_by_name(self, name, client_id, realm): """Get authorization policy by name""" url = URL_AUTHZ_POLICIES.format(url=self.baseurl, client_id=client_id, realm=realm) - search_url = "%s/search?name=%s&permission=false" % (url, name.replace(' ', '%20')) + search_url = f"{url}/search?name={quote(name, safe='')}" try: - return json.loads(to_native(open_url(search_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(search_url, method="GET") except Exception: return False - def get_client_role_scope_from_client(self, clientid, clientscopeid, realm="master"): - """ Fetch the roles associated with the client's scope for a specific client on the Keycloak server. + def get_client_role_scope_from_client(self, clientid, clientscopeid, realm: str = "master"): + """Fetch the roles associated with the client's scope for a specific client on the Keycloak server. :param clientid: ID of the client from which to obtain the associated roles. :param clientscopeid: ID of the client who owns the roles. :param realm: Realm from which to obtain the scope. :return: The client scope of roles from specified client. """ - client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format(url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid) + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format( + url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid + ) try: - return json.loads(to_native(open_url(client_role_scope_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(client_role_scope_url, method="GET") except Exception as e: - self.fail_open_url(e, msg='Could not fetch roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch roles scope for client {clientid} in realm {realm}: {e}") - def update_client_role_scope_from_client(self, payload, clientid, clientscopeid, realm="master"): - """ Update and fetch the roles associated with the client's scope on the Keycloak server. + def update_client_role_scope_from_client(self, payload, clientid, clientscopeid, realm: str = "master"): + """Update and fetch the roles associated with the client's scope on the Keycloak server. :param payload: List of roles to be added to the scope. :param clientid: ID of the client to update scope. :param clientscopeid: ID of the client who owns the roles. :param realm: Realm from which to obtain the clients. :return: The client scope of roles from specified client. """ - client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format(url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid) + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format( + url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid + ) try: - open_url(client_role_scope_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(payload), validate_certs=self.validate_certs) + self._request(client_role_scope_url, method="POST", data=json.dumps(payload)) except Exception as e: - self.fail_open_url(e, msg='Could not update roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + self.fail_request(e, msg=f"Could not update roles scope for client {clientid} in realm {realm}: {e}") return self.get_client_role_scope_from_client(clientid, clientscopeid, realm) - def delete_client_role_scope_from_client(self, payload, clientid, clientscopeid, realm="master"): - """ Delete the roles contains in the payload from the client's scope on the Keycloak server. + def delete_client_role_scope_from_client(self, payload, clientid, clientscopeid, realm: str = "master"): + """Delete the roles contains in the payload from the client's scope on the Keycloak server. :param payload: List of roles to be deleted. :param clientid: ID of the client to delete roles from scope. :param clientscopeid: ID of the client who owns the roles. :param realm: Realm from which to obtain the clients. :return: The client scope of roles from specified client. """ - client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format(url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid) + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format( + url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid + ) try: - open_url(client_role_scope_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(payload), validate_certs=self.validate_certs) + self._request(client_role_scope_url, method="DELETE", data=json.dumps(payload)) except Exception as e: - self.fail_open_url(e, msg='Could not delete roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + self.fail_request(e, msg=f"Could not delete roles scope for client {clientid} in realm {realm}: {e}") return self.get_client_role_scope_from_client(clientid, clientscopeid, realm) - def get_client_role_scope_from_realm(self, clientid, realm="master"): - """ Fetch the realm roles from the client's scope on the Keycloak server. + def get_client_role_scope_from_realm(self, clientid, realm: str = "master"): + """Fetch the realm roles from the client's scope on the Keycloak server. :param clientid: ID of the client from which to obtain the associated realm roles. :param realm: Realm from which to obtain the clients. :return: The client realm roles scope. """ client_role_scope_url = URL_CLIENT_ROLE_SCOPE_REALM.format(url=self.baseurl, realm=realm, id=clientid) try: - return json.loads(to_native(open_url(client_role_scope_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, - timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + return self._request_and_deserialize(client_role_scope_url, method="GET") except Exception as e: - self.fail_open_url(e, msg='Could not fetch roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + self.fail_request(e, msg=f"Could not fetch roles scope for client {clientid} in realm {realm}: {e}") - def update_client_role_scope_from_realm(self, payload, clientid, realm="master"): - """ Update and fetch the realm roles from the client's scope on the Keycloak server. + def update_client_role_scope_from_realm(self, payload, clientid, realm: str = "master"): + """Update and fetch the realm roles from the client's scope on the Keycloak server. :param payload: List of realm roles to add. :param clientid: ID of the client to update scope. :param realm: Realm from which to obtain the clients. @@ -3157,16 +3331,15 @@ class KeycloakAPI(object): """ client_role_scope_url = URL_CLIENT_ROLE_SCOPE_REALM.format(url=self.baseurl, realm=realm, id=clientid) try: - open_url(client_role_scope_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(payload), validate_certs=self.validate_certs) + self._request(client_role_scope_url, method="POST", data=json.dumps(payload)) except Exception as e: - self.fail_open_url(e, msg='Could not update roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + self.fail_request(e, msg=f"Could not update roles scope for client {clientid} in realm {realm}: {e}") return self.get_client_role_scope_from_realm(clientid, realm) - def delete_client_role_scope_from_realm(self, payload, clientid, realm="master"): - """ Delete the realm roles contains in the payload from the client's scope on the Keycloak server. + def delete_client_role_scope_from_realm(self, payload, clientid, realm: str = "master"): + """Delete the realm roles contains in the payload from the client's scope on the Keycloak server. :param payload: List of realm roles to delete. :param clientid: ID of the client to delete roles from scope. :param realm: Realm from which to obtain the clients. @@ -3174,18 +3347,78 @@ class KeycloakAPI(object): """ client_role_scope_url = URL_CLIENT_ROLE_SCOPE_REALM.format(url=self.baseurl, realm=realm, id=clientid) try: - open_url(client_role_scope_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(payload), validate_certs=self.validate_certs) + self._request(client_role_scope_url, method="DELETE", data=json.dumps(payload)) except Exception as e: - self.fail_open_url(e, msg='Could not delete roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + self.fail_request(e, msg=f"Could not delete roles scope for client {clientid} in realm {realm}: {e}") return self.get_client_role_scope_from_realm(clientid, realm) - def fail_open_url(self, e, msg, **kwargs): + def fail_request(self, e: Exception, msg: str, **kwargs: t.Any) -> t.NoReturn: + """Triggers a module failure. This should be called + when an exception occurs during/after a request. + Attempts to parse the exception e as an HTTP error + and append it to msg. + + :param e: exception which triggered the failure + :param msg: error message to display to the user + :param kwargs: additional arguments to pass to module.fail_json + """ try: if isinstance(e, HTTPError): - msg = "%s: %s" % (msg, to_native(e.read())) - except Exception as ingore: + msg = f"{msg}: {to_native(e.read())}" + except Exception: pass self.module.fail_json(msg, **kwargs) + + def fail_open_url(self, e: Exception, msg: str, **kwargs: t.Any) -> t.NoReturn: + """DEPRECATED: Use fail_request instead. + + Triggers a module failure. This should be called + when an exception occurs during/after a request. + Attempts to parse the exception e as an HTTP error + and append it to msg. + + :param e: exception which triggered the failure + :param msg: error message to display to the user + :param kwargs: additional arguments to pass to module.fail_json + """ + self.fail_request(e, msg, **kwargs) + + def send_execute_actions_email( + self, user_id, realm: str = "master", client_id=None, data=None, redirect_uri=None, lifespan=None + ): + """ + Send an email to the user with a link they can click to perform required actions (e.g. reset password). + Uses execute-actions-email endpoint with provided required actions (defaults handled by caller). + + :param user_id: ID of the user + :param realm: Realm name (not the ID) + :param client_id: Optional client id for the redirect + :param redirect_uri: Optional redirect uri + :param data: List of required action names (list[str]) + :param lifespan: Optional lifespan (seconds) for the action token + :return: HTTP response (204 No Content on success) + """ + try: + execute_action_url = URL_EXECUTE_ACTION.format(url=self.baseurl, realm=realm, user_id=user_id) + + params = {} + if client_id is not None: + params["client_id"] = client_id + if redirect_uri is not None: + params["redirect_uri"] = redirect_uri + if lifespan is not None: + params["lifespan"] = lifespan + + if params: + execute_action_url = f"{execute_action_url}?{urlencode(params)}" + + body = None + if data is not None: + # API expects JSON array of action names + body = json.dumps(data) + + return self._request(execute_action_url, method="PUT", data=body) + except Exception as e: + self.fail_request(e, msg=f"Could not send execute actions email to user {user_id} in realm {realm}: {e}") diff --git a/plugins/module_utils/identity/keycloak/keycloak_clientsecret.py b/plugins/module_utils/identity/keycloak/keycloak_clientsecret.py new file mode 100644 index 0000000..ffa5974 --- /dev/null +++ b/plugins/module_utils/identity/keycloak/keycloak_clientsecret.py @@ -0,0 +1,76 @@ +# Copyright (c) 2022, John Cant +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import typing as t + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + keycloak_argument_spec, +) + + +def keycloak_clientsecret_module() -> AnsibleModule: + """ + Returns an AnsibleModule definition for modules that interact with a client + secret. + + :return: argument_spec dict + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + realm=dict(default="master"), + id=dict(type="str"), + client_id=dict(type="str", aliases=["clientId"]), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [ + ["id", "client_id"], + ["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"], + ] + ), + required_together=([["auth_username", "auth_password"]]), + mutually_exclusive=[["token", "auth_realm"], ["token", "auth_username"], ["token", "auth_password"]], + ) + + return module + + +def keycloak_clientsecret_module_resolve_params(module: AnsibleModule, kc: KeycloakAPI) -> tuple[str, dict[str, t.Any]]: + """ + Given an AnsibleModule definition for keycloak_clientsecret_*, and a + KeycloakAPI client, resolve the params needed to interact with the Keycloak + client secret, looking up the client by clientId if necessary via an API + call. + + :return: tuple of id, realm + """ + + realm = module.params.get("realm") + id = module.params.get("id") + client_id = module.params.get("client_id") + + # only lookup the client_id if id isn't provided. + # in the case that both are provided, prefer the ID, since it is one + # less lookup. + if id is None: + # Due to the required_one_of spec, client_id is guaranteed to not be None + client = kc.get_client_by_clientid(client_id, realm=realm) + + if client is None: + module.fail_json(msg=f"Client does not exist {client_id}") + + id = client["id"] + + return id, realm diff --git a/plugins/modules/keycloak_authentication.py b/plugins/modules/keycloak_authentication.py new file mode 100644 index 0000000..4a45ad5 --- /dev/null +++ b/plugins/modules/keycloak_authentication.py @@ -0,0 +1,516 @@ +# Copyright (c) 2019, INSPQ +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_authentication + +short_description: Configure authentication in Keycloak + +description: + - This module actually can only make a copy of an existing authentication flow, add an execution to it and configure it. + - It can also delete the flow. +version_added: "3.0.0" + +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + realm: + description: + - The name of the realm in which is the authentication. + required: true + type: str + alias: + description: + - Alias for the authentication flow. + required: true + type: str + description: + description: + - Description of the flow. + type: str + providerId: + description: + - C(providerId) for the new flow when not copied from an existing flow. + choices: ["basic-flow", "client-flow"] + type: str + copyFrom: + description: + - C(flowAlias) of the authentication flow to use for the copy. + type: str + authenticationExecutions: + description: + - Configuration structure for the executions. + type: list + elements: dict + suboptions: + providerId: + description: + - C(providerID) for the new flow when not copied from an existing flow. + type: str + displayName: + description: + - Name of the execution or subflow to create or update. + type: str + requirement: + description: + - Control status of the subflow or execution. + choices: ["REQUIRED", "ALTERNATIVE", "DISABLED", "CONDITIONAL"] + type: str + flowAlias: + description: + - Alias of parent flow. + type: str + authenticationConfig: + description: + - Describe the config of the authentication. + type: dict + index: + description: + - Priority order of the execution. + type: int + subFlowType: + description: + - For new subflows, optionally specify the type. + - Is only used at creation. + choices: ["basic-flow", "form-flow"] + default: "basic-flow" + type: str + state: + description: + - Control if the authentication flow must exists or not. + choices: ["present", "absent"] + default: present + type: str + force: + type: bool + default: false + description: + - If V(true), allows to remove the authentication flow and recreate it. +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Philippe Gauthier (@elfelip) + - Gaëtan Daubresse (@Gaetan2907) +""" + +EXAMPLES = r""" +- name: Create an authentication flow from first broker login and add an execution to it. + middleware_automation.keycloak.keycloak_authentication: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: master + alias: "Copy of first broker login" + copyFrom: "first broker login" + authenticationExecutions: + - providerId: "test-execution1" + requirement: "REQUIRED" + authenticationConfig: + alias: "test.execution1.property" + config: + test1.property: "value" + - providerId: "test-execution2" + requirement: "REQUIRED" + authenticationConfig: + alias: "test.execution2.property" + config: + test2.property: "value" + state: present + +- name: Re-create the authentication flow + middleware_automation.keycloak.keycloak_authentication: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: master + alias: "Copy of first broker login" + copyFrom: "first broker login" + authenticationExecutions: + - providerId: "test-provisioning" + requirement: "REQUIRED" + authenticationConfig: + alias: "test.provisioning.property" + config: + test.provisioning.property: "value" + state: present + force: true + +- name: Create an authentication flow with subflow containing an execution. + middleware_automation.keycloak.keycloak_authentication: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: master + alias: "Copy of first broker login" + copyFrom: "first broker login" + authenticationExecutions: + - providerId: "test-execution1" + requirement: "REQUIRED" + - displayName: "New Subflow" + requirement: "REQUIRED" + - providerId: "auth-cookie" + requirement: "REQUIRED" + flowAlias: "New Sublow" + state: present + +- name: Remove authentication. + middleware_automation.keycloak.keycloak_authentication: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: master + alias: "Copy of first broker login" + state: absent +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +end_state: + description: Representation of the authentication after module execution. + returned: on success + type: dict + sample: + { + "alias": "Copy of first broker login", + "authenticationExecutions": [ + { + "alias": "review profile config", + "authenticationConfig": { + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + }, + "id": "6f09e4fb-aad4-496a-b873-7fa9779df6d7" + }, + "configurable": true, + "displayName": "Review Profile", + "id": "8f77dab8-2008-416f-989e-88b09ccf0b4c", + "index": 0, + "level": 0, + "providerId": "idp-review-profile", + "requirement": "REQUIRED", + "requirementChoices": [ + "REQUIRED", + "ALTERNATIVE", + "DISABLED" + ] + } + ], + "builtIn": false, + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "id": "bc228863-5887-4297-b898-4d988f8eaa5c", + "providerId": "basic-flow", + "topLevel": true + } +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + is_struct_included, + keycloak_argument_spec, +) + + +def find_exec_in_executions(searched_exec, executions): + """ + Search if exec is contained in the executions. + :param searched_exec: Execution to search for. + :param executions: List of executions. + :return: Index of the execution, -1 if not found.. + """ + for i, existing_exec in enumerate(executions, start=0): + if ( + "providerId" in existing_exec + and "providerId" in searched_exec + and existing_exec["providerId"] == searched_exec["providerId"] + or "displayName" in existing_exec + and "displayName" in searched_exec + and existing_exec["displayName"] == searched_exec["displayName"] + ): + return i + return -1 + + +def create_or_update_executions(kc, config, realm="master"): + """ + Create or update executions for an authentication flow. + :param kc: Keycloak API access. + :param config: Representation of the authentication flow including its executions. + :param realm: Realm + :return: tuple (changed, dict(before, after) + WHERE + bool changed indicates if changes have been made + dict(str, str) shows state before and after creation/update + """ + try: + changed = False + after = "" + before = "" + execution = None + if config.get("authenticationExecutions") is not None: + # Get existing executions on the Keycloak server for this alias + existing_executions = kc.get_executions_representation(config, realm=realm) + for new_exec_index, new_exec in enumerate(config["authenticationExecutions"], start=0): + if new_exec["index"] is not None: + new_exec_index = new_exec["index"] + exec_found = False + # Get flowalias parent if given + if new_exec["flowAlias"] is not None: + flow_alias_parent = new_exec["flowAlias"] + else: + flow_alias_parent = config["alias"] + # Check if same providerId or displayName name between existing and new execution + exec_index = find_exec_in_executions(new_exec, existing_executions) + if exec_index != -1: + # Remove key that doesn't need to be compared with existing_exec + exclude_key = ["flowAlias", "subFlowType"] + for key in new_exec: + if new_exec[key] is None: + exclude_key.append(key) + # Compare the executions to see if it need changes + if ( + not is_struct_included(new_exec, existing_executions[exec_index], exclude_key) + or exec_index != new_exec_index + ): + exec_found = True + if new_exec["index"] is None: + new_exec_index = exec_index + before += f"{existing_executions[exec_index]}\n" + execution = existing_executions[exec_index].copy() + # Remove exec from list in case 2 exec with same name + existing_executions[exec_index].clear() + elif new_exec["providerId"] is not None: + kc.create_execution(new_exec, flowAlias=flow_alias_parent, realm=realm) + execution = kc.get_executions_representation(config, realm=realm)[exec_index] + exec_found = True + exec_index = new_exec_index + after += f"{new_exec}\n" + elif new_exec["displayName"] is not None: + kc.create_subflow( + new_exec["displayName"], flow_alias_parent, realm=realm, flowType=new_exec["subFlowType"] + ) + execution = kc.get_executions_representation(config, realm=realm)[exec_index] + exec_found = True + exec_index = new_exec_index + after += f"{new_exec}\n" + if exec_found: + changed = True + if exec_index != -1: + # Update the existing execution + updated_exec = {"id": execution["id"]} + # add the execution configuration + if new_exec["authenticationConfig"] is not None: + if "authenticationConfig" in execution and "id" in execution["authenticationConfig"]: + kc.delete_authentication_config(execution["authenticationConfig"]["id"], realm=realm) + kc.add_authenticationConfig_to_execution( + updated_exec["id"], new_exec["authenticationConfig"], realm=realm + ) + for key in new_exec: + # remove unwanted key for the next API call + if key not in ("flowAlias", "authenticationConfig", "subFlowType"): + updated_exec[key] = new_exec[key] + if new_exec["requirement"] is not None: + if "priority" in execution: + updated_exec["priority"] = execution["priority"] + kc.update_authentication_executions(flow_alias_parent, updated_exec, realm=realm) + diff = exec_index - new_exec_index + kc.change_execution_priority(updated_exec["id"], diff, realm=realm) + after += f"{kc.get_executions_representation(config, realm=realm)[new_exec_index]}\n" + return changed, dict(before=before, after=after) + except Exception as e: + kc.module.fail_json( + msg=f"Could not create or update executions for authentication flow {config['alias']} in realm {realm}: {e}" + ) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + realm=dict(type="str", required=True), + alias=dict(type="str", required=True), + providerId=dict(type="str", choices=["basic-flow", "client-flow"]), + description=dict(type="str"), + copyFrom=dict(type="str"), + authenticationExecutions=dict( + type="list", + elements="dict", + options=dict( + providerId=dict(type="str"), + displayName=dict(type="str"), + requirement=dict(choices=["REQUIRED", "ALTERNATIVE", "DISABLED", "CONDITIONAL"], type="str"), + flowAlias=dict(type="str"), + authenticationConfig=dict(type="dict"), + index=dict(type="int"), + subFlowType=dict(choices=["basic-flow", "form-flow"], default="basic-flow", type="str"), + ), + ), + state=dict(choices=["absent", "present"], default="present"), + force=dict(type="bool", default=False), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", flow={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + state = module.params.get("state") + force = module.params.get("force") + + new_auth_repr = { + "alias": module.params.get("alias"), + "copyFrom": module.params.get("copyFrom"), + "providerId": module.params.get("providerId"), + "authenticationExecutions": module.params.get("authenticationExecutions"), + "description": module.params.get("description"), + "builtIn": module.params.get("builtIn"), + "subflow": module.params.get("subflow"), + } + + auth_repr = kc.get_authentication_flow_by_alias(alias=new_auth_repr["alias"], realm=realm) + + # Cater for when it doesn't exist (an empty dict) + if not auth_repr: + if state == "absent": + # Do nothing and exit + if module._diff: + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = f"{new_auth_repr['alias']} absent" + module.exit_json(**result) + + elif state == "present": + # Process a creation + result["changed"] = True + + if module._diff: + result["diff"] = dict(before="", after=new_auth_repr) + + if module.check_mode: + module.exit_json(**result) + + # If copyFrom is defined, create authentication flow from a copy + if "copyFrom" in new_auth_repr and new_auth_repr["copyFrom"] is not None: + auth_repr = kc.copy_auth_flow(config=new_auth_repr, realm=realm) + else: # Create an empty authentication flow + auth_repr = kc.create_empty_auth_flow(config=new_auth_repr, realm=realm) + + # If the authentication still not exist on the server, raise an exception. + if auth_repr is None: + result["msg"] = f"Authentication just created not found: {new_auth_repr}" + module.fail_json(**result) + + # Configure the executions for the flow + create_or_update_executions(kc=kc, config=new_auth_repr, realm=realm) + + # Get executions created + exec_repr = kc.get_executions_representation(config=new_auth_repr, realm=realm) + if exec_repr is not None: + auth_repr["authenticationExecutions"] = exec_repr + result["end_state"] = auth_repr + + else: + if state == "present": + # Process an update + + if force: # If force option is true + # Delete the actual authentication flow + result["changed"] = True + if module._diff: + result["diff"] = dict(before=auth_repr, after=new_auth_repr) + if module.check_mode: + module.exit_json(**result) + kc.delete_authentication_flow_by_id(id=auth_repr["id"], realm=realm) + # If copyFrom is defined, create authentication flow from a copy + if "copyFrom" in new_auth_repr and new_auth_repr["copyFrom"] is not None: + auth_repr = kc.copy_auth_flow(config=new_auth_repr, realm=realm) + else: # Create an empty authentication flow + auth_repr = kc.create_empty_auth_flow(config=new_auth_repr, realm=realm) + # If the authentication still not exist on the server, raise an exception. + if auth_repr is None: + result["msg"] = f"Authentication just created not found: {new_auth_repr}" + module.fail_json(**result) + # Configure the executions for the flow + + if module.check_mode: + module.exit_json(**result) + changed, diff = create_or_update_executions(kc=kc, config=new_auth_repr, realm=realm) + result["changed"] |= changed + + if module._diff: + result["diff"] = diff + + # Get executions created + exec_repr = kc.get_executions_representation(config=new_auth_repr, realm=realm) + if exec_repr is not None: + auth_repr["authenticationExecutions"] = exec_repr + result["end_state"] = auth_repr + + else: + # Process a deletion (because state was not 'present') + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=auth_repr, after="") + + if module.check_mode: + module.exit_json(**result) + + # delete it + kc.delete_authentication_flow_by_id(id=auth_repr["id"], realm=realm) + + result["msg"] = f"Authentication flow: {new_auth_repr['alias']} id: {auth_repr['id']} is deleted" + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_authentication_flow.py b/plugins/modules/keycloak_authentication_flow.py index c6ae5e3..8cf6f05 100644 --- a/plugins/modules/keycloak_authentication_flow.py +++ b/plugins/modules/keycloak_authentication_flow.py @@ -14,6 +14,8 @@ module: keycloak_authentication_flow short_description: Allows administration of Keycloak authentication flows via Keycloak API +version_added: "3.0.0" + description: - This module allows you to add, remove or modify Keycloak authentication flows via the Keycloak REST API. It requires access to the REST API via OpenID Connect; the user connecting and the client being @@ -105,6 +107,7 @@ options: extends_documentation_fragment: - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak - middleware_automation.keycloak.attributes author: diff --git a/plugins/modules/keycloak_authentication_required_actions.py b/plugins/modules/keycloak_authentication_required_actions.py new file mode 100644 index 0000000..a333698 --- /dev/null +++ b/plugins/modules/keycloak_authentication_required_actions.py @@ -0,0 +1,461 @@ + +# Copyright (c) 2017, Eike Frost +# Copyright (c) 2021, Christophe Gilles +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_authentication_required_actions + +short_description: Allows administration of Keycloak authentication required actions + +description: + - This module can register, update and delete required actions. + - It also filters out any duplicate required actions by their alias. The first occurrence is preserved. +version_added: "3.0.0" + +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + realm: + description: + - The name of the realm in which are the authentication required actions. + required: true + type: str + required_actions: + elements: dict + description: + - Authentication required action. + suboptions: + alias: + description: + - Unique name of the required action. + required: true + type: str + config: + description: + - Configuration for the required action. + type: dict + defaultAction: + description: + - Indicates whether new users have the required action assigned to them. + type: bool + enabled: + description: + - Indicates, if the required action is enabled or not. + type: bool + name: + description: + - Displayed name of the required action. Required for registration. + type: str + priority: + description: + - Priority of the required action. + type: int + providerId: + description: + - Provider ID of the required action. Required for registration. + type: str + type: list + state: + choices: ["absent", "present"] + description: + - Control if the realm authentication required actions are going to be registered/updated (V(present)) or deleted (V(absent)). + required: true + type: str + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Skrekulko (@Skrekulko) +""" + +EXAMPLES = r""" +- name: Register a new required action. + middleware_automation.keycloak.keycloak_authentication_required_actions: + auth_client_id: "admin-cli" + auth_keycloak_url: "http://localhost:8080" + auth_password: "password" + auth_realm: "master" + auth_username: "admin" + realm: "master" + required_actions: + - alias: "TERMS_AND_CONDITIONS" + name: "Terms and conditions" + providerId: "TERMS_AND_CONDITIONS" + enabled: true + state: "present" + +- name: Update the newly registered required action. + middleware_automation.keycloak.keycloak_authentication_required_actions: + auth_client_id: "admin-cli" + auth_keycloak_url: "http://localhost:8080" + auth_password: "password" + auth_realm: "master" + auth_username: "admin" + realm: "master" + required_actions: + - alias: "TERMS_AND_CONDITIONS" + enabled: false + state: "present" + +- name: Delete the updated registered required action. + middleware_automation.keycloak.keycloak_authentication_required_actions: + auth_client_id: "admin-cli" + auth_keycloak_url: "http://localhost:8080" + auth_password: "password" + auth_realm: "master" + auth_username: "admin" + realm: "master" + required_actions: + - alias: "TERMS_AND_CONDITIONS" + state: "absent" +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +end_state: + description: Representation of the authentication required actions after module execution. + returned: on success + type: complex + contains: + alias: + description: + - Unique name of the required action. + sample: test-provider-id + type: str + config: + description: + - Configuration for the required action. + sample: {} + type: dict + defaultAction: + description: + - Indicates whether new users have the required action assigned to them. + sample: false + type: bool + enabled: + description: + - Indicates, if the required action is enabled or not. + sample: false + type: bool + name: + description: + - Displayed name of the required action. Required for registration. + sample: Test provider ID + type: str + priority: + description: + - Priority of the required action. + sample: 90 + type: int + providerId: + description: + - Provider ID of the required action. Required for registration. + sample: test-provider-id + type: str +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def sanitize_required_actions(objects): + for obj in objects: + alias = obj["alias"] + name = obj["name"] + provider_id = obj["providerId"] + + if not name: + obj["name"] = alias + + if provider_id != alias: + obj["providerId"] = alias + + return objects + + +def filter_duplicates(objects): + filtered_objects = {} + + for obj in objects: + alias = obj["alias"] + + if alias not in filtered_objects: + filtered_objects[alias] = obj + + return list(filtered_objects.values()) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + realm=dict(type="str", required=True), + required_actions=dict( + type="list", + elements="dict", + options=dict( + alias=dict(type="str", required=True), + config=dict(type="dict"), + defaultAction=dict(type="bool"), + enabled=dict(type="bool"), + name=dict(type="str"), + priority=dict(type="int"), + providerId=dict(type="str"), + ), + ), + state=dict(type="str", choices=["present", "absent"], required=True), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", end_state={}, diff=dict(before={}, after={})) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Convenience variables + realm = module.params.get("realm") + desired_required_actions = module.params.get("required_actions") + state = module.params.get("state") + + # Sanitize required actions + desired_required_actions = sanitize_required_actions(desired_required_actions) + + # Filter out duplicate required actions + desired_required_actions = filter_duplicates(desired_required_actions) + + # Get required actions + before_required_actions = kc.get_required_actions(realm=realm) + + if state == "present": + # Initialize empty lists to hold the required actions that need to be + # registered, updated, and original ones of the updated one + register_required_actions = [] + before_updated_required_actions = [] + updated_required_actions = [] + + # Loop through the desired required actions and check if they exist in the before required actions + for desired_required_action in desired_required_actions: + found = False + + # Loop through the before required actions and check if the aliases match + for before_required_action in before_required_actions: + if desired_required_action["alias"] == before_required_action["alias"]: + update_required = False + + # Fill in the parameters + for k, v in before_required_action.items(): + if k not in desired_required_action or desired_required_action[k] is None: + desired_required_action[k] = v + + # Loop through the keys of the desired and before required actions + # and check if there are any differences between them + for key in desired_required_action.keys(): + if ( + key in before_required_action + and desired_required_action[key] != before_required_action[key] + ): + update_required = True + break + + # If there are differences, add the before and desired required actions + # to their respective lists for updating + if update_required: + before_updated_required_actions.append(before_required_action) + updated_required_actions.append(desired_required_action) + found = True + break + # If the desired required action is not found in the before required actions, + # add it to the list of required actions to register + if not found: + # Check if name is provided + if "name" not in desired_required_action or desired_required_action["name"] is None: + module.fail_json( + msg=f"Unable to register required action {desired_required_action['alias']} in realm {realm}: name not included" + ) + + # Check if provider ID is provided + if "providerId" not in desired_required_action or desired_required_action["providerId"] is None: + module.fail_json( + msg=f"Unable to register required action {desired_required_action['alias']} in realm {realm}: providerId not included" + ) + + register_required_actions.append(desired_required_action) + + # Handle diff + if module._diff: + diff_required_actions = updated_required_actions.copy() + diff_required_actions.extend(register_required_actions) + + result["diff"] = dict(before=before_updated_required_actions, after=diff_required_actions) + + # Handle changed + if register_required_actions or updated_required_actions: + result["changed"] = True + + # Handle check mode + if module.check_mode: + if register_required_actions or updated_required_actions: + result["change"] = True + result["msg"] = "Required actions would be registered/updated" + else: + result["change"] = False + result["msg"] = "Required actions would not be registered/updated" + + module.exit_json(**result) + + # Register required actions + if register_required_actions: + for register_required_action in register_required_actions: + kc.register_required_action(realm=realm, rep=register_required_action) + kc.update_required_action( + alias=register_required_action["alias"], realm=realm, rep=register_required_action + ) + + # Update required actions + if updated_required_actions: + for updated_required_action in updated_required_actions: + kc.update_required_action( + alias=updated_required_action["alias"], realm=realm, rep=updated_required_action + ) + + # Initialize the final list of required actions + final_required_actions = [] + + # Iterate over the before_required_actions + for before_required_action in before_required_actions: + # Check if there is an updated_required_action with the same alias + updated_required_action_found = False + + for updated_required_action in updated_required_actions: + if updated_required_action["alias"] == before_required_action["alias"]: + # Merge the two dictionaries, favoring the values from updated_required_action + merged_dict = {} + for key in before_required_action.keys(): + if key in updated_required_action: + merged_dict[key] = updated_required_action[key] + else: + merged_dict[key] = before_required_action[key] + + for key in updated_required_action.keys(): + if key not in before_required_action: + merged_dict[key] = updated_required_action[key] + + # Add the merged dictionary to the final list of required actions + final_required_actions.append(merged_dict) + + # Mark the updated_required_action as found + updated_required_action_found = True + + # Stop looking for updated_required_action + break + + # If no matching updated_required_action was found, add the before_required_action to the final list of required actions + if not updated_required_action_found: + final_required_actions.append(before_required_action) + + # Append any remaining updated_required_actions that were not merged + for updated_required_action in updated_required_actions: + if not any(updated_required_action["alias"] == action["alias"] for action in final_required_actions): + final_required_actions.append(updated_required_action) + + # Append newly registered required actions + final_required_actions.extend(register_required_actions) + + # Handle message and end state + result["msg"] = "Required actions registered/updated" + result["end_state"] = final_required_actions + else: + # Filter out the deleted required actions + final_required_actions = [] + delete_required_actions = [] + + for before_required_action in before_required_actions: + delete_action = False + + for desired_required_action in desired_required_actions: + if before_required_action["alias"] == desired_required_action["alias"]: + delete_action = True + break + + if not delete_action: + final_required_actions.append(before_required_action) + else: + delete_required_actions.append(before_required_action) + + # Handle diff + if module._diff: + result["diff"] = dict(before=before_required_actions, after=final_required_actions) + + # Handle changed + if delete_required_actions: + result["changed"] = True + + # Handle check mode + if module.check_mode: + if final_required_actions: + result["change"] = True + result["msg"] = "Required actions would be deleted" + else: + result["change"] = False + result["msg"] = "Required actions would not be deleted" + + module.exit_json(**result) + + # Delete required actions + if delete_required_actions: + for delete_required_action in delete_required_actions: + kc.delete_required_action(alias=delete_required_action["alias"], realm=realm) + + # Handle message and end state + result["msg"] = "Required actions deleted" + result["end_state"] = final_required_actions + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_authentication_v2.py b/plugins/modules/keycloak_authentication_v2.py new file mode 100644 index 0000000..e6234a1 --- /dev/null +++ b/plugins/modules/keycloak_authentication_v2.py @@ -0,0 +1,1038 @@ + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_authentication_v2 + +short_description: Configure authentication flows in Keycloak in an idempotent and safe manner. +version_added: "3.0.0" +description: + - This module allows the creation, deletion, and modification of Keycloak authentication flows using the Keycloak REST API. + - Rather than modifying an existing flow in place, the module re-creates the flow using the B(Safe Swap) mechanism described below. + - B(Safe Swap mechanism) - When an authentication flow needs to be updated, the module never modifies the existing flow in place. + Instead it follows a multi-step swap procedure to ensure the flow is never left in an intermediate or unsafe state during the update. + This is especially important when the flow is actively bound to a realm binding, a client override, or as an identity-provider + login-flow or post-flow, because a partially-updated flow could inadvertently allow unauthorised access. + - The B(Safe Swap mechanism) is as follows. 1. A new flow is created under a temporary name (the original alias plus a configurable suffix, + for example C(myflow_tmp_for_swap)). + 2. All executions and their configurations are added to the new temporary flow. 3. If the existing flow is currently bound to a realm or a client, + all bindings are redirected to the new temporary flow. This ensures continuity and avoids any gap in active authentication coverage. + 4. The old flow is deleted. 5. The temporary flow is renamed to the original alias, restoring the expected name. + - B(Handling pre-existing temporary swap flows) - If a temporary swap flow already exists (for example, from a previously interrupted run), + the module can optionally delete it before proceeding. This behaviour is controlled by the O(force_temporary_swap_flow_deletion) option. + If the option is V(false) and a temporary flow already exists, the module will fail to prevent accidental data loss. + - B(Idempotency) - If the existing flow already matches the desired configuration, no changes are made. + The module compares a normalised representation of the existing flow against the desired state before deciding whether to trigger the Safe Swap procedure. + - A depth of 4 sub-flows is supported. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + realm: + description: + - The name of the realm in which the authentication flow resides. + required: true + type: str + alias: + description: + - The name of the authentication flow. + required: true + type: str + description: + description: + - A human-readable description of the flow. + type: str + providerId: + description: + - The C(providerId) for the new flow. + choices: [basic-flow, client-flow] + type: str + default: basic-flow + authenticationExecutions: + description: + - The desired execution configuration for the flow. + - Executions at root level. + type: list + elements: dict + suboptions: + requirement: + description: + - The requirement status of the execution or sub-flow. + choices: [REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL] + type: str + required: true + providerId: + description: + - The C(providerId) of the execution. + type: str + authenticationConfig: + description: + - The configuration for the execution. + type: dict + suboptions: + alias: + description: Name of the execution config. + type: str + required: true + config: + description: Options for the execution config. + required: true + type: dict + subFlow: + description: + - The name of the sub-flow. + type: str + subFlowType: + description: + - The type of the sub-flow. + choices: [basic-flow, form-flow] + default: basic-flow + type: str + authenticationExecutions: + description: + - The execution configuration for executions within the sub-flow. + - Executions at sub level 1. + type: list + elements: dict + suboptions: + requirement: + description: + - The requirement status of the execution or sub-flow. + choices: [REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL] + type: str + required: true + providerId: + description: + - The C(providerId) of the execution. + type: str + authenticationConfig: + description: + - The configuration for the execution. + type: dict + suboptions: + alias: + description: Name of the execution config. + type: str + required: true + config: + description: Options for the execution config. + required: true + type: dict + subFlow: + description: + - The name of the sub-flow. + type: str + subFlowType: + description: + - The type of the sub-flow. + choices: [basic-flow, form-flow] + default: basic-flow + type: str + authenticationExecutions: + description: + - The execution configuration for executions within the sub-flow. + - Executions at sub level 2. + type: list + elements: dict + suboptions: + requirement: + description: + - The requirement status of the execution or sub-flow. + choices: [REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL] + type: str + required: true + providerId: + description: + - The C(providerId) of the execution. + type: str + authenticationConfig: + description: + - The configuration for the execution. + type: dict + suboptions: + alias: + description: Name of the execution config. + type: str + required: true + config: + description: Options for the execution config. + required: true + type: dict + subFlow: + description: + - The name of the sub-flow. + type: str + subFlowType: + description: + - The type of the sub-flow. + choices: [basic-flow, form-flow] + default: basic-flow + type: str + authenticationExecutions: + description: + - The execution configuration for executions within the sub-flow. + - Executions at sub level 3. + type: list + elements: dict + suboptions: + requirement: + description: + - The requirement status of the execution or sub-flow. + choices: [REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL] + type: str + required: true + providerId: + description: + - The C(providerId) of the execution. + type: str + authenticationConfig: + description: + - The configuration for the execution. + type: dict + suboptions: + alias: + description: Name of the execution config. + type: str + required: true + config: + description: Options for the execution config. + required: true + type: dict + subFlow: + description: + - The name of the sub-flow. + type: str + subFlowType: + description: + - The type of the sub-flow. + choices: [basic-flow, form-flow] + default: basic-flow + type: str + authenticationExecutions: + description: + - The execution configuration for executions within the sub-flow. + - Executions at sub level 4 (last sub level). + type: list + elements: dict + suboptions: + requirement: + description: + - The requirement status of the execution or sub-flow. + choices: [REQUIRED, ALTERNATIVE, DISABLED, CONDITIONAL] + type: str + required: true + providerId: + description: + - The C(providerId) of the execution. + type: str + required: true + authenticationConfig: + description: + - The configuration for the execution. + type: dict + suboptions: + alias: + description: Name of the execution config. + type: str + required: true + config: + description: Options for the execution config. + required: true + type: dict + state: + description: + - Whether the authentication flow should exist or not. + choices: [present, absent] + default: present + type: str + temporary_swap_flow_suffix: + description: + - The suffix appended to the alias of the temporary flow created during a Safe Swap update. + - The temporary flow exists only for the duration of the swap procedure and is renamed to + the original alias once all bindings have been successfully transferred. + type: str + default: _tmp_for_swap + force_temporary_swap_flow_deletion: + description: + - If C(true), any pre-existing temporary swap flow (identified by the original alias plus + O(temporary_swap_flow_suffix)) is deleted before the Safe Swap procedure begins. + - Set this to C(false) to cause the module to fail instead of silently removing a + pre-existing temporary flow, for example to avoid accidental data loss after an + interrupted run. + default: true + type: bool +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Thomas Bargetz (@thomasbargetz) +""" + +EXAMPLES = r""" +- name: Create or modify the 'My Login Flow'. + middleware_automation.keycloak.keycloak_authentication_v2: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: master + alias: My Login Flow + authenticationExecutions: + - providerId: idp-review-profile + requirement: REQUIRED + authenticationConfig: + alias: My Login Flow - review profile config + config: + update.profile.on.first.login: "missing" + - subFlow: My Login Flow - User creation or linking + requirement: REQUIRED + authenticationExecutions: + - providerId: idp-create-user-if-unique + requirement: ALTERNATIVE + authenticationConfig: + alias: My Login Flow - create unique user config + config: + require.password.update.after.registration: "true" + - providerId: auth-cookie + requirement: REQUIRED + - subFlow: My Login Flow - Handle Existing Account + requirement: ALTERNATIVE + authenticationExecutions: + - providerId: idp-confirm-link + requirement: REQUIRED + - providerId: auth-cookie + requirement: DISABLED + state: present + +- name: Remove an authentication flow. + middleware_automation.keycloak.keycloak_authentication_v2: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: master + alias: My Login Flow + state: absent +""" + +RETURN = r""" +end_state: + description: Representation of the authentication flow after module execution. + returned: on success + type: dict + sample: + { + "alias": "My Login Flow", + "builtIn": false, + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "id": "bc228863-5887-4297-b898-4d988f8eaa5c", + "providerId": "basic-flow", + "topLevel": true, + "authenticationExecutions": [ + { + "alias": "review profile config", + "authenticationConfig": { + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + }, + "id": "6f09e4fb-aad4-496a-b873-7fa9779df6d7" + }, + "configurable": true, + "displayName": "Review Profile", + "id": "8f77dab8-2008-416f-989e-88b09ccf0b4c", + "index": 0, + "level": 0, + "providerId": "idp-review-profile", + "requirement": "REQUIRED", + "requirementChoices": [ + "REQUIRED", + "ALTERNATIVE", + "DISABLED" + ] + } + ] + } +""" + +import copy +import traceback +import typing as t + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def rename_auth_flow(kc: KeycloakAPI, realm: str, flow_id: str, new_alias: str) -> None: + """Rename an existing authentication flow to a new alias. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which the flow resides. + :param flow_id: the ID of the flow to rename. + :param new_alias: the new alias to assign to the flow. + """ + auth = kc.get_authentication_flow_by_id(flow_id, realm) + if auth is not None: + updated = copy.deepcopy(auth) + updated["alias"] = new_alias + # The authenticationExecutions key is not accepted by the update endpoint. + updated.pop("authenticationExecutions", None) + kc.update_authentication_flow(flow_id, config=updated, realm=realm) + + +def append_suffix_to_executions(executions: list, suffix: str) -> None: + """Recursively append a suffix to all sub-flow and authentication config aliases. + + :param executions: a list of execution dicts to process. + :param suffix: the suffix string to append. + """ + for execution in executions: + if execution.get("authenticationConfig") is not None: + execution["authenticationConfig"]["alias"] += suffix + if execution.get("subFlow") is not None: + execution["subFlow"] += suffix + if execution.get("authenticationExecutions") is not None: + append_suffix_to_executions(execution["authenticationExecutions"], suffix) + + +def append_suffix_to_flow_names(desired_auth: dict, suffix: str) -> None: + """Append a suffix to the top-level alias and all nested aliases in a flow definition. + + This is used during the Safe Swap procedure to give the temporary flow a distinct name. + + :param desired_auth: the desired authentication flow dict (mutated in place). + :param suffix: the suffix string to append. + """ + desired_auth["alias"] += suffix + append_suffix_to_executions(desired_auth["authenticationExecutions"], suffix) + + +def remove_suffix_from_flow_names(kc: KeycloakAPI, realm: str, auth: dict, suffix: str) -> None: + """Remove a previously-added suffix from the top-level flow alias, all sub-flow aliases, + and all authentication config aliases. + + This is the final step of the Safe Swap procedure, which restores the original alias after + the temporary flow has been bound and the old flow deleted. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which the flow resides. + :param auth: the authentication flow dict (mutated in place to reflect the renamed alias). + :param suffix: the suffix to remove. + """ + new_alias = auth["alias"].removesuffix(suffix) + rename_auth_flow(kc, realm, auth["id"], new_alias) + auth["alias"] = new_alias + + executions = kc.get_executions_representation(config=auth, realm=realm) + for execution in executions: + if execution.get("authenticationFlow"): + new_sub_flow_alias = execution["displayName"].removesuffix(suffix) + rename_auth_flow(kc, realm, execution["flowId"], new_sub_flow_alias) + if execution.get("configurable"): + auth_config = execution.get("authenticationConfig") + if auth_config is not None: + auth_config["alias"] = auth_config["alias"].removesuffix(suffix) + kc.update_authentication_config( + configId=auth_config["id"], + authenticationConfig=auth_config, + realm=realm, + ) + + +def update_execution_requirement_and_config( + kc: KeycloakAPI, + realm: str, + top_level_auth: dict, + execution: dict, + parent_flow_alias: str, +) -> None: + """Update a newly-created execution to set its requirement and, if present, its configuration. + + Keycloak ignores the requirement value on execution creation and defaults all new executions + to DISABLED. A subsequent update is therefore required to apply the correct requirement. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which the flow resides. + :param top_level_auth: the top-level authentication flow dict used to look up executions. + :param execution: the desired execution dict containing 'requirement' and optionally + 'authenticationConfig'. + :param parent_flow_alias: the alias of the flow or sub-flow that owns this execution. + """ + # The most recently added execution is always last in the list. + created_exec = kc.get_executions_representation(top_level_auth, realm=realm)[-1] + exec_update = { + "id": created_exec["id"], + "providerId": execution["providerId"], + "requirement": execution["requirement"], + "priority": created_exec["priority"], + } + kc.update_authentication_executions( + flowAlias=parent_flow_alias, + updatedExec=exec_update, + realm=realm, + ) + + if execution.get("authenticationConfig") is not None: + kc.add_authenticationConfig_to_execution( + created_exec["id"], + execution["authenticationConfig"], + realm=realm, + ) + + +def create_executions( + kc: KeycloakAPI, + realm: str, + top_level_auth: dict, + executions: list, + parent_flow_alias: str, +) -> None: + """Recursively create all executions and sub-flows under the given parent flow. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which the flow resides. + :param top_level_auth: the top-level authentication flow dict, used when querying the + current execution list after each creation. + :param executions: a list of desired execution dicts to create. + :param parent_flow_alias: the alias of the flow or sub-flow that will own the executions. + """ + for desired_exec in executions: + sub_flow = desired_exec["subFlow"] + sub_flow_type = desired_exec["subFlowType"] + sub_flow_execs = desired_exec.get("authenticationExecutions") + + # Build the minimal payload accepted by the execution creation endpoint. + exec_payload = { + "providerId": desired_exec.get("providerId"), + "requirement": desired_exec["requirement"], + } + if desired_exec.get("authenticationConfig") is not None: + exec_payload["authenticationConfig"] = desired_exec["authenticationConfig"] + + if sub_flow is not None: + kc.create_subflow(sub_flow, parent_flow_alias, realm=realm, flowType=sub_flow_type) + update_execution_requirement_and_config(kc, realm, top_level_auth, exec_payload, parent_flow_alias) + if sub_flow_execs is not None: + create_executions(kc, realm, top_level_auth, sub_flow_execs, sub_flow) + else: + kc.create_execution(exec_payload, flowAlias=parent_flow_alias, realm=realm) + update_execution_requirement_and_config(kc, realm, top_level_auth, exec_payload, parent_flow_alias) + + +def create_empty_flow(kc: KeycloakAPI, realm: str, auth_flow_config: dict) -> dict: + """Create an empty authentication flow from the given configuration dict. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which to create the flow. + :param auth_flow_config: the flow configuration dict (must include at least 'alias'). + :returns: the newly-created flow dict as returned by the Keycloak API. + :raises RuntimeError: if the created flow cannot be retrieved immediately after creation. + """ + created_auth = kc.create_empty_auth_flow(config=auth_flow_config, realm=realm) + if created_auth is None: + raise RuntimeError(f"Could not retrieve the authentication flow that was just created: {auth_flow_config}") + + return created_auth + + +def desired_auth_to_diff_repr(desired_auth: dict) -> dict: + """Convert a desired authentication flow dict into the normalized representation used for + diff comparison. + + :param desired_auth: the desired flow dict as provided by the module parameters. + :returns: a normalized dict suitable for comparison with 'existing_auth_to_diff_repr'. + """ + desired_copy = copy.deepcopy(desired_auth) + desired_copy["topLevel"] = True + desired_copy["authenticationExecutions"] = desired_executions_to_diff_repr(desired_copy["authenticationExecutions"]) + return desired_copy + + +def desired_executions_to_diff_repr(desired_executions: list) -> list: + return desired_executions_to_diff_repr_rec(executions=desired_executions, level=0) + + +def desired_executions_to_diff_repr_rec(executions: list, level: int) -> list: + """Recursively flatten and normalize a nested execution list into the same flat structure + that the Keycloak API returns, so that the two representations can be compared directly. + + :param executions: a list of desired execution dicts (possibly nested). + :param level: the current nesting depth (0 for top-level executions). + :returns: a flat list of normalized execution dicts. + """ + converted: list = [] + for index, execution in enumerate(executions): + converted.append(execution) + execution["index"] = index + execution["priority"] = index + execution["level"] = level + + if execution.get("authenticationConfig") is None: + execution.pop("authenticationConfig", None) + + if execution.get("subFlow") is not None: + execution.pop("providerId", None) + execution["authenticationFlow"] = True + if execution.get("authenticationExecutions") is not None: + converted += desired_executions_to_diff_repr_rec(execution["authenticationExecutions"], level + 1) + + execution.pop("subFlow", None) + execution.pop("subFlowType", None) + execution.pop("authenticationExecutions", None) + + return converted + + +def existing_auth_to_diff_repr(kc: KeycloakAPI, realm: str, existing_auth: dict) -> dict: + """Build a normalized representation of an existing flow that can be compared with the + output of 'desired_auth_to_diff_repr'. + + Server-side fields that have no equivalent in the desired state (such as 'id', + 'builtIn', 'requirementChoices', and 'configurable') are stripped so that the + comparison is not skewed by fields the user cannot control. + + :param kc: a KeycloakAPI instance. + :param realm: the realm in which the flow resides. + :param existing_auth: the existing flow dict as returned by the Keycloak API. + :returns: a normalized dict. + """ + existing_copy = copy.deepcopy(existing_auth) + existing_copy.pop("id", None) + existing_copy.pop("builtIn", None) + + executions = kc.get_executions_representation(config=existing_copy, realm=realm) + for execution in executions: + execution.pop("id", None) + execution.pop("requirementChoices", None) + execution.pop("configurable", None) + execution.pop("displayName", None) + execution.pop("description", None) + execution.pop("flowId", None) + + if execution.get("authenticationConfig") is not None: + execution["authenticationConfig"].pop("id", None) + # The alias is already stored inside the authenticationConfig object; the + # top-level alias field on the execution is redundant and is removed. + execution.pop("alias", None) + + existing_copy["authenticationExecutions"] = executions + # Normalize a missing description to None so that it compares equal to an unset desired value. + existing_copy["description"] = existing_copy.get("description") or None + return existing_copy + + +def is_auth_flow_in_use(kc: KeycloakAPI, realm: str, existing_auth: dict) -> bool: + """Determine whether the given flow is currently bound to a realm binding, a client + authentication flow override or as an identity-provider login-flow or post-flow. + + :param kc: a KeycloakAPI instance. + :param realm: the realm to inspect. + :param existing_auth: the existing flow dict (must include 'id' and 'alias'). + :returns: True if the flow is bound anywhere, False otherwise. + """ + flow_id = existing_auth["id"] + flow_alias = existing_auth["alias"] + realm_data = kc.get_realm_by_id(realm) + if realm_data is None: + raise RuntimeError(f"realm '{realm}' does not exist") + + realm_binding_keys = [ + "browserFlow", + "registrationFlow", + "directGrantFlow", + "resetCredentialsFlow", + "clientAuthenticationFlow", + "dockerAuthenticationFlow", + "firstBrokerLoginFlow", + ] + for binding_key in realm_binding_keys: + if realm_data.get(binding_key) == flow_alias: + return True + + for client in kc.get_clients(realm=realm): + overrides = client.get("authenticationFlowBindingOverrides", {}) + if overrides.get("browser") == flow_id: + return True + if overrides.get("direct_grant") == flow_id: + return True + + for identity_provider in kc.get_identity_providers(realm): + first_broker_login_flow_alias = identity_provider.get("firstBrokerLoginFlowAlias") + post_broker_login_flow_alias = identity_provider.get("postBrokerLoginFlowAlias") + if first_broker_login_flow_alias == flow_alias or post_broker_login_flow_alias == flow_alias: + return True + + return False + + +def rebind_auth_flow_bindings( + kc: KeycloakAPI, + realm: str, + from_id: str, + from_alias: str, + to_id: str, + to_alias: str, +) -> None: + """Re-point all realm bindings, client flow overrides and identity-provider login-flows or post-flows + that reference the source flow to the target flow. + + This is the critical step in the Safe Swap procedure that transfers live bindings from the + old flow to the newly-created temporary flow without any gap in coverage. + + :param kc: a KeycloakAPI instance. + :param realm: the realm to update. + :param from_id: the ID of the flow to rebind away from. + :param from_alias: the alias of the flow to rebind away from. + :param to_id: the ID of the flow to rebind to. + :param to_alias: the alias of the flow to rebind to. + """ + realm_data = kc.get_realm_by_id(realm) + if realm_data is None: + raise RuntimeError(f"realm '{realm}' does not exist") + realm_changed = False + + realm_binding_keys = [ + "browserFlow", + "registrationFlow", + "directGrantFlow", + "resetCredentialsFlow", + "clientAuthenticationFlow", + "dockerAuthenticationFlow", + "firstBrokerLoginFlow", + ] + for binding_key in realm_binding_keys: + if realm_data.get(binding_key) == from_alias: + realm_data[binding_key] = to_alias + realm_changed = True + + if realm_changed: + kc.update_realm(realm_data, realm) + + for client in kc.get_clients(realm=realm): + overrides = client.get("authenticationFlowBindingOverrides", {}) + client_changed = False + + if overrides.get("browser") == from_id: + client["authenticationFlowBindingOverrides"]["browser"] = to_id + client_changed = True + if overrides.get("direct_grant") == from_id: + client["authenticationFlowBindingOverrides"]["direct_grant"] = to_id + client_changed = True + + if client_changed: + kc.update_client(id=client["id"], clientrep=client, realm=realm) + + for identity_provider in kc.get_identity_providers(realm): + first_broker_login_flow_alias = identity_provider.get("firstBrokerLoginFlowAlias") + post_broker_login_flow_alias = identity_provider.get("postBrokerLoginFlowAlias") + identity_provider_changed = False + + if first_broker_login_flow_alias == from_alias: + identity_provider["firstBrokerLoginFlowAlias"] = to_alias + identity_provider_changed = True + + if post_broker_login_flow_alias == from_alias: + identity_provider["postBrokerLoginFlowAlias"] = to_alias + identity_provider_changed = True + + if identity_provider_changed: + kc.update_identity_provider(idprep=identity_provider, realm=realm) + + +def delete_tmp_swap_flow_if_exists( + kc: KeycloakAPI, + realm: str, + tmp_swap_alias: str, + fallback_id: str, + fallback_alias: str, +) -> None: + """Delete a pre-existing temporary swap flow, rebinding any of its bindings back to the + fallback flow first to avoid orphaned bindings. + + :param kc: a KeycloakAPI instance. + :param realm: the realm to inspect. + :param tmp_swap_alias: the alias of the temporary swap flow to delete. + :param fallback_id: the ID of the flow to rebind to before deleting the temporary flow. + :param fallback_alias: the alias of the flow to rebind to before deleting the temporary flow. + """ + existing_tmp = kc.get_authentication_flow_by_alias(tmp_swap_alias, realm) + if existing_tmp is not None and len(existing_tmp) > 0: + rebind_auth_flow_bindings( + kc, + realm, + from_id=existing_tmp["id"], + from_alias=existing_tmp["alias"], + to_id=fallback_id, + to_alias=fallback_alias, + ) + kc.delete_authentication_flow_by_id(id=existing_tmp["id"], realm=realm) + + +def create_authentication_execution_spec_options(depth: int) -> dict[str, t.Any]: + options: dict[str, t.Any] = dict( + providerId=dict(type="str", required=depth == 0), + requirement=dict(type="str", required=True, choices=["REQUIRED", "ALTERNATIVE", "DISABLED", "CONDITIONAL"]), + authenticationConfig=dict( + type="dict", + options=dict( + alias=dict(type="str", required=True), + config=dict(type="dict", required=True), + ), + ), + ) + if depth > 0: + options.update( + subFlow=dict(type="str"), + subFlowType=dict(type="str", choices=["basic-flow", "form-flow"], default="basic-flow"), + authenticationExecutions=dict( + type="list", + elements="dict", + options=create_authentication_execution_spec_options(depth - 1), + ), + ) + return options + + +def validate_executions(kc: KeycloakAPI, realm: str, executions: dict) -> None: + valid_providers = kc.get_authenticator_providers(realm) + valid_provider_ids = {provider["id"] for provider in valid_providers} + + invalid_provider_ids = validate_executions_rec(valid_provider_ids, executions) + if len(invalid_provider_ids) > 0: + invalid_provider_ids_str = ", ".join(f"'{item}'" for item in invalid_provider_ids) + raise ValueError( + f"The following execution providerIds are unknown and therefore invalid: {invalid_provider_ids_str}" + ) + + +def validate_executions_rec(valid_provider_ids: set, executions: dict) -> list: + invalid_provider_ids = [] + for execution in executions: + provider_id = execution["providerId"] + sub_flow = execution["subFlow"] + if provider_id is not None: + if provider_id not in valid_provider_ids: + invalid_provider_ids.append(provider_id) + + if sub_flow is not None: + invalid_provider_ids.extend( + validate_executions_rec(valid_provider_ids, execution["authenticationExecutions"]) + ) + + return invalid_provider_ids + + +def main() -> None: + """Module entry point.""" + argument_spec = keycloak_argument_spec() + + meta_args = dict( + realm=dict(type="str", required=True), + alias=dict(type="str", required=True), + providerId=dict(type="str", choices=["basic-flow", "client-flow"], default="basic-flow"), + description=dict(type="str"), + authenticationExecutions=dict( + type="list", + elements="dict", + options=create_authentication_execution_spec_options(4), + ), + state=dict(choices=["absent", "present"], default="present"), + force_temporary_swap_flow_deletion=dict(type="bool", default=True), + temporary_swap_flow_suffix=dict(type="str", default="_tmp_for_swap"), + ) + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", end_state={}) + + # Obtain an access token and initialize the API client. + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + state = module.params.get("state") + force_swap_deletion = module.params.get("force_temporary_swap_flow_deletion") + tmp_swap_suffix = module.params.get("temporary_swap_flow_suffix") + + desired_auth = { + "alias": module.params.get("alias"), + "providerId": module.params.get("providerId"), + "authenticationExecutions": module.params.get("authenticationExecutions") or [], + "description": module.params.get("description") or None, + } + desired_auth_diff_repr = desired_auth_to_diff_repr(desired_auth) + + existing_auth = kc.get_authentication_flow_by_alias(alias=desired_auth["alias"], realm=realm) + existing_auth_diff_repr = None + if existing_auth: + existing_auth_diff_repr = existing_auth_to_diff_repr(kc, realm, existing_auth) + + try: + try: + validate_executions(kc, realm, desired_auth["authenticationExecutions"]) + except ValueError as e: + module.fail_json( + msg=f"Validation of executions failed: {e}", + exception=traceback.format_exc(), + ) + + if not existing_auth: + if state == "absent": + # The flow does not exist and is not required; nothing to do. + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = f"'{desired_auth['alias']}' is already absent" + module.exit_json(**result) + + elif state == "present": + # The flow does not yet exist; create it. + if module.check_mode: + result["changed"] = True + result["diff"] = dict(before="", after=desired_auth_diff_repr) + module.exit_json(**result) + + created_auth = create_empty_flow(kc, realm, desired_auth) + result["changed"] = True + + create_executions( + kc=kc, + realm=realm, + top_level_auth=created_auth, + executions=desired_auth["authenticationExecutions"], + parent_flow_alias=desired_auth["alias"], + ) + + exec_repr = kc.get_executions_representation(config=desired_auth, realm=realm) + if exec_repr is not None: + created_auth["authenticationExecutions"] = exec_repr + + result["diff"] = dict(before="", after=created_auth) + result["end_state"] = created_auth + result["msg"] = f"Authentication flow '{created_auth['alias']}' with id: '{created_auth['id']}' created" + + else: + is_flow_in_use = is_auth_flow_in_use(kc, realm, existing_auth) + + if state == "present": + change_required = existing_auth_diff_repr != desired_auth_diff_repr + if change_required: + result["diff"] = dict(before=existing_auth_diff_repr, after=desired_auth_diff_repr) + + if module.check_mode: + result["changed"] = change_required + module.exit_json(**result) + + if not change_required: + # The existing flow already matches the desired state; nothing to do. + result["end_state"] = existing_auth_diff_repr + module.exit_json(**result) + + # The flow needs to be updated. Rather than modifying the existing flow in place, + # the Safe Swap procedure is used to guarantee that the flow is never left in an + # unsafe intermediate state. See the module documentation for a full description. + if is_flow_in_use: + tmp_swap_alias = desired_auth["alias"] + tmp_swap_suffix + + if force_swap_deletion: + # Remove any leftover temporary flow from a previous interrupted run, + # rebinding its bindings back to the current flow first. + delete_tmp_swap_flow_if_exists( + kc=kc, + realm=realm, + tmp_swap_alias=tmp_swap_alias, + fallback_id=existing_auth["id"], + fallback_alias=existing_auth["alias"], + ) + + # Build the new flow under a temporary name so that both flows coexist + # during the swap. + append_suffix_to_flow_names(desired_auth, tmp_swap_suffix) + else: + # The flow is not bound anywhere; it is safe to delete it immediately and + # recreate it under the original name. + kc.delete_authentication_flow_by_id(existing_auth["id"], realm=realm) + + created_auth = create_empty_flow(kc, realm, desired_auth) + result["changed"] = True + create_executions( + kc=kc, + realm=realm, + top_level_auth=created_auth, + executions=desired_auth["authenticationExecutions"], + parent_flow_alias=desired_auth["alias"], + ) + + if is_flow_in_use: + # Transfer all bindings from the old flow to the new temporary flow, then + # delete the old flow and strip the temporary suffix from all aliases. + rebind_auth_flow_bindings( + kc=kc, + realm=realm, + from_id=existing_auth["id"], + from_alias=existing_auth["alias"], + to_id=created_auth["id"], + to_alias=created_auth["alias"], + ) + kc.delete_authentication_flow_by_id(existing_auth["id"], realm=realm) + remove_suffix_from_flow_names(kc, realm, created_auth, tmp_swap_suffix) + + created_auth_diff_repr = existing_auth_to_diff_repr(kc, realm, created_auth) + result["diff"] = dict(before=existing_auth_diff_repr, after=created_auth_diff_repr) + result["end_state"] = created_auth_diff_repr + result["msg"] = f"Authentication flow: {created_auth['alias']} id: {created_auth['id']} updated" + + else: + if is_flow_in_use: + module.fail_json( + msg=f"Flow {existing_auth['alias']} with id {existing_auth['id']} is in use and therefore cannot be deleted in realm {realm}" + ) + + result["diff"] = dict(before=existing_auth_diff_repr, after="") + if module.check_mode: + result["changed"] = True + module.exit_json(**result) + + kc.delete_authentication_flow_by_id(id=existing_auth["id"], realm=realm) + result["changed"] = True + result["msg"] = f"Authentication flow: {desired_auth['alias']} id: {existing_auth['id']} is deleted" + except Exception as e: + module.fail_json( + msg=f"An unexpected error occurred: {e}", + exception=traceback.format_exc(), + ) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_authz_authorization_scope.py b/plugins/modules/keycloak_authz_authorization_scope.py new file mode 100644 index 0000000..4c80149 --- /dev/null +++ b/plugins/modules/keycloak_authz_authorization_scope.py @@ -0,0 +1,278 @@ + +# Copyright (c) 2017, Eike Frost +# Copyright (c) 2021, Christophe Gilles +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_authz_authorization_scope + +short_description: Allows administration of Keycloak client authorization scopes using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows the administration of Keycloak client Authorization Scopes using the Keycloak REST API. Authorization + Scopes are only available if a client has Authorization enabled. + - This module requires access to the REST API using OpenID Connect; the user connecting and the realm being used must have + the requisite access rights. In a default Keycloak installation, admin-cli and an admin user would work, as would a separate + realm definition with the scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase options used by Keycloak. The Authorization Services + paths and payloads have not officially been documented by the Keycloak project. + U(https://www.puppeteers.net/blog/keycloak-authorization-services-rest-api-paths-and-payload/). +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the authorization scope. + - On V(present), the authorization scope is created (or updated if it exists already). + - On V(absent), the authorization scope is removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + name: + description: + - Name of the authorization scope to create. + type: str + required: true + display_name: + description: + - The display name of the authorization scope. + type: str + icon_uri: + description: + - The icon URI for the authorization scope. + type: str + client_id: + description: + - The C(clientId) of the Keycloak client that should have the authorization scope. + - This is usually a human-readable name of the Keycloak client. + type: str + required: true + realm: + description: + - The name of the Keycloak realm the Keycloak client is in. + type: str + required: true + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Samuli Seppänen (@mattock) +""" + +EXAMPLES = r""" +- name: Manage Keycloak file:delete authorization scope + keycloak_authz_authorization_scope: + name: file:delete + state: present + display_name: File delete + client_id: myclient + realm: myrealm + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +end_state: + description: Representation of the authorization scope after module execution. + returned: on success + type: complex + contains: + id: + description: ID of the authorization scope. + type: str + returned: when O(state=present) + sample: a6ab1cf2-1001-40ec-9f39-48f23b6a0a41 + name: + description: Name of the authorization scope. + type: str + returned: when O(state=present) + sample: file:delete + display_name: + description: Display name of the authorization scope. + type: str + returned: when O(state=present) + sample: File delete + icon_uri: + description: Icon URI for the authorization scope. + type: str + returned: when O(state=present) + sample: http://localhost/icon.png +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(type="str", default="present", choices=["present", "absent"]), + name=dict(type="str", required=True), + display_name=dict(type="str"), + icon_uri=dict(type="str"), + client_id=dict(type="str", required=True), + realm=dict(type="str", required=True), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", end_state={}, diff=dict(before={}, after={})) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Convenience variables + state = module.params.get("state") + name = module.params.get("name") + display_name = module.params.get("display_name") + icon_uri = module.params.get("icon_uri") + client_id = module.params.get("client_id") + realm = module.params.get("realm") + + # Get the "id" of the client based on the usually more human-readable + # "clientId" + cid = kc.get_client_id(client_id, realm=realm) + if not cid: + module.fail_json(msg=f"Invalid client {client_id} for realm {realm}") + + # Get current state of the Authorization Scope using its name as the search + # filter. This returns False if it is not found. + before_authz_scope = kc.get_authz_authorization_scope_by_name(name=name, client_id=cid, realm=realm) + + # Generate a JSON payload for Keycloak Admin API. This is needed for + # "create" and "update" operations. + desired_authz_scope = {} + desired_authz_scope["name"] = name + desired_authz_scope["displayName"] = display_name + desired_authz_scope["iconUri"] = icon_uri + + # Add "id" to payload for modify operations + if before_authz_scope: + desired_authz_scope["id"] = before_authz_scope["id"] + + # Ensure that undefined (null) optional parameters are presented as empty + # strings in the desired state. This makes comparisons with current state + # much easier. + for k, v in desired_authz_scope.items(): + if not v: + desired_authz_scope[k] = "" + + # Do the above for the current state + if before_authz_scope: + for k in ["displayName", "iconUri"]: + if k not in before_authz_scope: + before_authz_scope[k] = "" + + if before_authz_scope and state == "present": + changes = False + for k, v in desired_authz_scope.items(): + if before_authz_scope[k] != v: + changes = True + # At this point we know we have to update the object anyways, + # so there's no need to do more work. + break + + if changes: + if module._diff: + result["diff"] = dict(before=before_authz_scope, after=desired_authz_scope) + + if module.check_mode: + result["changed"] = True + result["msg"] = "Authorization scope would be updated" + module.exit_json(**result) + else: + kc.update_authz_authorization_scope( + payload=desired_authz_scope, id=before_authz_scope["id"], client_id=cid, realm=realm + ) + result["changed"] = True + result["msg"] = "Authorization scope updated" + else: + result["changed"] = False + result["msg"] = "Authorization scope not updated" + + result["end_state"] = desired_authz_scope + elif not before_authz_scope and state == "present": + if module._diff: + result["diff"] = dict(before={}, after=desired_authz_scope) + + if module.check_mode: + result["changed"] = True + result["msg"] = "Authorization scope would be created" + module.exit_json(**result) + else: + kc.create_authz_authorization_scope(payload=desired_authz_scope, client_id=cid, realm=realm) + result["changed"] = True + result["msg"] = "Authorization scope created" + result["end_state"] = desired_authz_scope + elif before_authz_scope and state == "absent": + if module._diff: + result["diff"] = dict(before=before_authz_scope, after={}) + + if module.check_mode: + result["changed"] = True + result["msg"] = "Authorization scope would be removed" + module.exit_json(**result) + else: + kc.remove_authz_authorization_scope(id=before_authz_scope["id"], client_id=cid, realm=realm) + result["changed"] = True + result["msg"] = "Authorization scope removed" + elif not before_authz_scope and state == "absent": + result["changed"] = False + else: + module.fail_json( + msg=f"Unable to determine what to do with authorization scope {name} of client {client_id} in realm {realm}" + ) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_authz_custom_policy.py b/plugins/modules/keycloak_authz_custom_policy.py new file mode 100644 index 0000000..1ca179f --- /dev/null +++ b/plugins/modules/keycloak_authz_custom_policy.py @@ -0,0 +1,211 @@ + +# Copyright (c) 2017, Eike Frost +# Copyright (c) 2021, Christophe Gilles +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_authz_custom_policy + +short_description: Allows administration of Keycloak client custom Javascript policies using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows the administration of Keycloak client custom Javascript using the Keycloak REST API. Custom Javascript + policies are only available if a client has Authorization enabled and if they have been deployed to the Keycloak server + as JAR files. + - This module requires access to the REST API using OpenID Connect; the user connecting and the realm being used must have + the requisite access rights. In a default Keycloak installation, admin-cli and an admin user would work, as would a separate + realm definition with the scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase options used by Keycloak. The Authorization Services + paths and payloads have not officially been documented by the Keycloak project. + U(https://www.puppeteers.net/blog/keycloak-authorization-services-rest-api-paths-and-payload/). +attributes: + check_mode: + support: full + diff_mode: + support: none + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the custom policy. + - On V(present), the custom policy is created (or updated if it exists already). + - On V(absent), the custom policy is removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + name: + description: + - Name of the custom policy to create. + type: str + required: true + policy_type: + description: + - The type of the policy. This must match the name of the custom policy deployed to the server. + - Multiple policies pointing to the same policy type can be created, but their names have to differ. + type: str + required: true + client_id: + description: + - The V(clientId) of the Keycloak client that should have the custom policy attached to it. + - This is usually a human-readable name of the Keycloak client. + type: str + required: true + realm: + description: + - The name of the Keycloak realm the Keycloak client is in. + type: str + required: true + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Samuli Seppänen (@mattock) +""" + +EXAMPLES = r""" +- name: Manage Keycloak custom authorization policy + middleware_automation.keycloak.keycloak_authz_custom_policy: + name: OnlyOwner + state: present + policy_type: script-policy.js + client_id: myclient + realm: myrealm + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +end_state: + description: Representation of the custom policy after module execution. + returned: on success + type: dict + contains: + name: + description: Name of the custom policy. + type: str + returned: when I(state=present) + sample: file:delete + policy_type: + description: Type of custom policy. + type: str + returned: when I(state=present) + sample: File delete +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(type="str", default="present", choices=["present", "absent"]), + name=dict(type="str", required=True), + policy_type=dict(type="str", required=True), + client_id=dict(type="str", required=True), + realm=dict(type="str", required=True), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Convenience variables + state = module.params.get("state") + name = module.params.get("name") + policy_type = module.params.get("policy_type") + client_id = module.params.get("client_id") + realm = module.params.get("realm") + + cid = kc.get_client_id(client_id, realm=realm) + if not cid: + module.fail_json(msg=f"Invalid client {client_id} for realm {realm}") + + before_authz_custom_policy = kc.get_authz_policy_by_name(name=name, client_id=cid, realm=realm) + + desired_authz_custom_policy = {} + desired_authz_custom_policy["name"] = name + desired_authz_custom_policy["type"] = policy_type + + # Modifying existing custom policies is not possible + if before_authz_custom_policy and state == "present": + result["msg"] = f"Custom policy {name} already exists" + result["changed"] = False + result["end_state"] = desired_authz_custom_policy + elif not before_authz_custom_policy and state == "present": + if module.check_mode: + result["msg"] = f"Would create custom policy {name}" + else: + kc.create_authz_custom_policy( + payload=desired_authz_custom_policy, policy_type=policy_type, client_id=cid, realm=realm + ) + result["msg"] = f"Custom policy {name} created" + + result["changed"] = True + result["end_state"] = desired_authz_custom_policy + elif before_authz_custom_policy and state == "absent": + if module.check_mode: + result["msg"] = f"Would remove custom policy {name}" + else: + kc.remove_authz_custom_policy(policy_id=before_authz_custom_policy["id"], client_id=cid, realm=realm) + result["msg"] = f"Custom policy {name} removed" + + result["changed"] = True + result["end_state"] = {} + elif not before_authz_custom_policy and state == "absent": + result["msg"] = f"Custom policy {name} does not exist" + result["changed"] = False + result["end_state"] = {} + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_authz_permission.py b/plugins/modules/keycloak_authz_permission.py new file mode 100644 index 0000000..4e94d80 --- /dev/null +++ b/plugins/modules/keycloak_authz_permission.py @@ -0,0 +1,441 @@ + +# Copyright (c) 2017, Eike Frost +# Copyright (c) 2021, Christophe Gilles +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_authz_permission + +version_added: "3.0.0" + +short_description: Allows administration of Keycloak client authorization permissions using Keycloak API + +description: + - This module allows the administration of Keycloak client authorization permissions using the Keycloak REST API. Authorization + permissions are only available if a client has Authorization enabled. + - There are some peculiarities in JSON paths and payloads for authorization permissions. In particular POST and PUT operations + are targeted at permission endpoints, whereas GET requests go to policies endpoint. To make matters more interesting the + JSON responses from GET requests return data in a different format than what is expected for POST and PUT. The end result + is that it is not possible to detect changes to things like policies, scopes or resources - at least not without a large + number of additional API calls. Therefore this module always updates authorization permissions instead of attempting to + determine if changes are truly needed. + - This module requires access to the REST API using OpenID Connect; the user connecting and the realm being used must have + the requisite access rights. In a default Keycloak installation, admin-cli and an admin user would work, as would a separate + realm definition with the scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase options used by Keycloak. The Authorization Services + paths and payloads have not officially been documented by the Keycloak project. + U(https://www.puppeteers.net/blog/keycloak-authorization-services-rest-api-paths-and-payload/). +attributes: + check_mode: + support: full + diff_mode: + support: none + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the authorization permission. + - On V(present), the authorization permission is created (or updated if it exists already). + - On V(absent), the authorization permission is removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + name: + description: + - Name of the authorization permission to create. + type: str + required: true + description: + description: + - The description of the authorization permission. + type: str + permission_type: + description: + - The type of authorization permission. + - On V(scope) create a scope-based permission. + - On V(resource) create a resource-based permission. + type: str + required: true + choices: + - resource + - scope + decision_strategy: + description: + - The decision strategy to use with this permission. + type: str + default: UNANIMOUS + choices: + - UNANIMOUS + - AFFIRMATIVE + - CONSENSUS + resources: + description: + - Resource names to attach to this permission. + - Scope-based permissions can only include one resource. + - Resource-based permissions can include multiple resources. + type: list + elements: str + default: [] + scopes: + description: + - Scope names to attach to this permission. + - Resource-based permissions cannot have scopes attached to them. + type: list + elements: str + default: [] + policies: + description: + - Policy names to attach to this permission. + type: list + elements: str + default: [] + client_id: + description: + - The clientId of the keycloak client that should have the authorization scope. + - This is usually a human-readable name of the Keycloak client. + type: str + required: true + realm: + description: + - The name of the Keycloak realm the Keycloak client is in. + type: str + required: true + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Samuli Seppänen (@mattock) +""" + +EXAMPLES = r""" +- name: Manage scope-based Keycloak authorization permission + middleware_automation.keycloak.keycloak_authz_permission: + name: ScopePermission + state: present + description: Scope permission + permission_type: scope + scopes: + - file:delete + policies: + - Default Policy + client_id: myclient + realm: myrealm + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + +- name: Manage resource-based Keycloak authorization permission + middleware_automation.keycloak.keycloak_authz_permission: + name: ResourcePermission + state: present + description: Resource permission + permission_type: resource + resources: + - Default Resource + policies: + - Default Policy + client_id: myclient + realm: myrealm + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +end_state: + description: Representation of the authorization permission after module execution. + returned: on success + type: complex + contains: + id: + description: ID of the authorization permission. + type: str + returned: when O(state=present) + sample: 9da05cd2-b273-4354-bbd8-0c133918a454 + name: + description: Name of the authorization permission. + type: str + returned: when O(state=present) + sample: ResourcePermission + description: + description: Description of the authorization permission. + type: str + returned: when O(state=present) + sample: Resource Permission + type: + description: Type of the authorization permission. + type: str + returned: when O(state=present) + sample: resource + decisionStrategy: + description: The decision strategy to use. + type: str + returned: when O(state=present) + sample: UNANIMOUS + logic: + description: The logic used for the permission (part of the payload, but has a fixed value). + type: str + returned: when O(state=present) + sample: POSITIVE + resources: + description: IDs of resources attached to this permission. + type: list + returned: when O(state=present) + sample: + - 49e052ff-100d-4b79-a9dd-52669ed3c11d + scopes: + description: IDs of scopes attached to this permission. + type: list + returned: when O(state=present) + sample: + - 9da05cd2-b273-4354-bbd8-0c133918a454 + policies: + description: IDs of policies attached to this permission. + type: list + returned: when O(state=present) + sample: + - 9da05cd2-b273-4354-bbd8-0c133918a454 +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(type="str", default="present", choices=["present", "absent"]), + name=dict(type="str", required=True), + description=dict(type="str"), + permission_type=dict(type="str", choices=["scope", "resource"], required=True), + decision_strategy=dict(type="str", default="UNANIMOUS", choices=["UNANIMOUS", "AFFIRMATIVE", "CONSENSUS"]), + resources=dict(type="list", elements="str", default=[]), + scopes=dict(type="list", elements="str", default=[]), + policies=dict(type="list", elements="str", default=[]), + client_id=dict(type="str", required=True), + realm=dict(type="str", required=True), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + # Convenience variables + state = module.params.get("state") + name = module.params.get("name") + description = module.params.get("description") + permission_type = module.params.get("permission_type") + decision_strategy = module.params.get("decision_strategy") + realm = module.params.get("realm") + client_id = module.params.get("client_id") + realm = module.params.get("realm") + resources = module.params.get("resources") + scopes = module.params.get("scopes") + policies = module.params.get("policies") + + if permission_type == "scope" and state == "present": + if scopes == []: + module.fail_json(msg="Scopes need to defined when permission type is set to scope!") + if len(resources) > 1: + module.fail_json(msg="Only one resource can be defined for a scope permission!") + + if permission_type == "resource" and state == "present": + if resources == []: + module.fail_json(msg="A resource need to defined when permission type is set to resource!") + if scopes != []: + module.fail_json(msg="Scopes cannot be defined when permission type is set to resource!") + + result = dict(changed=False, msg="", end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Get id of the client based on client_id + cid = kc.get_client_id(client_id, realm=realm) + if not cid: + module.fail_json(msg=f"Invalid client {client_id} for realm {realm}") + + # Get current state of the permission using its name as the search + # filter. This returns False if it is not found. + permission = kc.get_authz_permission_by_name(name=name, client_id=cid, realm=realm) + + # Generate a JSON payload for Keycloak Admin API. This is needed for + # "create" and "update" operations. + payload = {} + payload["name"] = name + payload["description"] = description + payload["type"] = permission_type + payload["decisionStrategy"] = decision_strategy + payload["logic"] = "POSITIVE" + payload["scopes"] = [] + payload["resources"] = [] + payload["policies"] = [] + + if permission_type == "scope": + # Add the resource id, if any, to the payload. While the data type is a + # list, it is only possible to have one entry in it based on what Keycloak + # Admin Console does. + r = False + resource_scopes = [] + + if resources: + r = kc.get_authz_resource_by_name(resources[0], cid, realm) + if not r: + module.fail_json( + msg=f"Unable to find authorization resource with name {resources[0]} for client {cid} in realm {realm}" + ) + else: + payload["resources"].append(r["_id"]) + + for rs in r["scopes"]: + resource_scopes.append(rs["id"]) + + # Generate a list of scope ids based on scope names. Fail if the + # defined resource does not include all those scopes. + for scope in scopes: + s = kc.get_authz_authorization_scope_by_name(scope, cid, realm) + if r and s["id"] not in resource_scopes: + module.fail_json( + msg=f"Resource {resources[0]} does not include scope {scope} for client {client_id} in realm {realm}" + ) + else: + payload["scopes"].append(s["id"]) + + elif permission_type == "resource": + if resources: + for resource in resources: + r = kc.get_authz_resource_by_name(resource, cid, realm) + if not r: + module.fail_json( + msg=f"Unable to find authorization resource with name {resource} for client {cid} in realm {realm}" + ) + else: + payload["resources"].append(r["_id"]) + + # Add policy ids, if any, to the payload. + if policies: + for policy in policies: + p = kc.get_authz_policy_by_name(policy, cid, realm) + + if p: + payload["policies"].append(p["id"]) + else: + module.fail_json( + msg=f"Unable to find authorization policy with name {policy} for client {client_id} in realm {realm}" + ) + + # Add "id" to payload for update operations + if permission: + payload["id"] = permission["id"] + + # Handle the special case where the user attempts to change an already + # existing permission's type - something that can't be done without a + # full delete -> (re)create cycle. + if permission["type"] != payload["type"]: + module.fail_json( + msg=( + f"Modifying the type of permission (scope/resource) is not supported: " + f"permission {permission['id']} of client {cid} in realm {realm} unchanged" + ) + ) + + # Updating an authorization permission is tricky for several reasons. + # Firstly, the current permission is retrieved using a _policy_ endpoint, + # not from a permission endpoint. Also, the data that is returned is in a + # different format than what is expected by the payload. So, comparing the + # current state attribute by attribute to the payload is not possible. For + # example the data contains a JSON object "config" which may contain the + # authorization type, but which is no required in the payload. Moreover, + # information about resources, scopes and policies is _not_ present in the + # data. So, there is no way to determine if any of those fields have + # changed. Therefore the best options we have are + # + # a) Always apply the payload without checking the current state + # b) Refuse to make any changes to any settings (only support create and delete) + # + # The approach taken here is a). + # + if permission and state == "present": + if module.check_mode: + result["msg"] = "Notice: unable to check current resources, scopes and policies for permission. \ + Would apply desired state without checking the current state." + else: + kc.update_authz_permission( + payload=payload, permission_type=permission_type, id=permission["id"], client_id=cid, realm=realm + ) + result["msg"] = "Notice: unable to check current resources, scopes and policies for permission. \ + Applying desired state without checking the current state." + + # Assume that something changed, although we don't know if that is the case. + result["changed"] = True + result["end_state"] = payload + elif not permission and state == "present": + if module.check_mode: + result["msg"] = "Would create permission" + else: + kc.create_authz_permission(payload=payload, permission_type=permission_type, client_id=cid, realm=realm) + result["msg"] = "Permission created" + + result["changed"] = True + result["end_state"] = payload + elif permission and state == "absent": + if module.check_mode: + result["msg"] = "Would remove permission" + else: + kc.remove_authz_permission(id=permission["id"], client_id=cid, realm=realm) + result["msg"] = "Permission removed" + + result["changed"] = True + + elif not permission and state == "absent": + result["changed"] = False + else: + module.fail_json( + msg=f"Unable to determine what to do with permission {name} of client {client_id} in realm {realm}" + ) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_authz_permission_info.py b/plugins/modules/keycloak_authz_permission_info.py new file mode 100644 index 0000000..43e509f --- /dev/null +++ b/plugins/modules/keycloak_authz_permission_info.py @@ -0,0 +1,176 @@ + +# Copyright (c) 2017, Eike Frost +# Copyright (c) 2021, Christophe Gilles +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_authz_permission_info + +version_added: "3.0.0" + +short_description: Query Keycloak client authorization permissions information + +description: + - This module allows querying information about Keycloak client authorization permissions from the resources endpoint using + the Keycloak REST API. Authorization permissions are only available if a client has Authorization enabled. + - This module requires access to the REST API using OpenID Connect; the user connecting and the realm being used must have + the requisite access rights. In a default Keycloak installation, admin-cli and an admin user would work, as would a separate + realm definition with the scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase options used by Keycloak. The Authorization Services + paths and payloads have not officially been documented by the Keycloak project. + U(https://www.puppeteers.net/blog/keycloak-authorization-services-rest-api-paths-and-payload/). +attributes: + action_group: + version_added: "3.0.0" + +options: + name: + description: + - Name of the authorization permission to create. + type: str + required: true + client_id: + description: + - The clientId of the keycloak client that should have the authorization scope. + - This is usually a human-readable name of the Keycloak client. + type: str + required: true + realm: + description: + - The name of the Keycloak realm the Keycloak client is in. + type: str + required: true + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.attributes.info_module + +author: + - Samuli Seppänen (@mattock) +""" + +EXAMPLES = r""" +- name: Query Keycloak authorization permission + middleware_automation.keycloak.keycloak_authz_permission_info: + name: ScopePermission + client_id: myclient + realm: myrealm + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +queried_state: + description: State of the resource (a policy) as seen by Keycloak. + returned: on success + type: complex + contains: + id: + description: ID of the authorization permission. + type: str + sample: 9da05cd2-b273-4354-bbd8-0c133918a454 + name: + description: Name of the authorization permission. + type: str + sample: ResourcePermission + description: + description: Description of the authorization permission. + type: str + sample: Resource Permission + type: + description: Type of the authorization permission. + type: str + sample: resource + decisionStrategy: + description: The decision strategy. + type: str + sample: UNANIMOUS + logic: + description: The logic used for the permission (part of the payload, but has a fixed value). + type: str + sample: POSITIVE + config: + description: Configuration of the permission (empty in all observed cases). + type: dict + sample: {} +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + name=dict(type="str", required=True), + client_id=dict(type="str", required=True), + realm=dict(type="str", required=True), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + # Convenience variables + name = module.params.get("name") + client_id = module.params.get("client_id") + realm = module.params.get("realm") + + result = dict(changed=False, msg="", queried_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Get id of the client based on client_id + cid = kc.get_client_id(client_id, realm=realm) + if not cid: + module.fail_json(msg=f"Invalid client {client_id} for realm {realm}") + + # Get current state of the permission using its name as the search + # filter. This returns False if it is not found. + permission = kc.get_authz_permission_by_name(name=name, client_id=cid, realm=realm) + + result["queried_state"] = permission + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_client.py b/plugins/modules/keycloak_client.py index 0afa52b..f475289 100644 --- a/plugins/modules/keycloak_client.py +++ b/plugins/modules/keycloak_client.py @@ -1,610 +1,584 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- # Copyright (c) 2017, Eike Frost # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later -from __future__ import absolute_import, division, print_function -__metaclass__ = type +from __future__ import annotations -DOCUMENTATION = ''' ---- +DOCUMENTATION = r""" module: keycloak_client -short_description: Allows administration of Keycloak clients via Keycloak API +short_description: Allows administration of Keycloak clients using Keycloak API + +version_added: "3.0.0" description: - - This module allows the administration of Keycloak clients via the Keycloak REST API. It - requires access to the REST API via OpenID Connect; the user connecting and the client being - used must have the requisite access rights. In a default Keycloak installation, admin-cli - and an admin user would work, as would a separate client definition with the scope tailored - to your needs and a user having the expected roles. - - - The names of module options are snake_cased versions of the camelCase ones found in the - Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html). - Aliases are provided so camelCased versions can be used as well. - - - The Keycloak API does not always sanity check inputs e.g. you can set - SAML-specific settings on an OpenID Connect client for instance and vice versa. Be careful. - If you do not specify a setting, usually a sensible default is chosen. - + - This module allows the administration of Keycloak clients using the Keycloak REST API. It requires access to the REST + API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. In a default + Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). Aliases are provided so camelCased versions can be used + as well. + - The Keycloak API does not always sanity check inputs, for example you can set SAML-specific settings on an OpenID Connect + client for instance and the other way around. Be careful. If you do not specify a setting, usually a sensible default + is chosen. attributes: - check_mode: - support: full - diff_mode: - support: full + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" options: - state: - description: - - State of the client - - On V(present), the client will be created (or updated if it exists already). - - On V(absent), the client will be removed if it exists - choices: ['present', 'absent'] - default: 'present' - type: str - - realm: - description: - - The realm to create the client in. - type: str - default: master - - client_id: - description: - - Client id of client to be worked on. This is usually an alphanumeric name chosen by - you. Either this or O(id) is required. If you specify both, O(id) takes precedence. - This is 'clientId' in the Keycloak REST API. - aliases: - - clientId - type: str - - id: - description: - - Id of client to be worked on. This is usually an UUID. Either this or O(client_id) - is required. If you specify both, this takes precedence. - type: str - - name: - description: - - Name of the client (this is not the same as O(client_id)). - type: str - + state: description: + - State of the client. + - On V(present), the client are created (or updated if it exists already). + - On V(absent), the client are removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + + realm: + description: + - The realm to create the client in. + type: str + default: master + + client_id: + description: + - Client ID of client to be worked on. This is usually an alphanumeric name chosen by you. Either this or O(id) is required. + If you specify both, O(id) takes precedence. This is C(clientId) in the Keycloak REST API. + aliases: + - clientId + type: str + + id: + description: + - ID of client to be worked on. This is usually an UUID. Either this or O(client_id) is required. If you specify both, + this takes precedence. + type: str + + name: + description: + - Name of the client (this is not the same as O(client_id)). + type: str + + description: + description: + - Description of the client in Keycloak. + type: str + + root_url: + description: + - Root URL appended to relative URLs for this client. This is C(rootUrl) in the Keycloak REST API. + aliases: + - rootUrl + type: str + + admin_url: + description: + - URL to the admin interface of the client. This is C(adminUrl) in the Keycloak REST API. + aliases: + - adminUrl + type: str + + base_url: + description: + - Default URL to use when the auth server needs to redirect or link back to the client This is C(baseUrl) in the Keycloak + REST API. + aliases: + - baseUrl + type: str + + enabled: + description: + - Is this client enabled or not? + type: bool + + client_authenticator_type: + description: + - How do clients authenticate with the auth server? Either V(client-secret), V(client-jwt), or V(client-x509) can be + chosen. When using V(client-secret), the module parameter O(secret) can set it, for V(client-jwt), you can use the + keys C(use.jwks.url), C(jwks.url), and C(jwt.credential.certificate) in the O(attributes) module parameter to configure + its behavior. For V(client-x509) you can use the keys C(x509.allow.regex.pattern.comparison) and C(x509.subjectdn) + in the O(attributes) module parameter to configure which certificate(s) to accept. + - This is C(clientAuthenticatorType) in the Keycloak REST API. + choices: ['client-secret', 'client-jwt', 'client-x509'] + aliases: + - clientAuthenticatorType + type: str + + secret: + description: + - When using O(client_authenticator_type=client-secret) (the default), you can specify a secret here (otherwise one + is generated if it does not exit). If changing this secret, the module does not register a change currently (but the + changed secret is saved). + type: str + + registration_access_token: + description: + - The registration access token provides access for clients to the client registration service. This is C(registrationAccessToken) + in the Keycloak REST API. + aliases: + - registrationAccessToken + type: str + + default_roles: + description: + - List of default roles for this client. If the client roles referenced do not exist yet, they are created. This is + C(defaultRoles) in the Keycloak REST API. + aliases: + - defaultRoles + type: list + elements: str + + redirect_uris: + description: + - Acceptable redirect URIs for this client. This is C(redirectUris) in the Keycloak REST API. + aliases: + - redirectUris + type: list + elements: str + + web_origins: + description: + - List of allowed CORS origins. This is C(webOrigins) in the Keycloak REST API. + aliases: + - webOrigins + type: list + elements: str + + valid_post_logout_redirect_uris: + description: + - Valid post logout redirect URIs for this client. + - This is stored as C(post.logout.redirect.uris) in the client attributes. + - Use V(+) as a single list element to allow all redirect URIs. + aliases: + - postLogoutRedirectUris + type: list + elements: str + + not_before: + description: + - Revoke any tokens issued before this date for this client (this is a UNIX timestamp). This is C(notBefore) in the + Keycloak REST API. + type: int + aliases: + - notBefore + + bearer_only: + description: + - The access type of this client is bearer-only. This is C(bearerOnly) in the Keycloak REST API. + aliases: + - bearerOnly + type: bool + + consent_required: + description: + - If enabled, users have to consent to client access. This is C(consentRequired) in the Keycloak REST API. + aliases: + - consentRequired + type: bool + + standard_flow_enabled: + description: + - Enable standard flow for this client or not (OpenID connect). This is C(standardFlowEnabled) in the Keycloak REST + API. + aliases: + - standardFlowEnabled + type: bool + + implicit_flow_enabled: + description: + - Enable implicit flow for this client or not (OpenID connect). This is C(implicitFlowEnabled) in the Keycloak REST + API. + aliases: + - implicitFlowEnabled + type: bool + + direct_access_grants_enabled: + description: + - Are direct access grants enabled for this client or not (OpenID connect). This is C(directAccessGrantsEnabled) in + the Keycloak REST API. + aliases: + - directAccessGrantsEnabled + type: bool + + service_accounts_enabled: + description: + - Are service accounts enabled for this client or not (OpenID connect). This is C(serviceAccountsEnabled) in the Keycloak + REST API. + aliases: + - serviceAccountsEnabled + type: bool + + authorization_services_enabled: + description: + - Are authorization services enabled for this client or not (OpenID connect). This is C(authorizationServicesEnabled) + in the Keycloak REST API. + aliases: + - authorizationServicesEnabled + type: bool + + public_client: + description: + - Is the access type for this client public or not. This is C(publicClient) in the Keycloak REST API. + aliases: + - publicClient + type: bool + + frontchannel_logout: + description: + - Is frontchannel logout enabled for this client or not. This is C(frontchannelLogout) in the Keycloak REST API. + aliases: + - frontchannelLogout + type: bool + + backchannel_logout_url: + description: + - URL that will cause the client to log itself out when a logout request is sent to this realm. + - This is stored as C(backchannel.logout.url) in the client attributes. + aliases: + - backchannelLogoutUrl + type: str + + protocol: + description: + - Type of client. + - At creation only, default value is V(openid-connect) if O(protocol) is omitted. + - The V(docker-v2) value was added in middleware_automation.keycloak 8.6.0. + type: str + choices: ['openid-connect', 'saml', 'docker-v2'] + + full_scope_allowed: + description: + - Is the "Full Scope Allowed" feature set for this client or not. This is C(fullScopeAllowed) in the Keycloak REST API. + aliases: + - fullScopeAllowed + type: bool + + node_re_registration_timeout: + description: + - Cluster node re-registration timeout for this client. This is C(nodeReRegistrationTimeout) in the Keycloak REST API. + type: int + aliases: + - nodeReRegistrationTimeout + + registered_nodes: + description: + - Dict of registered cluster nodes (with C(nodename) as the key and last registration time as the value). This is C(registeredNodes) + in the Keycloak REST API. + type: dict + aliases: + - registeredNodes + + client_template: + description: + - Client template to use for this client. If it does not exist this field is silently dropped. This is C(clientTemplate) + in the Keycloak REST API. + type: str + aliases: + - clientTemplate + + use_template_config: + description: + - Whether or not to use configuration from the O(client_template). This is C(useTemplateConfig) in the Keycloak REST + API. + aliases: + - useTemplateConfig + type: bool + + use_template_scope: + description: + - Whether or not to use scope configuration from the O(client_template). This is C(useTemplateScope) in the Keycloak + REST API. + aliases: + - useTemplateScope + type: bool + + use_template_mappers: + description: + - Whether or not to use mapper configuration from the O(client_template). This is C(useTemplateMappers) in the Keycloak + REST API. + aliases: + - useTemplateMappers + type: bool + + always_display_in_console: + description: + - Whether or not to display this client in account console, even if the user does not have an active session. + aliases: + - alwaysDisplayInConsole + type: bool + + surrogate_auth_required: + description: + - Whether or not surrogate auth is required. This is C(surrogateAuthRequired) in the Keycloak REST API. + aliases: + - surrogateAuthRequired + type: bool + + authorization_settings: + description: + - A data structure defining the authorization settings for this client. For reference, please see the Keycloak API docs + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html#_resourceserverrepresentation). This is C(authorizationSettings) + in the Keycloak REST API. + type: dict + aliases: + - authorizationSettings + + authentication_flow_binding_overrides: + description: + - Override realm authentication flow bindings. + type: dict + suboptions: + browser: description: - - Description of the client in Keycloak. + - Flow ID of the browser authentication flow. + - O(authentication_flow_binding_overrides.browser) and O(authentication_flow_binding_overrides.browser_name) are + mutually exclusive. type: str - root_url: + browser_name: description: - - Root URL appended to relative URLs for this client. - This is 'rootUrl' in the Keycloak REST API. + - Flow name of the browser authentication flow. + - O(authentication_flow_binding_overrides.browser) and O(authentication_flow_binding_overrides.browser_name) are + mutually exclusive. aliases: - - rootUrl + - browserName type: str - admin_url: + direct_grant: description: - - URL to the admin interface of the client. - This is 'adminUrl' in the Keycloak REST API. + - Flow ID of the direct grant authentication flow. + - O(authentication_flow_binding_overrides.direct_grant) and O(authentication_flow_binding_overrides.direct_grant_name) + are mutually exclusive. aliases: - - adminUrl + - directGrant type: str - base_url: + direct_grant_name: description: - - Default URL to use when the auth server needs to redirect or link back to the client - This is 'baseUrl' in the Keycloak REST API. + - Flow name of the direct grant authentication flow. + - O(authentication_flow_binding_overrides.direct_grant) and O(authentication_flow_binding_overrides.direct_grant_name) + are mutually exclusive. aliases: - - baseUrl + - directGrantName + type: str + aliases: + - authenticationFlowBindingOverrides + + client_scopes_behavior: + description: + - Determine how O(default_client_scopes) and O(optional_client_scopes) behave when updating an existing client. + - 'V(ignore): Do not change the client scopes of an existing client. This is the default for backward compatibility.' + - 'V(patch): Add missing scopes, do not remove any missing scopes.' + - 'V(idempotent): Make the client scopes exactly as specified, adding and removing scopes as needed.' + aliases: + - clientScopesBehavior + type: str + choices: ['ignore', 'patch', 'idempotent'] + default: 'ignore' + + default_client_scopes: + description: + - List of default client scopes. + - See O(client_scopes_behavior) for how this behaves when updating an existing client. + aliases: + - defaultClientScopes + type: list + elements: str + + optional_client_scopes: + description: + - List of optional client scopes. + - See O(client_scopes_behavior) for how this behaves when updating an existing client. + aliases: + - optionalClientScopes + type: list + elements: str + + protocol_mappers: + description: + - A list of dicts defining protocol mappers for this client. This is C(protocolMappers) in the Keycloak REST API. + aliases: + - protocolMappers + type: list + elements: dict + suboptions: + consentRequired: + description: + - Specifies whether a user needs to provide consent to a client for this mapper to be active. + type: bool + + consentText: + description: + - The human-readable name of the consent the user is presented to accept. type: str - enabled: + id: description: - - Is this client enabled or not? - type: bool - - client_authenticator_type: - description: - - How do clients authenticate with the auth server? Either V(client-secret), - V(client-jwt), or V(client-x509) can be chosen. When using V(client-secret), the module parameter - O(secret) can set it, for V(client-jwt), you can use the keys C(use.jwks.url), - C(jwks.url), and C(jwt.credential.certificate) in the O(attributes) module parameter - to configure its behavior. For V(client-x509) you can use the keys C(x509.allow.regex.pattern.comparison) - and C(x509.subjectdn) in the O(attributes) module parameter to configure which certificate(s) to accept. - - This is 'clientAuthenticatorType' in the Keycloak REST API. - choices: ['client-secret', 'client-jwt', 'client-x509'] - aliases: - - clientAuthenticatorType + - Usually a UUID specifying the internal ID of this protocol mapper instance. type: str - secret: + name: description: - - When using O(client_authenticator_type=client-secret) (the default), you can - specify a secret here (otherwise one will be generated if it does not exit). If - changing this secret, the module will not register a change currently (but the - changed secret will be saved). + - The name of this protocol mapper. type: str - registration_access_token: + protocol: description: - - The registration access token provides access for clients to the client registration - service. - This is 'registrationAccessToken' in the Keycloak REST API. - aliases: - - registrationAccessToken - type: str - - default_roles: - description: - - list of default roles for this client. If the client roles referenced do not exist - yet, they will be created. - This is 'defaultRoles' in the Keycloak REST API. - aliases: - - defaultRoles - type: list - elements: str - - redirect_uris: - description: - - Acceptable redirect URIs for this client. - This is 'redirectUris' in the Keycloak REST API. - aliases: - - redirectUris - type: list - elements: str - - web_origins: - description: - - List of allowed CORS origins. - This is 'webOrigins' in the Keycloak REST API. - aliases: - - webOrigins - type: list - elements: str - - not_before: - description: - - Revoke any tokens issued before this date for this client (this is a UNIX timestamp). - This is 'notBefore' in the Keycloak REST API. - type: int - aliases: - - notBefore - - bearer_only: - description: - - The access type of this client is bearer-only. - This is 'bearerOnly' in the Keycloak REST API. - aliases: - - bearerOnly - type: bool - - consent_required: - description: - - If enabled, users have to consent to client access. - This is 'consentRequired' in the Keycloak REST API. - aliases: - - consentRequired - type: bool - - standard_flow_enabled: - description: - - Enable standard flow for this client or not (OpenID connect). - This is 'standardFlowEnabled' in the Keycloak REST API. - aliases: - - standardFlowEnabled - type: bool - - implicit_flow_enabled: - description: - - Enable implicit flow for this client or not (OpenID connect). - This is 'implicitFlowEnabled' in the Keycloak REST API. - aliases: - - implicitFlowEnabled - type: bool - - direct_access_grants_enabled: - description: - - Are direct access grants enabled for this client or not (OpenID connect). - This is 'directAccessGrantsEnabled' in the Keycloak REST API. - aliases: - - directAccessGrantsEnabled - type: bool - - service_accounts_enabled: - description: - - Are service accounts enabled for this client or not (OpenID connect). - This is 'serviceAccountsEnabled' in the Keycloak REST API. - aliases: - - serviceAccountsEnabled - type: bool - - authorization_services_enabled: - description: - - Are authorization services enabled for this client or not (OpenID connect). - This is 'authorizationServicesEnabled' in the Keycloak REST API. - aliases: - - authorizationServicesEnabled - type: bool - - public_client: - description: - - Is the access type for this client public or not. - This is 'publicClient' in the Keycloak REST API. - aliases: - - publicClient - type: bool - - frontchannel_logout: - description: - - Is frontchannel logout enabled for this client or not. - This is 'frontchannelLogout' in the Keycloak REST API. - aliases: - - frontchannelLogout - type: bool - - protocol: - description: - - Type of client. - - At creation only, default value will be V(openid-connect) if O(protocol) is omitted. - - The V(docker-v2) value was added in community.general 8.6.0. - type: str + - This specifies for which protocol this protocol mapper is active. choices: ['openid-connect', 'saml', 'docker-v2'] - - full_scope_allowed: - description: - - Is the "Full Scope Allowed" feature set for this client or not. - This is 'fullScopeAllowed' in the Keycloak REST API. - aliases: - - fullScopeAllowed - type: bool - - node_re_registration_timeout: - description: - - Cluster node re-registration timeout for this client. - This is 'nodeReRegistrationTimeout' in the Keycloak REST API. - type: int - aliases: - - nodeReRegistrationTimeout - - registered_nodes: - description: - - dict of registered cluster nodes (with C(nodename) as the key and last registration - time as the value). - This is 'registeredNodes' in the Keycloak REST API. - type: dict - aliases: - - registeredNodes - - client_template: - description: - - Client template to use for this client. If it does not exist this field will silently - be dropped. - This is 'clientTemplate' in the Keycloak REST API. type: str - aliases: - - clientTemplate - use_template_config: + protocolMapper: description: - - Whether or not to use configuration from the O(client_template). - This is 'useTemplateConfig' in the Keycloak REST API. - aliases: - - useTemplateConfig - type: bool + - 'The Keycloak-internal name of the type of this protocol-mapper. While an exhaustive list is impossible to provide + since this may be extended through SPIs by the user of Keycloak, by default Keycloak as of 3.4 ships with at least:' + - V(docker-v2-allow-all-mapper). + - V(oidc-address-mapper). + - V(oidc-full-name-mapper). + - V(oidc-group-membership-mapper). + - V(oidc-hardcoded-claim-mapper). + - V(oidc-hardcoded-role-mapper). + - V(oidc-role-name-mapper). + - V(oidc-script-based-protocol-mapper). + - V(oidc-sha256-pairwise-sub-mapper). + - V(oidc-usermodel-attribute-mapper). + - V(oidc-usermodel-client-role-mapper). + - V(oidc-usermodel-property-mapper). + - V(oidc-usermodel-realm-role-mapper). + - V(oidc-usersessionmodel-note-mapper). + - V(saml-group-membership-mapper). + - V(saml-hardcode-attribute-mapper). + - V(saml-hardcode-role-mapper). + - V(saml-role-list-mapper). + - V(saml-role-name-mapper). + - V(saml-user-attribute-mapper). + - V(saml-user-property-mapper). + - V(saml-user-session-note-mapper). + - An exhaustive list of available mappers on your installation can be obtained on the admin console by going to + Server Info -> Providers and looking under 'protocol-mapper'. + type: str - use_template_scope: + config: description: - - Whether or not to use scope configuration from the O(client_template). - This is 'useTemplateScope' in the Keycloak REST API. - aliases: - - useTemplateScope - type: bool - - use_template_mappers: - description: - - Whether or not to use mapper configuration from the O(client_template). - This is 'useTemplateMappers' in the Keycloak REST API. - aliases: - - useTemplateMappers - type: bool - - always_display_in_console: - description: - - Whether or not to display this client in account console, even if the - user does not have an active session. - aliases: - - alwaysDisplayInConsole - type: bool - version_added: 4.7.0 - - surrogate_auth_required: - description: - - Whether or not surrogate auth is required. - This is 'surrogateAuthRequired' in the Keycloak REST API. - aliases: - - surrogateAuthRequired - type: bool - - authorization_settings: - description: - - a data structure defining the authorization settings for this client. For reference, - please see the Keycloak API docs at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html#_resourceserverrepresentation). - This is 'authorizationSettings' in the Keycloak REST API. + - Dict specifying the configuration options for the protocol mapper; the contents differ depending on the value + of O(protocol_mappers[].protocolMapper) and are not documented other than by the source of the mappers and its + parent class(es). An example is given below. It is easiest to obtain valid config values by dumping an already-existing + protocol mapper configuration through check-mode in the RV(existing) field. type: dict - aliases: - - authorizationSettings - authentication_flow_binding_overrides: + attributes: + description: + - A dict of further attributes for this client. This can contain various configuration settings; an example is given + in the examples section. While an exhaustive list of permissible options is not available; possible options as of + Keycloak 3.4 are listed below. The Keycloak API does not validate whether a given option is appropriate for the protocol + used; if specified anyway, Keycloak does not use it. + type: dict + suboptions: + saml.authnstatement: description: - - Override realm authentication flow bindings. - type: dict - suboptions: - browser: - description: - - Flow ID of the browser authentication flow. - - O(authentication_flow_binding_overrides.browser) - and O(authentication_flow_binding_overrides.browser_name) are mutually exclusive. - type: str - - browser_name: - description: - - Flow name of the browser authentication flow. - - O(authentication_flow_binding_overrides.browser) - and O(authentication_flow_binding_overrides.browser_name) are mutually exclusive. - aliases: - - browserName - type: str - version_added: 9.1.0 - - direct_grant: - description: - - Flow ID of the direct grant authentication flow. - - O(authentication_flow_binding_overrides.direct_grant) - and O(authentication_flow_binding_overrides.direct_grant_name) are mutually exclusive. - aliases: - - directGrant - type: str - - direct_grant_name: - description: - - Flow name of the direct grant authentication flow. - - O(authentication_flow_binding_overrides.direct_grant) - and O(authentication_flow_binding_overrides.direct_grant_name) are mutually exclusive. - aliases: - - directGrantName - type: str - version_added: 9.1.0 - aliases: - - authenticationFlowBindingOverrides - version_added: 3.4.0 - - default_client_scopes: + - For SAML clients, boolean specifying whether or not a statement containing method and timestamp should be included + in the login response. + saml.client.signature: description: - - List of default client scopes. - aliases: - - defaultClientScopes - type: list - elements: str - version_added: 4.7.0 - - optional_client_scopes: + - For SAML clients, boolean specifying whether a client signature is required and validated. + saml.encrypt: description: - - List of optional client scopes. - aliases: - - optionalClientScopes - type: list - elements: str - version_added: 4.7.0 - - protocol_mappers: + - Boolean specifying whether SAML assertions should be encrypted with the client's public key. + saml.force.post.binding: description: - - a list of dicts defining protocol mappers for this client. - This is 'protocolMappers' in the Keycloak REST API. - aliases: - - protocolMappers - type: list - elements: dict - suboptions: - consentRequired: - description: - - Specifies whether a user needs to provide consent to a client for this mapper to be active. - type: bool - - consentText: - description: - - The human-readable name of the consent the user is presented to accept. - type: str - - id: - description: - - Usually a UUID specifying the internal ID of this protocol mapper instance. - type: str - - name: - description: - - The name of this protocol mapper. - type: str - - protocol: - description: - - This specifies for which protocol this protocol mapper is active. - choices: ['openid-connect', 'saml', 'docker-v2'] - type: str - - protocolMapper: - description: - - "The Keycloak-internal name of the type of this protocol-mapper. While an exhaustive list is - impossible to provide since this may be extended through SPIs by the user of Keycloak, - by default Keycloak as of 3.4 ships with at least:" - - V(docker-v2-allow-all-mapper) - - V(oidc-address-mapper) - - V(oidc-full-name-mapper) - - V(oidc-group-membership-mapper) - - V(oidc-hardcoded-claim-mapper) - - V(oidc-hardcoded-role-mapper) - - V(oidc-role-name-mapper) - - V(oidc-script-based-protocol-mapper) - - V(oidc-sha256-pairwise-sub-mapper) - - V(oidc-usermodel-attribute-mapper) - - V(oidc-usermodel-client-role-mapper) - - V(oidc-usermodel-property-mapper) - - V(oidc-usermodel-realm-role-mapper) - - V(oidc-usersessionmodel-note-mapper) - - V(saml-group-membership-mapper) - - V(saml-hardcode-attribute-mapper) - - V(saml-hardcode-role-mapper) - - V(saml-role-list-mapper) - - V(saml-role-name-mapper) - - V(saml-user-attribute-mapper) - - V(saml-user-property-mapper) - - V(saml-user-session-note-mapper) - - An exhaustive list of available mappers on your installation can be obtained on - the admin console by going to Server Info -> Providers and looking under - 'protocol-mapper'. - type: str - - config: - description: - - Dict specifying the configuration options for the protocol mapper; the - contents differ depending on the value of O(protocol_mappers[].protocolMapper) and are not documented - other than by the source of the mappers and its parent class(es). An example is given - below. It is easiest to obtain valid config values by dumping an already-existing - protocol mapper configuration through check-mode in the RV(existing) field. - type: dict - - attributes: + - For SAML clients, boolean specifying whether always to use POST binding for responses. + saml.onetimeuse.condition: description: - - A dict of further attributes for this client. This can contain various configuration - settings; an example is given in the examples section. While an exhaustive list of - permissible options is not available; possible options as of Keycloak 3.4 are listed below. The Keycloak - API does not validate whether a given option is appropriate for the protocol used; if specified - anyway, Keycloak will simply not use it. - type: dict - suboptions: - saml.authnstatement: - description: - - For SAML clients, boolean specifying whether or not a statement containing method and timestamp - should be included in the login response. + - For SAML clients, boolean specifying whether a OneTimeUse condition should be included in login responses. + saml.server.signature: + description: + - Boolean specifying whether SAML documents should be signed by the realm. + saml.server.signature.keyinfo.ext: + description: + - For SAML clients, boolean specifying whether REDIRECT signing key lookup should be optimized through inclusion + of the signing key ID in the SAML Extensions element. + saml.signature.algorithm: + description: + - Signature algorithm used to sign SAML documents. One of V(RSA_SHA256), V(RSA_SHA1), V(RSA_SHA512), or V(DSA_SHA1). + saml.signing.certificate: + description: + - SAML signing key certificate, base64-encoded. + saml.signing.private.key: + description: + - SAML signing key private key, base64-encoded. + saml_assertion_consumer_url_post: + description: + - SAML POST Binding URL for the client's assertion consumer service (login responses). + saml_assertion_consumer_url_redirect: + description: + - SAML Redirect Binding URL for the client's assertion consumer service (login responses). + saml_force_name_id_format: + description: + - For SAML clients, Boolean specifying whether to ignore requested NameID subject format and using the configured + one instead. + saml_name_id_format: + description: + - For SAML clients, the NameID format to use (one of V(username), V(email), V(transient), or V(persistent)). + saml_signature_canonicalization_method: + description: + - SAML signature canonicalization method. This is one of four values, namely V(http://www.w3.org/2001/10/xml-exc-c14n#) + for EXCLUSIVE, V(http://www.w3.org/2001/10/xml-exc-c14n#WithComments) for EXCLUSIVE_WITH_COMMENTS, + V(http://www.w3.org/TR/2001/REC-xml-c14n-20010315) + for INCLUSIVE, and V(http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments) for INCLUSIVE_WITH_COMMENTS. + saml_single_logout_service_url_post: + description: + - SAML POST binding URL for the client's single logout service. + saml_single_logout_service_url_redirect: + description: + - SAML redirect binding URL for the client's single logout service. + user.info.response.signature.alg: + description: + - For OpenID-Connect clients, JWA algorithm for signed UserInfo-endpoint responses. One of V(RS256) or V(unsigned). + request.object.signature.alg: + description: + - For OpenID-Connect clients, JWA algorithm which the client needs to use when sending OIDC request object. One + of V(any), V(none), V(RS256). + use.jwks.url: + description: + - For OpenID-Connect clients, boolean specifying whether to use a JWKS URL to obtain client public keys. + jwks.url: + description: + - For OpenID-Connect clients, URL where client keys in JWK are stored. + jwt.credential.certificate: + description: + - For OpenID-Connect clients, client certificate for validating JWT issued by client and signed by its key, base64-encoded. + x509.subjectdn: + description: + - For OpenID-Connect clients, subject which is used to authenticate the client. + type: str - saml.client.signature: - description: - - For SAML clients, boolean specifying whether a client signature is required and validated. - - saml.encrypt: - description: - - Boolean specifying whether SAML assertions should be encrypted with the client's public key. - - saml.force.post.binding: - description: - - For SAML clients, boolean specifying whether always to use POST binding for responses. - - saml.onetimeuse.condition: - description: - - For SAML clients, boolean specifying whether a OneTimeUse condition should be included in login responses. - - saml.server.signature: - description: - - Boolean specifying whether SAML documents should be signed by the realm. - - saml.server.signature.keyinfo.ext: - description: - - For SAML clients, boolean specifying whether REDIRECT signing key lookup should be optimized through inclusion - of the signing key id in the SAML Extensions element. - - saml.signature.algorithm: - description: - - Signature algorithm used to sign SAML documents. One of V(RSA_SHA256), V(RSA_SHA1), V(RSA_SHA512), or V(DSA_SHA1). - - saml.signing.certificate: - description: - - SAML signing key certificate, base64-encoded. - - saml.signing.private.key: - description: - - SAML signing key private key, base64-encoded. - - saml_assertion_consumer_url_post: - description: - - SAML POST Binding URL for the client's assertion consumer service (login responses). - - saml_assertion_consumer_url_redirect: - description: - - SAML Redirect Binding URL for the client's assertion consumer service (login responses). - - saml_force_name_id_format: - description: - - For SAML clients, Boolean specifying whether to ignore requested NameID subject format and using the configured one instead. - - saml_name_id_format: - description: - - For SAML clients, the NameID format to use (one of V(username), V(email), V(transient), or V(persistent)) - - saml_signature_canonicalization_method: - description: - - SAML signature canonicalization method. This is one of four values, namely - V(http://www.w3.org/2001/10/xml-exc-c14n#) for EXCLUSIVE, - V(http://www.w3.org/2001/10/xml-exc-c14n#WithComments) for EXCLUSIVE_WITH_COMMENTS, - V(http://www.w3.org/TR/2001/REC-xml-c14n-20010315) for INCLUSIVE, and - V(http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments) for INCLUSIVE_WITH_COMMENTS. - - saml_single_logout_service_url_post: - description: - - SAML POST binding url for the client's single logout service. - - saml_single_logout_service_url_redirect: - description: - - SAML redirect binding url for the client's single logout service. - - user.info.response.signature.alg: - description: - - For OpenID-Connect clients, JWA algorithm for signed UserInfo-endpoint responses. One of V(RS256) or V(unsigned). - - request.object.signature.alg: - description: - - For OpenID-Connect clients, JWA algorithm which the client needs to use when sending - OIDC request object. One of V(any), V(none), V(RS256). - - use.jwks.url: - description: - - For OpenID-Connect clients, boolean specifying whether to use a JWKS URL to obtain client - public keys. - - jwks.url: - description: - - For OpenID-Connect clients, URL where client keys in JWK are stored. - - jwt.credential.certificate: - description: - - For OpenID-Connect clients, client certificate for validating JWT issued by - client and signed by its key, base64-encoded. - - x509.subjectdn: - description: - - For OpenID-Connect clients, subject which will be used to authenticate the client. - type: str - version_added: 9.5.0 - - x509.allow.regex.pattern.comparison: - description: - - For OpenID-Connect clients, boolean specifying whether to allow C(x509.subjectdn) as regular expression. - type: bool - version_added: 9.5.0 + x509.allow.regex.pattern.comparison: + description: + - For OpenID-Connect clients, boolean specifying whether to allow C(x509.subjectdn) as regular expression. + type: bool extends_documentation_fragment: - - middleware_automation.keycloak.keycloak - - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes author: - - Eike Frost (@eikef) -''' + - Eike Frost (@eikef) + - Ivan Kokalović (@koke1997) +""" -EXAMPLES = ''' +EXAMPLES = r""" - name: Create or update Keycloak client (minimal example), authentication with credentials middleware_automation.keycloak.keycloak_client: - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD @@ -616,7 +590,7 @@ EXAMPLES = ''' - name: Create or update Keycloak client (minimal example), authentication with token middleware_automation.keycloak.keycloak_client: auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master token: TOKEN client_id: test @@ -627,7 +601,7 @@ EXAMPLES = ''' - name: Delete a Keycloak client middleware_automation.keycloak.keycloak_client: auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD @@ -639,7 +613,7 @@ EXAMPLES = ''' - name: Create or update a Keycloak client (minimal example), with x509 authentication middleware_automation.keycloak.keycloak_client: auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD @@ -655,7 +629,7 @@ EXAMPLES = ''' - name: Create or update a Keycloak client (with all the bells and whistles) middleware_automation.keycloak.keycloak_client: auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD @@ -702,7 +676,7 @@ EXAMPLES = ''' - test01 - test02 authentication_flow_binding_overrides: - browser: 4c90336b-bf1d-4b87-916d-3677ba4e5fbb + browser: 4c90336b-bf1d-4b87-916d-3677ba4e5fbb protocol_mappers: - config: access.token.claim: true @@ -741,60 +715,186 @@ EXAMPLES = ''' jwks.url: JWKS_URL_FOR_CLIENT_AUTH_JWT jwt.credential.certificate: JWT_CREDENTIAL_CERTIFICATE_FOR_CLIENT_AUTH delegate_to: localhost -''' +""" -RETURN = ''' +RETURN = r""" msg: - description: Message as to what action was taken. - returned: always - type: str - sample: "Client testclient has been updated" + description: Message as to what action was taken. + returned: always + type: str + sample: "Client testclient has been updated" proposed: - description: Representation of proposed client. - returned: always - type: dict - sample: { - clientId: "test" - } + description: Representation of proposed client. + returned: always + type: dict + sample: {"clientId": "test"} existing: - description: Representation of existing client (sample is truncated). - returned: always - type: dict - sample: { - "adminUrl": "http://www.example.com/admin_url", - "attributes": { - "request.object.signature.alg": "RS256", - } + description: Representation of existing client (sample is truncated). + returned: always + type: dict + sample: + { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256" + } } end_state: - description: Representation of client after module execution (sample is truncated). - returned: on success - type: dict - sample: { - "adminUrl": "http://www.example.com/admin_url", - "attributes": { - "request.object.signature.alg": "RS256", - } + description: Representation of client after module execution (sample is truncated). + returned: on success + type: dict + sample: + { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256" + } } -''' +""" -from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, get_token, KeycloakError, is_struct_included -from ansible.module_utils.basic import AnsibleModule import copy +from ansible.module_utils.basic import AnsibleModule -PROTOCOL_OPENID_CONNECT = 'openid-connect' -PROTOCOL_SAML = 'saml' -PROTOCOL_DOCKER_V2 = 'docker-v2' -CLIENT_META_DATA = ['authorizationServicesEnabled'] +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak._keycloak_utils import ( + merge_settings_without_absent_nulls, +) +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + is_struct_included, + keycloak_argument_spec, +) + +PROTOCOL_OPENID_CONNECT = "openid-connect" +PROTOCOL_SAML = "saml" +PROTOCOL_DOCKER_V2 = "docker-v2" +CLIENT_META_DATA = ["authorizationServicesEnabled"] +CLIENT_COMPARE_EXCLUDE = CLIENT_META_DATA + ["defaultRoles"] +EMPTY_OPTIONAL_URL_FIELDS = ("rootUrl", "adminUrl", "baseUrl") +LIST_CLIENT_PARAMS = frozenset( + { + "redirect_uris", + "web_origins", + "default_roles", + "valid_post_logout_redirect_uris", + "default_client_scopes", + "optional_client_scopes", + } +) + +# Parameters that map to client attributes rather than top-level API fields. +# Each entry maps the module parameter name to (attribute_key, transform_fn). +# transform_fn converts the module param value to the attribute string value. +# Use None for transform_fn when no transformation is needed (identity). +ATTRIBUTE_PARAMS = { + "valid_post_logout_redirect_uris": ( + "post.logout.redirect.uris", + "##".join, + ), + "backchannel_logout_url": ( + "backchannel.logout.url", + None, + ), +} + + +def normalise_scopes_for_behavior(desired_client, before_client, clientScopesBehavior): + """ + Normalize the desired and existing client scopes according to the specified behavior. + + This function adjusts the lists of default and optional client scopes in the desired client + configuration based on the selected behavior: + - 'ignore': The desired scopes are set to match the existing scopes. + - 'patch': Any scopes present in the existing configuration but missing from the desired configuration + are appended to the desired scopes. + - 'idempotent': No modification is made; the desired scopes are used as-is. + + :param desired_client: + type: dict + description: The desired client configuration, including default and optional client scopes. + + :param before_client: + type: dict + description: The current client configuration, including default and optional client scopes. + + :param clientScopesBehavior: + type: str + description: The behavior mode for handling client scopes. Must be one of 'ignore', 'patch', or 'idempotent'. + + :return: + type: tuple + description: Returns a tuple of (desired_client, before_client) after normalization. + """ + desired_client = copy.deepcopy(desired_client) + before_client = copy.deepcopy(before_client) + if clientScopesBehavior == "ignore": + desired_client["defaultClientScopes"] = copy.deepcopy(before_client["defaultClientScopes"]) + desired_client["optionalClientScopes"] = copy.deepcopy(before_client["optionalClientScopes"]) + elif clientScopesBehavior == "patch": + for scope in before_client["defaultClientScopes"]: + if scope not in desired_client["defaultClientScopes"]: + desired_client["defaultClientScopes"].append(scope) + for scope in before_client["optionalClientScopes"]: + if scope not in desired_client["optionalClientScopes"]: + desired_client["optionalClientScopes"].append(scope) + + return desired_client, before_client + + +def check_optional_scopes_not_default(desired_client, clientScopesBehavior, module): + """ + Ensure that no client scope is assigned as both default and optional. + + This function checks the desired client configuration to verify that no scope is present + in both the default and optional client scopes. If such a conflict is found, the module + execution fails with an appropriate error message. + + :param desired_client: + type: dict + description: The desired client configuration, including default and optional client scopes. + + :param clientScopesBehavior: + type: str + description: The behavior mode for handling client scopes. Must be one of 'ignore', 'patch', or 'idempotent'. + + :param module: + type: AnsibleModule + description: The Ansible module instance, used to fail execution if a conflict is detected. + + :return: + type: None + description: Returns None. Fails the module if a scope is both default and optional. + """ + if clientScopesBehavior == "ignore": + return + for scope in desired_client["optionalClientScopes"]: + if scope in desired_client["defaultClientScopes"]: + module.fail_json(msg=f"Client scope {scope} cannot be both default and optional") + + +def _coerce_str_list(value): + if value is None: + return [] + if isinstance(value, str): + return [value] + return list(value) + + +def _sorted_str_list(clientrep, field): + if field in clientrep: + clientrep[field] = sorted(_coerce_str_list(clientrep[field])) + else: + clientrep[field] = [] def normalise_cr(clientrep, remove_ids=False): - """ Re-sorts any properties where the order so that diff's is minimised, and adds default values where appropriate so that the + """Re-sorts any properties where the order so that diff's is minimised, and adds default values where appropriate so that the the change detection is more effective. :param clientrep: the clientrep dict to be sanitized @@ -803,50 +903,86 @@ def normalise_cr(clientrep, remove_ids=False): :return: normalised clientrep dict """ # Avoid the dict passed in to be modified - clientrep = clientrep.copy() + clientrep = copy.deepcopy(clientrep) - if 'attributes' in clientrep: - clientrep['attributes'] = list(sorted(clientrep['attributes'])) + if remove_ids: + clientrep.pop("id", None) - if 'defaultClientScopes' in clientrep: - clientrep['defaultClientScopes'] = list(sorted(clientrep['defaultClientScopes'])) + for url_field in EMPTY_OPTIONAL_URL_FIELDS: + if clientrep.get(url_field) == "": + clientrep.pop(url_field, None) - if 'optionalClientScopes' in clientrep: - clientrep['optionalClientScopes'] = list(sorted(clientrep['optionalClientScopes'])) + _sorted_str_list(clientrep, "defaultClientScopes") + _sorted_str_list(clientrep, "optionalClientScopes") + _sorted_str_list(clientrep, "redirectUris") + _sorted_str_list(clientrep, "defaultRoles") - if 'redirectUris' in clientrep: - clientrep['redirectUris'] = list(sorted(clientrep['redirectUris'])) - - if 'protocolMappers' in clientrep: - clientrep['protocolMappers'] = sorted(clientrep['protocolMappers'], key=lambda x: (x.get('name'), x.get('protocol'), x.get('protocolMapper'))) - for mapper in clientrep['protocolMappers']: + if "protocolMappers" in clientrep: + clientrep["protocolMappers"] = sorted( + clientrep["protocolMappers"], key=lambda x: (x.get("name"), x.get("protocol"), x.get("protocolMapper")) + ) + for mapper in clientrep["protocolMappers"]: if remove_ids: - mapper.pop('id', None) + mapper.pop("id", None) + + # Convert bool to string + if "config" in mapper: + for key, value in mapper["config"].items(): + if isinstance(value, bool): + mapper["config"][key] = str(value).lower() # Set to a default value. - mapper['consentRequired'] = mapper.get('consentRequired', False) + mapper["consentRequired"] = mapper.get("consentRequired", False) + else: + clientrep["protocolMappers"] = [] + + if "attributes" in clientrep: + for key, value in clientrep["attributes"].items(): + if isinstance(value, bool): + clientrep["attributes"][key] = str(value).lower() + clientrep["attributes"].pop("client.secret.creation.time", None) + else: + clientrep["attributes"] = {} + + _sorted_str_list(clientrep, "webOrigins") return clientrep +def client_rep_is_unchanged(before_client, desired_client): + before_norm = normalise_cr(before_client, remove_ids=True) + desired_norm = normalise_cr(desired_client, remove_ids=True) + return is_struct_included(desired_norm, before_norm, CLIENT_COMPARE_EXCLUDE) + + +def normalize_kc_resp(clientrep): + # kc drops the variable 'authorizationServicesEnabled' if set to false + # to minimize diff/changes we set it to false if not set by kc + if clientrep and "authorizationServicesEnabled" not in clientrep: + clientrep["authorizationServicesEnabled"] = False + + def sanitize_cr(clientrep): - """ Removes probably sensitive details from a client representation. + """Removes probably sensitive details from a client representation. :param clientrep: the clientrep dict to be sanitized :return: sanitized clientrep dict """ result = copy.deepcopy(clientrep) - if 'secret' in result: - result['secret'] = 'no_log' - if 'attributes' in result: - attributes = result['attributes'] - if isinstance(attributes, dict) and 'saml.signing.private.key' in attributes: - attributes['saml.signing.private.key'] = 'no_log' + if "secret" in result: + result["secret"] = "no_log" + if "attributes" in result: + attributes = result["attributes"] + if isinstance(attributes, dict): + if "saml.signing.private.key" in attributes: + attributes["saml.signing.private.key"] = "no_log" + if "saml.encryption.private.key" in attributes: + attributes["saml.encryption.private.key"] = "no_log" return normalise_cr(result) def get_authentication_flow_id(flow_name, realm, kc): - """ Get the authentication flow ID based on the flow name, realm, and Keycloak client. + """Get the authentication flow ID based on the flow name, realm, and Keycloak client. Args: flow_name (str): The name of the authentication flow. @@ -862,11 +998,11 @@ def get_authentication_flow_id(flow_name, realm, kc): flow = kc.get_authentication_flow_by_alias(flow_name, realm) if flow: return flow["id"] - kc.module.fail_json(msg='Authentification flow %s not found in realm %s' % (flow_name, realm)) + kc.module.fail_json(msg=f"Authentification flow {flow_name} not found in realm {realm}") def flow_binding_from_dict_to_model(newClientFlowBinding, realm, kc): - """ Convert a dictionary representing client flow bindings to a model representation. + """Convert a dictionary representing client flow bindings to a model representation. Args: newClientFlowBinding (dict): A dictionary containing client flow bindings. @@ -883,10 +1019,7 @@ def flow_binding_from_dict_to_model(newClientFlowBinding, realm, kc): """ - modelFlow = { - "browser": None, - "direct_grant": None - } + modelFlow = {"browser": None, "direct_grant": None} for k, v in newClientFlowBinding.items(): if not v: @@ -903,6 +1036,193 @@ def flow_binding_from_dict_to_model(newClientFlowBinding, realm, kc): return modelFlow +def find_match(iterable, attribute, name): + """ + Search for an element in a list of dictionaries based on a given attribute and value. + + This function iterates over the elements of an iterable (typically a list of dictionaries) + and returns the first element whose value for the specified attribute matches `name`. + + :param iterable: + type: iterable (commonly list[dict]) + description: The collection of elements to search within (usually a list of dictionaries). + + :param attribute: + type: str + description: The dictionary key/attribute used for comparison. + + :param name: + type: Any + description: The value to search for within the given attribute. + + :return: + type: dict | None + description: Returns the first dictionary where the attribute matches the given value case insensitive. + Returns `None` if no match is found. + """ + name_lower = str(name).lower() + return next( + (value for value in iterable if attribute in value and str(value[attribute]).lower() == name_lower), + None, + ) + + +def add_default_client_scopes(desired_client, before_client, realm, kc): + """ + Adds missing default client scopes to a Keycloak client. + + This function compares the desired default client scopes specified in `desired_client` + with the current default client scopes in `before_client`. For each scope that is present + in `desired_client["defaultClientScopes"]` but missing from `before_client['defaultClientScopes']`, + it retrieves the scope information from Keycloak and adds it to the client. + + :param desired_client: + type: dict + description: The desired client configuration, including the list of default client scopes. + + :param before_client: + type: dict + description: The current client configuration, including the list of default client scopes. + + :param realm + type: str + description: The name of the Keycloak realm. + + :param kc + type: KeycloakAPI + description: An instance of the Keycloak API client. + + Returns: + None + """ + desired_default_scope = desired_client["defaultClientScopes"] + missing_scopes = [item for item in desired_default_scope if item not in before_client["defaultClientScopes"]] + if not missing_scopes: + return + client_scopes = kc.get_clientscopes(realm) + for name in missing_scopes: + scope = find_match(client_scopes, "name", name) + if scope: + kc.add_default_clientscope(scope["id"], realm, desired_client["clientId"]) + + +def add_optional_client_scopes(desired_client, before_client, realm, kc): + """ + Adds missing optional client scopes to a Keycloak client. + + This function compares the desired optional client scopes specified in `desired_client` + with the current optional client scopes in `before_client`. For each scope that is present + in `desired_client["optionalClientScopes"]` but missing from `before_client['optionalClientScopes']`, + it retrieves the scope information from Keycloak and adds it to the client. + + :param desired_client: + type: dict + description: The desired client configuration, including the list of optional client scopes. + + :param before_client: + type: dict + description: The current client configuration, including the list of optional client scopes. + + :param realm: + type: str + description: The name of the Keycloak realm. + + :param kc: + type: KeycloakAPI + description: An instance of the Keycloak API client. + + Returns: + None + """ + desired_optional_scope = desired_client["optionalClientScopes"] + missing_scopes = [item for item in desired_optional_scope if item not in before_client["optionalClientScopes"]] + if not missing_scopes: + return + client_scopes = kc.get_clientscopes(realm) + for name in missing_scopes: + scope = find_match(client_scopes, "name", name) + if scope: + kc.add_optional_clientscope(scope["id"], realm, desired_client["clientId"]) + + +def remove_default_client_scopes(desired_client, before_client, realm, kc): + """ + Removes default client scopes from a Keycloak client that are no longer desired. + + This function compares the current default client scopes in `before_client` + with the desired default client scopes in `desired_client`. For each scope that is present + in `before_client["defaultClientScopes"]` but missing from `desired_client['defaultClientScopes']`, + it retrieves the scope information from Keycloak and removes it from the client. + + :param desired_client: + type: dict + description: The desired client configuration, including the list of default client scopes. + + :param before_client: + type: dict + description: The current client configuration, including the list of default client scopes. + + :param realm: + type: str + description: The name of the Keycloak realm. + + :param kc: + type: KeycloakAPI + description: An instance of the Keycloak API client. + + Returns: + None + """ + before_default_scope = before_client["defaultClientScopes"] + missing_scopes = [item for item in before_default_scope if item not in desired_client["defaultClientScopes"]] + if not missing_scopes: + return + client_scopes = kc.get_default_clientscopes(realm, desired_client["clientId"]) + for name in missing_scopes: + scope = find_match(client_scopes, "name", name) + if scope: + kc.delete_default_clientscope(scope["id"], realm, desired_client["clientId"]) + + +def remove_optional_client_scopes(desired_client, before_client, realm, kc): + """ + Removes optional client scopes from a Keycloak client that are no longer desired. + + This function compares the current optional client scopes in `before_client` + with the desired optional client scopes in `desired_client`. For each scope that is present + in `before_client["optionalClientScopes"]` but missing from `desired_client['optionalClientScopes']`, + it retrieves the scope information from Keycloak and removes it from the client. + + :param desired_client: + type: dict + description: The desired client configuration, including the list of optional client scopes. + + :param before_client: + type: dict + description: The current client configuration, including the list of optional client scopes. + + :param realm: + type: str + description: The name of the Keycloak realm. + + :param kc: + type: KeycloakAPI + description: An instance of the Keycloak API client. + + Returns: + None + """ + before_optional_scope = before_client["optionalClientScopes"] + missing_scopes = [item for item in before_optional_scope if item not in desired_client["optionalClientScopes"]] + if not missing_scopes: + return + client_scopes = kc.get_optional_clientscopes(realm, desired_client["clientId"]) + for name in missing_scopes: + scope = find_match(client_scopes, "name", name) + if scope: + kc.delete_optional_clientscope(scope["id"], realm, desired_client["clientId"]) + + def main(): """ Module execution @@ -912,83 +1232,96 @@ def main(): argument_spec = keycloak_argument_spec() protmapper_spec = dict( - consentRequired=dict(type='bool'), - consentText=dict(type='str'), - id=dict(type='str'), - name=dict(type='str'), - protocol=dict(type='str', choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), - protocolMapper=dict(type='str'), - config=dict(type='dict'), + consentRequired=dict(type="bool"), + consentText=dict(type="str"), + id=dict(type="str"), + name=dict(type="str"), + protocol=dict(type="str", choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), + protocolMapper=dict(type="str"), + config=dict(type="dict"), ) authentication_flow_spec = dict( - browser=dict(type='str'), - browser_name=dict(type='str', aliases=['browserName']), - direct_grant=dict(type='str', aliases=['directGrant']), - direct_grant_name=dict(type='str', aliases=['directGrantName']), + browser=dict(type="str"), + browser_name=dict(type="str", aliases=["browserName"]), + direct_grant=dict(type="str", aliases=["directGrant"]), + direct_grant_name=dict(type="str", aliases=["directGrantName"]), ) meta_args = dict( - state=dict(default='present', choices=['present', 'absent']), - realm=dict(type='str', default='master'), - - id=dict(type='str'), - client_id=dict(type='str', aliases=['clientId']), - name=dict(type='str'), - description=dict(type='str'), - root_url=dict(type='str', aliases=['rootUrl']), - admin_url=dict(type='str', aliases=['adminUrl']), - base_url=dict(type='str', aliases=['baseUrl']), - surrogate_auth_required=dict(type='bool', aliases=['surrogateAuthRequired']), - enabled=dict(type='bool'), - client_authenticator_type=dict(type='str', choices=['client-secret', 'client-jwt', 'client-x509'], aliases=['clientAuthenticatorType']), - secret=dict(type='str', no_log=True), - registration_access_token=dict(type='str', aliases=['registrationAccessToken'], no_log=True), - default_roles=dict(type='list', elements='str', aliases=['defaultRoles']), - redirect_uris=dict(type='list', elements='str', aliases=['redirectUris']), - web_origins=dict(type='list', elements='str', aliases=['webOrigins']), - not_before=dict(type='int', aliases=['notBefore']), - bearer_only=dict(type='bool', aliases=['bearerOnly']), - consent_required=dict(type='bool', aliases=['consentRequired']), - standard_flow_enabled=dict(type='bool', aliases=['standardFlowEnabled']), - implicit_flow_enabled=dict(type='bool', aliases=['implicitFlowEnabled']), - direct_access_grants_enabled=dict(type='bool', aliases=['directAccessGrantsEnabled']), - service_accounts_enabled=dict(type='bool', aliases=['serviceAccountsEnabled']), - authorization_services_enabled=dict(type='bool', aliases=['authorizationServicesEnabled']), - public_client=dict(type='bool', aliases=['publicClient']), - frontchannel_logout=dict(type='bool', aliases=['frontchannelLogout']), - protocol=dict(type='str', choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), - attributes=dict(type='dict'), - full_scope_allowed=dict(type='bool', aliases=['fullScopeAllowed']), - node_re_registration_timeout=dict(type='int', aliases=['nodeReRegistrationTimeout']), - registered_nodes=dict(type='dict', aliases=['registeredNodes']), - client_template=dict(type='str', aliases=['clientTemplate']), - use_template_config=dict(type='bool', aliases=['useTemplateConfig']), - use_template_scope=dict(type='bool', aliases=['useTemplateScope']), - use_template_mappers=dict(type='bool', aliases=['useTemplateMappers']), - always_display_in_console=dict(type='bool', aliases=['alwaysDisplayInConsole']), - authentication_flow_binding_overrides=dict( - type='dict', - aliases=['authenticationFlowBindingOverrides'], - options=authentication_flow_spec, - required_one_of=[['browser', 'direct_grant', 'browser_name', 'direct_grant_name']], - mutually_exclusive=[['browser', 'browser_name'], ['direct_grant', 'direct_grant_name']], + state=dict(default="present", choices=["present", "absent"]), + realm=dict(type="str", default="master"), + id=dict(type="str"), + client_id=dict(type="str", aliases=["clientId"]), + name=dict(type="str"), + description=dict(type="str"), + root_url=dict(type="str", aliases=["rootUrl"]), + admin_url=dict(type="str", aliases=["adminUrl"]), + base_url=dict(type="str", aliases=["baseUrl"]), + surrogate_auth_required=dict(type="bool", aliases=["surrogateAuthRequired"]), + enabled=dict(type="bool"), + client_authenticator_type=dict( + type="str", choices=["client-secret", "client-jwt", "client-x509"], aliases=["clientAuthenticatorType"] ), - protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec, aliases=['protocolMappers']), - authorization_settings=dict(type='dict', aliases=['authorizationSettings']), - default_client_scopes=dict(type='list', elements='str', aliases=['defaultClientScopes']), - optional_client_scopes=dict(type='list', elements='str', aliases=['optionalClientScopes']), + secret=dict(type="str", no_log=True), + registration_access_token=dict(type="str", aliases=["registrationAccessToken"], no_log=True), + default_roles=dict(type="list", elements="str", aliases=["defaultRoles"]), + redirect_uris=dict(type="list", elements="str", aliases=["redirectUris"]), + web_origins=dict(type="list", elements="str", aliases=["webOrigins"]), + valid_post_logout_redirect_uris=dict(type="list", elements="str", aliases=["postLogoutRedirectUris"]), + not_before=dict(type="int", aliases=["notBefore"]), + bearer_only=dict(type="bool", aliases=["bearerOnly"]), + consent_required=dict(type="bool", aliases=["consentRequired"]), + standard_flow_enabled=dict(type="bool", aliases=["standardFlowEnabled"]), + implicit_flow_enabled=dict(type="bool", aliases=["implicitFlowEnabled"]), + direct_access_grants_enabled=dict(type="bool", aliases=["directAccessGrantsEnabled"]), + service_accounts_enabled=dict(type="bool", aliases=["serviceAccountsEnabled"]), + authorization_services_enabled=dict(type="bool", aliases=["authorizationServicesEnabled"]), + public_client=dict(type="bool", aliases=["publicClient"]), + frontchannel_logout=dict(type="bool", aliases=["frontchannelLogout"]), + backchannel_logout_url=dict(type="str", aliases=["backchannelLogoutUrl"]), + protocol=dict(type="str", choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), + attributes=dict(type="dict"), + full_scope_allowed=dict(type="bool", aliases=["fullScopeAllowed"]), + node_re_registration_timeout=dict(type="int", aliases=["nodeReRegistrationTimeout"]), + registered_nodes=dict(type="dict", aliases=["registeredNodes"]), + client_template=dict(type="str", aliases=["clientTemplate"]), + use_template_config=dict(type="bool", aliases=["useTemplateConfig"]), + use_template_scope=dict(type="bool", aliases=["useTemplateScope"]), + use_template_mappers=dict(type="bool", aliases=["useTemplateMappers"]), + always_display_in_console=dict(type="bool", aliases=["alwaysDisplayInConsole"]), + authentication_flow_binding_overrides=dict( + type="dict", + aliases=["authenticationFlowBindingOverrides"], + options=authentication_flow_spec, + required_one_of=[["browser", "direct_grant", "browser_name", "direct_grant_name"]], + mutually_exclusive=[["browser", "browser_name"], ["direct_grant", "direct_grant_name"]], + ), + protocol_mappers=dict(type="list", elements="dict", options=protmapper_spec, aliases=["protocolMappers"]), + authorization_settings=dict(type="dict", aliases=["authorizationSettings"]), + client_scopes_behavior=dict( + type="str", aliases=["clientScopesBehavior"], choices=["ignore", "patch", "idempotent"], default="ignore" + ), + default_client_scopes=dict(type="list", elements="str", aliases=["defaultClientScopes"]), + optional_client_scopes=dict(type="list", elements="str", aliases=["optionalClientScopes"]), ) argument_spec.update(meta_args) - module = AnsibleModule(argument_spec=argument_spec, - supports_check_mode=True, - required_one_of=([['client_id', 'id'], - ['token', 'auth_realm', 'auth_username', 'auth_password']]), - required_together=([['auth_realm', 'auth_username', 'auth_password']])) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [ + ["client_id", "id"], + ["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"], + ] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) - result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API try: @@ -998,140 +1331,186 @@ def main(): kc = KeycloakAPI(module, connection_header) - realm = module.params.get('realm') - cid = module.params.get('id') - state = module.params.get('state') + realm = module.params.get("realm") + cid = module.params.get("id") + clientScopesBehavior = module.params.get("client_scopes_behavior") + state = module.params.get("state") # Filter and map the parameters names that apply to the client - client_params = [x for x in module.params - if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and - module.params.get(x) is not None] + client_params = [ + x + for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ["state", "realm"] and module.params.get(x) is not None + ] # See if it already exists in Keycloak if cid is None: - before_client = kc.get_client_by_clientid(module.params.get('client_id'), realm=realm) + before_client = kc.get_client_by_clientid(module.params.get("client_id"), realm=realm) if before_client is not None: - cid = before_client['id'] + cid = before_client["id"] else: before_client = kc.get_client_by_id(cid, realm=realm) + normalize_kc_resp(before_client) + if before_client is None: before_client = {} # Build a proposed changeset from parameters given to this module changeset = {} + # Collect attribute-mapped parameters to inject into attributes later + attribute_overrides = {} + for param_name, (attr_key, transform_fn) in ATTRIBUTE_PARAMS.items(): + param_value = module.params.get(param_name) + if param_value is not None: + attribute_overrides[attr_key] = transform_fn(param_value) if transform_fn else param_value + for client_param in client_params: new_param_value = module.params.get(client_param) - # some lists in the Keycloak API are sorted, some are not. - if isinstance(new_param_value, list): - if client_param in ['attributes']: - try: - new_param_value = sorted(new_param_value) - except TypeError: - pass + # Skip attribute-mapped params; they are handled via attributes + if client_param in ATTRIBUTE_PARAMS: + continue + # Unfortunately, the ansible argument spec checker introduces variables with null values when # they are not specified - if client_param == 'protocol_mappers': + if client_param == "protocol_mappers": new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value] - elif client_param == 'authentication_flow_binding_overrides': - new_param_value = flow_binding_from_dict_to_model(new_param_value, realm, kc) + elif client_param == "authentication_flow_binding_overrides": + desired_flow_binding_overrides = flow_binding_from_dict_to_model(new_param_value, realm, kc) + existing_flow_binding_overrides = before_client.get("authenticationFlowBindingOverrides") + # ensures idempotency + new_param_value = merge_settings_without_absent_nulls( + existing_flow_binding_overrides, desired_flow_binding_overrides + ) + elif client_param == "attributes" and "attributes" in before_client: + desired_attributes = new_param_value + existing_attributes = copy.deepcopy(before_client["attributes"]) + # ensures idempotency + new_param_value = merge_settings_without_absent_nulls(existing_attributes, desired_attributes) + elif client_param in ["clientScopesBehavior", "client_scopes_behavior"]: + continue + elif client_param in LIST_CLIENT_PARAMS and isinstance(new_param_value, str): + new_param_value = [new_param_value] + elif client_param in ("root_url", "admin_url", "base_url") and new_param_value == "": + continue changeset[camel(client_param)] = new_param_value + # Inject attribute-mapped parameters into the attributes dict + if attribute_overrides: + if "attributes" not in changeset: + changeset["attributes"] = copy.deepcopy(before_client.get("attributes", {})) + if isinstance(changeset["attributes"], dict): + changeset["attributes"].update(attribute_overrides) + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) - desired_client = before_client.copy() + desired_client = copy.deepcopy(before_client) desired_client.update(changeset) - result['proposed'] = sanitize_cr(changeset) - result['existing'] = sanitize_cr(before_client) + result["proposed"] = sanitize_cr(changeset) + result["existing"] = sanitize_cr(before_client) # Cater for when it doesn't exist (an empty dict) if not before_client: - if state == 'absent': + if state == "absent": # Do nothing and exit if module._diff: - result['diff'] = dict(before='', after='') - result['changed'] = False - result['end_state'] = {} - result['msg'] = 'Client does not exist; doing nothing.' + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = "Client does not exist; doing nothing." module.exit_json(**result) # Process a creation - result['changed'] = True + result["changed"] = True - if 'clientId' not in desired_client: - module.fail_json(msg='client_id needs to be specified when creating a new client') - if 'protocol' not in desired_client: - desired_client['protocol'] = PROTOCOL_OPENID_CONNECT + if "clientId" not in desired_client: + module.fail_json(msg="client_id needs to be specified when creating a new client") + if "protocol" not in desired_client: + desired_client["protocol"] = PROTOCOL_OPENID_CONNECT if module._diff: - result['diff'] = dict(before='', after=sanitize_cr(desired_client)) + result["diff"] = dict(before="", after=sanitize_cr(desired_client)) if module.check_mode: module.exit_json(**result) # create it kc.create_client(desired_client, realm=realm) - after_client = kc.get_client_by_clientid(desired_client['clientId'], realm=realm) + after_client = kc.get_client_by_clientid(desired_client["clientId"], realm=realm) - result['end_state'] = sanitize_cr(after_client) + result["end_state"] = sanitize_cr(after_client) - result['msg'] = 'Client %s has been created.' % desired_client['clientId'] + result["msg"] = f"Client {desired_client['clientId']} has been created." module.exit_json(**result) else: - if state == 'present': + if state == "present": + # We can only compare the current client with the proposed updates we have + desired_client_with_scopes, before_client_with_scopes = normalise_scopes_for_behavior( + desired_client, before_client, clientScopesBehavior + ) + check_optional_scopes_not_default(desired_client, clientScopesBehavior, module) + # no changes + if client_rep_is_unchanged(before_client_with_scopes, desired_client_with_scopes): + result["changed"] = False + result["end_state"] = sanitize_cr(before_client) + result["msg"] = f"No changes required for Client {desired_client['clientId']}." + module.exit_json(**result) + + before_norm = normalise_cr(before_client_with_scopes, remove_ids=True) + desired_norm = normalise_cr(desired_client_with_scopes, remove_ids=True) + # Process an update - result['changed'] = True + result["changed"] = True if module.check_mode: - # We can only compare the current client with the proposed updates we have - before_norm = normalise_cr(before_client, remove_ids=True) - desired_norm = normalise_cr(desired_client, remove_ids=True) + result["end_state"] = sanitize_cr(desired_client_with_scopes) if module._diff: - result['diff'] = dict(before=sanitize_cr(before_norm), - after=sanitize_cr(desired_norm)) - result['changed'] = not is_struct_included(desired_norm, before_norm, CLIENT_META_DATA) - + result["diff"] = dict(before=sanitize_cr(before_norm), after=sanitize_cr(desired_norm)) module.exit_json(**result) # do the update kc.update_client(cid, desired_client, realm=realm) + remove_default_client_scopes(desired_client_with_scopes, before_client_with_scopes, realm, kc) + remove_optional_client_scopes(desired_client_with_scopes, before_client_with_scopes, realm, kc) + add_default_client_scopes(desired_client_with_scopes, before_client_with_scopes, realm, kc) + add_optional_client_scopes(desired_client_with_scopes, before_client_with_scopes, realm, kc) + after_client = kc.get_client_by_id(cid, realm=realm) - if before_client == after_client: - result['changed'] = False + normalize_kc_resp(after_client) + if module._diff: - result['diff'] = dict(before=sanitize_cr(before_client), - after=sanitize_cr(after_client)) + result["diff"] = dict(before=sanitize_cr(before_client), after=sanitize_cr(after_client)) - result['end_state'] = sanitize_cr(after_client) + result["end_state"] = sanitize_cr(after_client) - result['msg'] = 'Client %s has been updated.' % desired_client['clientId'] + result["msg"] = f"Client {desired_client['clientId']} has been updated." module.exit_json(**result) else: # Process a deletion (because state was not 'present') - result['changed'] = True + result["changed"] = True if module._diff: - result['diff'] = dict(before=sanitize_cr(before_client), after='') + result["diff"] = dict(before=sanitize_cr(before_client), after="") if module.check_mode: module.exit_json(**result) # delete it kc.delete_client(cid, realm=realm) - result['proposed'] = {} + result["proposed"] = {} - result['end_state'] = {} + result["end_state"] = {} - result['msg'] = 'Client %s has been deleted.' % before_client['clientId'] + result["msg"] = f"Client {before_client['clientId']} has been deleted." module.exit_json(**result) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/plugins/modules/keycloak_client_rolemapping.py b/plugins/modules/keycloak_client_rolemapping.py new file mode 100644 index 0000000..999739f --- /dev/null +++ b/plugins/modules/keycloak_client_rolemapping.py @@ -0,0 +1,412 @@ + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_client_rolemapping + +short_description: Allows administration of Keycloak client_rolemapping with the Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to add, remove or modify Keycloak client_rolemapping with the Keycloak REST API. It requires access + to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. + In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with + the scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way + by this module. You may pass single values for attributes when calling the module, and this is translated into a list + suitable for the API. + - When updating a client_rolemapping, where possible provide the role ID to the module. This removes a lookup to the API + to translate the name into the role ID. +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the client_rolemapping. + - On V(present), the client_rolemapping is created if it does not yet exist, or updated with the parameters + you provide. + - On V(absent), the client_rolemapping is removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this role_representation resides. + default: 'master' + + group_name: + type: str + description: + - Name of the group to be mapped. + - This parameter is required (can be replaced by gid for less API call). + parents: + type: list + description: + - List of parent groups for the group to handle sorted top to bottom. + - Set this if your group is a subgroup and you do not provide the GID in O(gid). + elements: dict + suboptions: + id: + type: str + description: + - Identify parent by ID. + - Needs less API calls than using O(parents[].name). + - A deep parent chain can be started at any point when first given parent is given as ID. + - Note that in principle both ID and name can be specified at the same time but current implementation only always + use just one of them, with ID being preferred. + name: + type: str + description: + - Identify parent by name. + - Needs more internal API calls than using O(parents[].id) to map names to ID's under the hood. + - When giving a parent chain with only names it must be complete up to the top. + - Note that in principle both ID and name can be specified at the same time but current implementation only always + use just one of them, with ID being preferred. + gid: + type: str + description: + - ID of the group to be mapped. + - This parameter is not required for updating or deleting the rolemapping but providing it reduces the number of API + calls required. + client_id: + type: str + description: + - Name of the client to be mapped (different than O(cid)). + - This parameter is required (can be replaced by cid for less API call). + cid: + type: str + description: + - ID of the client to be mapped. + - This parameter is not required for updating or deleting the rolemapping but providing it reduces the number of API + calls required. + roles: + description: + - Roles to be mapped to the group. + type: list + elements: dict + suboptions: + name: + type: str + description: + - Name of the role_representation. + - This parameter is required only when creating or updating the role_representation. + id: + type: str + description: + - The unique identifier for this role_representation. + - This parameter is not required for updating or deleting a role_representation but providing it reduces the number + of API calls required. +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Gaëtan Daubresse (@Gaetan2907) +""" + +EXAMPLES = r""" +- name: Map a client role to a group, authentication with credentials + middleware_automation.keycloak.keycloak_client_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + client_id: client1 + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a group, authentication with token + middleware_automation.keycloak.keycloak_client_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + state: present + client_id: client1 + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a subgroup, authentication with token + middleware_automation.keycloak.keycloak_client_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + state: present + client_id: client1 + group_name: subgroup1 + parents: + - name: parent-group + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Unmap client role from a group + middleware_automation.keycloak.keycloak_client_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: absent + client_id: client1 + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Role role1 assigned to group group1." + +proposed: + description: Representation of proposed client role mapping. + returned: always + type: dict + sample: {"clientId": "test"} + +existing: + description: + - Representation of existing client role mapping. + - The sample is truncated. + returned: always + type: dict + sample: + { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256" + } + } + +end_state: + description: + - Representation of client role mapping after module execution. + - The sample is truncated. + returned: on success + type: dict + sample: + { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256" + } + } +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + roles_spec = dict( + name=dict(type="str"), + id=dict(type="str"), + ) + + meta_args = dict( + state=dict(default="present", choices=["present", "absent"]), + realm=dict(default="master"), + gid=dict(type="str"), + group_name=dict(type="str"), + parents=dict( + type="list", + elements="dict", + options=dict(id=dict(type="str"), name=dict(type="str")), + ), + cid=dict(type="str"), + client_id=dict(type="str"), + roles=dict(type="list", elements="dict", options=roles_spec), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + state = module.params.get("state") + cid = module.params.get("cid") + client_id = module.params.get("client_id") + gid = module.params.get("gid") + group_name = module.params.get("group_name") + roles = module.params.get("roles") + parents = module.params.get("parents") + + # Check the parameters + if cid is None and client_id is None: + module.fail_json(msg="Either the `client_id` or `cid` has to be specified.") + if gid is None and group_name is None: + module.fail_json(msg="Either the `group_name` or `gid` has to be specified.") + + # Get the potential missing parameters + if gid is None: + group_rep = kc.get_group_by_name(group_name, realm=realm, parents=parents) + if group_rep is not None: + gid = group_rep["id"] + else: + module.fail_json(msg=f"Could not fetch group {group_name}:") + if cid is None: + cid = kc.get_client_id(client_id, realm=realm) + if cid is None: + module.fail_json(msg=f"Could not fetch client {client_id}:") + if roles is None: + module.exit_json(msg="Nothing to do (no roles specified).") + else: + for role in roles: + if role["name"] is None and role["id"] is None: + module.fail_json(msg="Either the `name` or `id` has to be specified on each role.") + # Fetch missing role_id + if role["id"] is None: + role_id = kc.get_client_role_id_by_name(cid, role["name"], realm=realm) + if role_id is not None: + role["id"] = role_id + else: + module.fail_json(msg=f"Could not fetch role {role['name']}:") + # Fetch missing role_name + else: + role["name"] = kc.get_client_group_rolemapping_by_id(gid, cid, role["id"], realm=realm)["name"] + if role["name"] is None: + module.fail_json(msg=f"Could not fetch role {role['id']}") + + # Get effective client-level role mappings + available_roles_before = kc.get_client_group_available_rolemappings(gid, cid, realm=realm) + assigned_roles_before = kc.get_client_group_composite_rolemappings(gid, cid, realm=realm) + + result["existing"] = assigned_roles_before + result["proposed"] = list(assigned_roles_before) if assigned_roles_before else [] + + update_roles = [] + for role in roles: + # Fetch roles to assign if state present + if state == "present": + for available_role in available_roles_before: + if role["name"] == available_role["name"]: + update_roles.append( + { + "id": role["id"], + "name": role["name"], + } + ) + result["proposed"].append(available_role) + # Fetch roles to remove if state absent + else: + for assigned_role in assigned_roles_before: + if role["name"] == assigned_role["name"]: + update_roles.append( + { + "id": role["id"], + "name": role["name"], + } + ) + if assigned_role in result["proposed"]: # Handle double removal + result["proposed"].remove(assigned_role) + + if len(update_roles): + if state == "present": + # Assign roles + result["changed"] = True + if module._diff: + result["diff"] = dict(before=assigned_roles_before, after=result["proposed"]) + if module.check_mode: + module.exit_json(**result) + kc.add_group_rolemapping(gid, cid, update_roles, realm=realm) + result["msg"] = f"Roles {update_roles} assigned to group {group_name}." + assigned_roles_after = kc.get_client_group_composite_rolemappings(gid, cid, realm=realm) + result["end_state"] = assigned_roles_after + module.exit_json(**result) + else: + # Remove mapping of role + result["changed"] = True + if module._diff: + result["diff"] = dict(before=assigned_roles_before, after=result["proposed"]) + if module.check_mode: + module.exit_json(**result) + kc.delete_group_rolemapping(gid, cid, update_roles, realm=realm) + result["msg"] = f"Roles {update_roles} removed from group {group_name}." + assigned_roles_after = kc.get_client_group_composite_rolemappings(gid, cid, realm=realm) + result["end_state"] = assigned_roles_after + module.exit_json(**result) + # Do nothing + else: + result["changed"] = False + result["msg"] = ( + f"Nothing to do, roles {roles} are {'mapped' if state == 'present' else 'not mapped'} with group {group_name}." + ) + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_client_rolescope.py b/plugins/modules/keycloak_client_rolescope.py new file mode 100644 index 0000000..0904730 --- /dev/null +++ b/plugins/modules/keycloak_client_rolescope.py @@ -0,0 +1,285 @@ + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_client_rolescope + +short_description: Allows administration of Keycloak client roles scope to restrict the usage of certain roles to a other + specific client applications + +version_added: "3.0.0" + +description: + - This module allows you to add or remove Keycloak roles from clients scope using the Keycloak REST API. It requires access + to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. + In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with + the scope tailored to your needs and a user having the expected roles. + - Client O(client_id) must have O(middleware_automation.keycloak.keycloak_client#module:full_scope_allowed) set to V(false). + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way + by this module. You may pass single values for attributes when calling the module, and this is translated into a list + suitable for the API. +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the role mapping. + - On V(present), all roles in O(role_names) are mapped if not exist yet. + - On V(absent), all roles mapping in O(role_names) are removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + type: str + description: + - The Keycloak realm under which clients resides. + default: 'master' + + client_id: + type: str + required: true + description: + - Roles provided in O(role_names) while be added to this client scope. + client_scope_id: + type: str + description: + - If the O(role_names) are client role, the client ID under which it resides. + - If this parameter is absent, the roles are considered a realm role. + role_names: + required: true + type: list + elements: str + description: + - Names of roles to manipulate. + - If O(client_scope_id) is present, all roles must be under this client. + - If O(client_scope_id) is absent, all roles must be under the realm. +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Andre Desrosiers (@desand01) +""" + +EXAMPLES = r""" +- name: Add roles to public client scope + middleware_automation.keycloak.keycloak_client_rolescope: + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_id: frontend-client-public + client_scope_id: backend-client-private + role_names: + - backend-role-admin + - backend-role-user + +- name: Remove roles from public client scope + middleware_automation.keycloak.keycloak_client_rolescope: + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_id: frontend-client-public + client_scope_id: backend-client-private + role_names: + - backend-role-admin + state: absent + +- name: Add realm roles to public client scope + middleware_automation.keycloak.keycloak_client_rolescope: + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_id: frontend-client-public + role_names: + - realm-role-admin + - realm-role-user +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Client role scope for frontend-client-public has been updated" + +end_state: + description: Representation of role role scope after module execution. + returned: on success + type: list + elements: dict + sample: + [ + { + "clientRole": false, + "composite": false, + "containerId": "MyCustomRealm", + "id": "47293104-59a6-46f0-b460-2e9e3c9c424c", + "name": "backend-role-admin" + }, + { + "clientRole": false, + "composite": false, + "containerId": "MyCustomRealm", + "id": "39c62a6d-542c-4715-92d2-41021eb33967", + "name": "backend-role-user" + } + ] +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + client_id=dict(type="str", required=True), + client_scope_id=dict(type="str"), + realm=dict(type="str", default="master"), + role_names=dict(type="list", elements="str", required=True), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + result = dict(changed=False, msg="", diff={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + clientid = module.params.get("client_id") + client_scope_id = module.params.get("client_scope_id") + role_names = module.params.get("role_names") + state = module.params.get("state") + + objRealm = kc.get_realm_by_id(realm) + if not objRealm: + module.fail_json(msg=f"Failed to retrive realm '{realm}'") + + objClient = kc.get_client_by_clientid(clientid, realm) + if not objClient: + module.fail_json(msg=f"Failed to retrive client '{realm}.{clientid}'") + if objClient["fullScopeAllowed"] and state == "present": + module.fail_json(msg=f"FullScopeAllowed is active for Client '{realm}.{clientid}'") + + if client_scope_id: + objClientScope = kc.get_client_by_clientid(client_scope_id, realm) + if not objClientScope: + module.fail_json(msg=f"Failed to retrive client '{realm}.{client_scope_id}'") + before_role_mapping = kc.get_client_role_scope_from_client(objClient["id"], objClientScope["id"], realm) + else: + before_role_mapping = kc.get_client_role_scope_from_realm(objClient["id"], realm) + + if client_scope_id: + # retrive all role from client_scope + client_scope_roles_by_name = kc.get_client_roles_by_id(objClientScope["id"], realm) + else: + # retrive all role from realm + client_scope_roles_by_name = kc.get_realm_roles(realm) + + # convert to indexed Dict by name + client_scope_roles_by_name = {role["name"]: role for role in client_scope_roles_by_name} + role_mapping_by_name = {role["name"]: role for role in before_role_mapping} + role_mapping_to_manipulate = [] + + if state == "present": + # update desired + for role_name in role_names: + if role_name not in client_scope_roles_by_name: + if client_scope_id: + module.fail_json(msg=f"Failed to retrive role '{realm}.{client_scope_id}.{role_name}'") + else: + module.fail_json(msg=f"Failed to retrive role '{realm}.{role_name}'") + if role_name not in role_mapping_by_name: + role_mapping_to_manipulate.append(client_scope_roles_by_name[role_name]) + role_mapping_by_name[role_name] = client_scope_roles_by_name[role_name] + else: + # remove role if present + for role_name in role_names: + if role_name in role_mapping_by_name: + role_mapping_to_manipulate.append(role_mapping_by_name[role_name]) + del role_mapping_by_name[role_name] + + before_role_mapping = sorted(before_role_mapping, key=lambda d: d["name"]) + desired_role_mapping = sorted(role_mapping_by_name.values(), key=lambda d: d["name"]) + + result["changed"] = len(role_mapping_to_manipulate) > 0 + + if result["changed"]: + result["diff"] = dict(before=before_role_mapping, after=desired_role_mapping) + + if not result["changed"]: + # no changes + result["end_state"] = before_role_mapping + result["msg"] = f"No changes required for client role scope {clientid}." + elif state == "present": + # doing update + if module.check_mode: + result["end_state"] = desired_role_mapping + elif client_scope_id: + result["end_state"] = kc.update_client_role_scope_from_client( + role_mapping_to_manipulate, objClient["id"], objClientScope["id"], realm + ) + else: + result["end_state"] = kc.update_client_role_scope_from_realm( + role_mapping_to_manipulate, objClient["id"], realm + ) + result["msg"] = f"Client role scope for {clientid} has been updated" + else: + # doing delete + if module.check_mode: + result["end_state"] = desired_role_mapping + elif client_scope_id: + result["end_state"] = kc.delete_client_role_scope_from_client( + role_mapping_to_manipulate, objClient["id"], objClientScope["id"], realm + ) + else: + result["end_state"] = kc.delete_client_role_scope_from_realm( + role_mapping_to_manipulate, objClient["id"], realm + ) + result["msg"] = f"Client role scope for {clientid} has been deleted" + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_client_scope.py b/plugins/modules/keycloak_client_scope.py index 1649f31..5ec63fd 100644 --- a/plugins/modules/keycloak_client_scope.py +++ b/plugins/modules/keycloak_client_scope.py @@ -14,6 +14,8 @@ module: keycloak_client_scope short_description: Allows administration of Keycloak client scopes via Keycloak API +version_added: "3.0.0" + description: - This module allows you to add, remove or modify Keycloak client scopes via the Keycloak REST API. It requires access to the REST API via OpenID Connect; the user connecting and the client being @@ -106,6 +108,7 @@ options: extends_documentation_fragment: - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak - middleware_automation.keycloak.attributes author: diff --git a/plugins/modules/keycloak_clientscope_type.py b/plugins/modules/keycloak_clientscope_type.py new file mode 100644 index 0000000..f9cd07b --- /dev/null +++ b/plugins/modules/keycloak_clientscope_type.py @@ -0,0 +1,313 @@ + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_clientscope_type + +short_description: Set the type of aclientscope in realm or client using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to set the type (optional, default) of clientscopes using the Keycloak REST API. It requires access + to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. + In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with + the scope tailored to your needs and a user having the expected roles. +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + realm: + type: str + description: + - The Keycloak realm. + default: 'master' + + client_id: + description: + - The O(client_id) of the client. If not set the clientscope types are set as a default for the realm. + aliases: + - clientId + type: str + + default_clientscopes: + description: + - Client scopes that should be of type default. + type: list + elements: str + + optional_clientscopes: + description: + - Client scopes that should be of type optional. + type: list + elements: str + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Simon Pahl (@simonpahl) +""" + +EXAMPLES = r""" +- name: Set default client scopes on realm level + middleware_automation.keycloak.keycloak_clientscope_type: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: "MyCustomRealm" + default_clientscopes: ['profile', 'roles'] + delegate_to: localhost + + +- name: Set default and optional client scopes on client level with token auth + middleware_automation.keycloak.keycloak_clientscope_type: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + realm: "MyCustomRealm" + client_id: "MyCustomClient" + default_clientscopes: ['profile', 'roles'] + optional_clientscopes: ['phone'] + delegate_to: localhost +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "" +proposed: + description: Representation of proposed client-scope types mapping. + returned: always + type: dict + sample: + { + "default_clientscopes": [ + "profile", + "role" + ], + "optional_clientscopes": [] + } +existing: + description: + - Representation of client scopes before module execution. + returned: always + type: dict + sample: + { + "default_clientscopes": [ + "profile", + "role" + ], + "optional_clientscopes": [ + "phone" + ] + } +end_state: + description: + - Representation of client scopes after module execution. + - The sample is truncated. + returned: on success + type: dict + sample: + { + "default_clientscopes": [ + "profile", + "role" + ], + "optional_clientscopes": [] + } +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def keycloak_clientscope_type_module(): + """ + Returns an AnsibleModule definition. + + :return: argument_spec dict + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + realm=dict(default="master"), + client_id=dict(type="str", aliases=["clientId"]), + default_clientscopes=dict(type="list", elements="str"), + optional_clientscopes=dict(type="list", elements="str"), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [ + ["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"], + ["default_clientscopes", "optional_clientscopes"], + ] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + mutually_exclusive=[["token", "auth_realm"], ["token", "auth_username"], ["token", "auth_password"]], + ) + + return module + + +def clientscopes_to_add(existing, proposed): + to_add = [] + existing_clientscope_ids = extract_field(existing, "id") + for clientscope in proposed: + if clientscope["id"] not in existing_clientscope_ids: + to_add.append(clientscope) + return to_add + + +def clientscopes_to_delete(existing, proposed): + to_delete = [] + proposed_clientscope_ids = extract_field(proposed, "id") + for clientscope in existing: + if clientscope["id"] not in proposed_clientscope_ids: + to_delete.append(clientscope) + return to_delete + + +def extract_field(dictionary, field="name"): + return [cs[field] for cs in dictionary] + + +def normalize_scopes(scopes): + scopes_copy = scopes.copy() + if isinstance(scopes_copy.get("default_clientscopes"), list): + scopes_copy["default_clientscopes"] = sorted(scopes_copy["default_clientscopes"]) + if isinstance(scopes_copy.get("optional_clientscopes"), list): + scopes_copy["optional_clientscopes"] = sorted(scopes_copy["optional_clientscopes"]) + return scopes_copy + + +def main(): + """ + Module keycloak_clientscope_type + + :return: + """ + + module = keycloak_clientscope_type_module() + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + client_id = module.params.get("client_id") + default_clientscopes = module.params.get("default_clientscopes") + optional_clientscopes = module.params.get("optional_clientscopes") + + result = dict(changed=False, msg="", proposed={}, existing={}, end_state={}) + + all_clientscopes = kc.get_clientscopes(realm) + default_clientscopes_real = [] + optional_clientscopes_real = [] + + for client_scope in all_clientscopes: + if default_clientscopes is not None and client_scope["name"] in default_clientscopes: + default_clientscopes_real.append(client_scope) + if optional_clientscopes is not None and client_scope["name"] in optional_clientscopes: + optional_clientscopes_real.append(client_scope) + + if default_clientscopes is not None and len(default_clientscopes_real) != len(default_clientscopes): + module.fail_json(msg="At least one of the default_clientscopes does not exist!") + + if optional_clientscopes is not None and len(optional_clientscopes_real) != len(optional_clientscopes): + module.fail_json(msg="At least one of the optional_clientscopes does not exist!") + + result["proposed"].update( + { + "default_clientscopes": "no-change" if default_clientscopes is None else default_clientscopes, + "optional_clientscopes": "no-change" if optional_clientscopes is None else optional_clientscopes, + } + ) + + default_clientscopes_existing = kc.get_default_clientscopes(realm, client_id) + optional_clientscopes_existing = kc.get_optional_clientscopes(realm, client_id) + + result["existing"].update( + { + "default_clientscopes": extract_field(default_clientscopes_existing), + "optional_clientscopes": extract_field(optional_clientscopes_existing), + } + ) + + if module._diff: + result["diff"] = dict(before=normalize_scopes(result["existing"]), after=normalize_scopes(result["proposed"])) + + default_clientscopes_add = clientscopes_to_add(default_clientscopes_existing, default_clientscopes_real) + optional_clientscopes_add = clientscopes_to_add(optional_clientscopes_existing, optional_clientscopes_real) + + default_clientscopes_delete = clientscopes_to_delete(default_clientscopes_existing, default_clientscopes_real) + optional_clientscopes_delete = clientscopes_to_delete(optional_clientscopes_existing, optional_clientscopes_real) + + result["changed"] = any( + len(x) > 0 + for x in [ + default_clientscopes_add, + optional_clientscopes_add, + default_clientscopes_delete, + optional_clientscopes_delete, + ] + ) + + if module.check_mode: + module.exit_json(**result) + + # first delete so clientscopes can change type + for clientscope in default_clientscopes_delete: + kc.delete_default_clientscope(clientscope["id"], realm, client_id) + for clientscope in optional_clientscopes_delete: + kc.delete_optional_clientscope(clientscope["id"], realm, client_id) + + for clientscope in default_clientscopes_add: + kc.add_default_clientscope(clientscope["id"], realm, client_id) + for clientscope in optional_clientscopes_add: + kc.add_optional_clientscope(clientscope["id"], realm, client_id) + + result["end_state"].update( + { + "default_clientscopes": extract_field(kc.get_default_clientscopes(realm, client_id)), + "optional_clientscopes": extract_field(kc.get_optional_clientscopes(realm, client_id)), + } + ) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_clientsecret_info.py b/plugins/modules/keycloak_clientsecret_info.py new file mode 100644 index 0000000..a598fe7 --- /dev/null +++ b/plugins/modules/keycloak_clientsecret_info.py @@ -0,0 +1,166 @@ + +# Copyright (c) 2022, Fynn Chen +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_clientsecret_info + +short_description: Retrieve client secret using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to get a Keycloak client secret using the Keycloak REST API. It requires access to the REST API + using OpenID Connect; the user connecting and the client being used must have the requisite access rights. In a default + Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + - When retrieving a new client secret, where possible provide the client's O(id) (not O(client_id)) to the module. This + removes a lookup to the API to translate the O(client_id) into the client ID. + - 'Note that this module returns the client secret. To avoid this showing up in the logs, please add C(no_log: true) to + the task.' +attributes: + action_group: + version_added: "3.0.0" + +options: + realm: + type: str + description: + - They Keycloak realm under which this client resides. + default: 'master' + + id: + description: + - The unique identifier for this client. + - This parameter is not required for getting or generating a client secret but providing it reduces the number of API + calls required. + type: str + + client_id: + description: + - The O(client_id) of the client. Passing this instead of O(id) results in an extra API call. + aliases: + - clientId + type: str + + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.attributes.info_module + +author: + - Fynn Chen (@fynncfchen) + - John Cant (@johncant) +""" + +EXAMPLES = r""" +- name: Get a Keycloak client secret, authentication with credentials + middleware_automation.keycloak.keycloak_clientsecret_info: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + no_log: true + +- name: Get a new Keycloak client secret, authentication with token + middleware_automation.keycloak.keycloak_clientsecret_info: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + delegate_to: localhost + no_log: true + +- name: Get a new Keycloak client secret, passing client_id instead of id + middleware_automation.keycloak.keycloak_clientsecret_info: + client_id: 'myClientId' + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + delegate_to: localhost + no_log: true + +- name: Get a new Keycloak client secret, authentication with auth_client_id and auth_client_secret + middleware_automation.keycloak.keycloak_clientsecret_info: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + realm: MyCustomRealm + auth_client_id: admin-cli + auth_client_secret: SECRET + auth_keycloak_url: https://auth.example.com + delegate_to: localhost + no_log: true +""" + +RETURN = r""" +msg: + description: Textual description of whether we succeeded or failed. + returned: always + type: str + +clientsecret_info: + description: Representation of the client secret. + returned: on success + type: complex + contains: + type: + description: Credential type. + type: str + returned: always + sample: secret + value: + description: Client secret. + type: str + returned: always + sample: cUGnX1EIeTtPPAkcyGMv0ncyqDPu68P1 +""" + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, +) +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak_clientsecret import ( + keycloak_clientsecret_module, + keycloak_clientsecret_module_resolve_params, +) + + +def main(): + """ + Module keycloak_clientsecret_info + + :return: + """ + + module = keycloak_clientsecret_module() + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + id, realm = keycloak_clientsecret_module_resolve_params(module, kc) + + clientsecret = kc.get_clientsecret(id=id, realm=realm) + + result = {"clientsecret_info": clientsecret, "msg": f"Get client secret successful for ID {id}"} + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_clientsecret_regenerate.py b/plugins/modules/keycloak_clientsecret_regenerate.py new file mode 100644 index 0000000..4b96956 --- /dev/null +++ b/plugins/modules/keycloak_clientsecret_regenerate.py @@ -0,0 +1,176 @@ + +# Copyright (c) 2022, Fynn Chen +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_clientsecret_regenerate + +short_description: Regenerate Keycloak client secret using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to regenerate a Keycloak client secret using the Keycloak REST API. It requires access to the REST + API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. In a default + Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + - When regenerating a client secret, where possible provide the client's ID (not client_id) to the module. This removes + a lookup to the API to translate the client_id into the client ID. + - 'Note that this module returns the client secret. To avoid this showing up in the logs, please add C(no_log: true) to + the task.' +attributes: + check_mode: + support: full + diff_mode: + support: none + action_group: + version_added: "3.0.0" + +options: + realm: + type: str + description: + - They Keycloak realm under which this client resides. + default: 'master' + + id: + description: + - The unique identifier for this client. + - This parameter is not required for getting or generating a client secret but providing it reduces the number of API + calls required. + type: str + + client_id: + description: + - The client_id of the client. Passing this instead of ID results in an extra API call. + aliases: + - clientId + type: str + + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Fynn Chen (@fynncfchen) + - John Cant (@johncant) +""" + +EXAMPLES = r""" +- name: Regenerate a Keycloak client secret, authentication with credentials + middleware_automation.keycloak.keycloak_clientsecret_regenerate: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + no_log: true + +- name: Regenerate a Keycloak client secret, authentication with token + middleware_automation.keycloak.keycloak_clientsecret_regenerate: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + delegate_to: localhost + no_log: true + +- name: Regenerate a Keycloak client secret, passing client_id instead of id + middleware_automation.keycloak.keycloak_clientsecret_info: + client_id: 'myClientId' + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + delegate_to: localhost + no_log: true + +- name: Regenerate a new Keycloak client secret, authentication with auth_client_id and auth_client_secret + middleware_automation.keycloak.keycloak_clientsecret_regenerate: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + realm: MyCustomRealm + auth_client_id: admin-cli + auth_client_secret: SECRET + auth_keycloak_url: https://auth.example.com + delegate_to: localhost + no_log: true +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +end_state: + description: Representation of the client credential after module execution. + returned: on success + type: complex + contains: + type: + description: Credential type. + type: str + returned: always + sample: secret + value: + description: Client secret. + type: str + returned: always + sample: cUGnX1EIeTtPPAkcyGMv0ncyqDPu68P1 +""" + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, +) +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak_clientsecret import ( + keycloak_clientsecret_module, + keycloak_clientsecret_module_resolve_params, +) + + +def main(): + """ + Module keycloak_clientsecret_regenerate + + :return: + """ + + module = keycloak_clientsecret_module() + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + id, realm = keycloak_clientsecret_module_resolve_params(module, kc) + + if module.check_mode: + dummy_result = { + "msg": "No action taken while in check mode", + "end_state": {"type": "secret", "value": "X" * 32}, + } + module.exit_json(**dummy_result) + + # Create new secret + clientsecret = kc.create_clientsecret(id=id, realm=realm) + + result = {"msg": f"New client secret has been generated for ID {id}", "end_state": clientsecret} + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_clienttemplate.py b/plugins/modules/keycloak_clienttemplate.py new file mode 100644 index 0000000..a764973 --- /dev/null +++ b/plugins/modules/keycloak_clienttemplate.py @@ -0,0 +1,471 @@ + +# Copyright (c) 2017, Eike Frost +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_clienttemplate + +short_description: Allows administration of Keycloak client templates using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows the administration of Keycloak client templates using the Keycloak REST API. It requires access to + the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. + In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with + the scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). + - The Keycloak API does not always enforce for only sensible settings to be used -- you can set SAML-specific settings on + an OpenID Connect client for instance and the other way around. Be careful. If you do not specify a setting, usually a + sensible default is chosen. +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the client template. + - On V(present), the client template is created (or updated if it exists already). + - On V(absent), the client template is removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + + id: + description: + - ID of client template to be worked on. This is usually a UUID. + type: str + + realm: + description: + - Realm this client template is found in. + type: str + default: master + + name: + description: + - Name of the client template. + type: str + + description: + description: + - Description of the client template in Keycloak. + type: str + + protocol: + description: + - Type of client template. + - The V(docker-v2) value was added in middleware_automation.keycloak 8.6.0. + choices: ['openid-connect', 'saml', 'docker-v2'] + type: str + + full_scope_allowed: + description: + - Is the "Full Scope Allowed" feature set for this client template or not. This is C(fullScopeAllowed) in the Keycloak + REST API. + type: bool + + protocol_mappers: + description: + - A list of dicts defining protocol mappers for this client template. This is C(protocolMappers) in the Keycloak REST + API. + type: list + elements: dict + suboptions: + consentRequired: + description: + - Specifies whether a user needs to provide consent to a client for this mapper to be active. + type: bool + + consentText: + description: + - The human-readable name of the consent the user is presented to accept. + type: str + + id: + description: + - Usually a UUID specifying the internal ID of this protocol mapper instance. + type: str + + name: + description: + - The name of this protocol mapper. + type: str + + protocol: + description: + - This specifies for which protocol this protocol mapper is active. + choices: ['openid-connect', 'saml', 'docker-v2'] + type: str + + protocolMapper: + description: + - 'The Keycloak-internal name of the type of this protocol-mapper. While an exhaustive list is impossible to provide + since this may be extended through SPIs by the user of Keycloak, by default Keycloak as of 3.4 ships with at least:' + - V(docker-v2-allow-all-mapper). + - V(oidc-address-mapper). + - V(oidc-full-name-mapper). + - V(oidc-group-membership-mapper). + - V(oidc-hardcoded-claim-mapper). + - V(oidc-hardcoded-role-mapper). + - V(oidc-role-name-mapper). + - V(oidc-script-based-protocol-mapper). + - V(oidc-sha256-pairwise-sub-mapper). + - V(oidc-usermodel-attribute-mapper). + - V(oidc-usermodel-client-role-mapper). + - V(oidc-usermodel-property-mapper). + - V(oidc-usermodel-realm-role-mapper). + - V(oidc-usersessionmodel-note-mapper). + - V(saml-group-membership-mapper). + - V(saml-hardcode-attribute-mapper). + - V(saml-hardcode-role-mapper). + - V(saml-role-list-mapper). + - V(saml-role-name-mapper). + - V(saml-user-attribute-mapper). + - V(saml-user-property-mapper). + - V(saml-user-session-note-mapper). + - An exhaustive list of available mappers on your installation can be obtained on the admin console by going to + Server Info -> Providers and looking under 'protocol-mapper'. + type: str + + config: + description: + - Dict specifying the configuration options for the protocol mapper; the contents differ depending on the value + of O(protocol_mappers[].protocolMapper) and are not documented other than by the source of the mappers and its + parent class(es). An example is given below. It is easiest to obtain valid config values by dumping an already-existing + protocol mapper configuration through check-mode in the RV(existing) field. + type: dict + + attributes: + description: + - A dict of further attributes for this client template. This can contain various configuration settings, though in + the default installation of Keycloak as of 3.4, none are documented or known, so this is usually empty. + type: dict + +notes: + - The Keycloak REST API defines further fields (namely C(bearerOnly), C(consentRequired), C(standardFlowEnabled), C(implicitFlowEnabled), + C(directAccessGrantsEnabled), C(serviceAccountsEnabled), C(publicClient), and C(frontchannelLogout)) which, while available + with keycloak_client, do not have any effect on Keycloak client-templates and are discarded if supplied with an API request + changing client-templates. As such, they are not available through this module. +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Eike Frost (@eikef) +""" + +EXAMPLES = r""" +- name: Create or update Keycloak client template (minimal), authentication with credentials + middleware_automation.keycloak.keycloak_client: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + name: this_is_a_test + delegate_to: localhost + +- name: Create or update Keycloak client template (minimal), authentication with token + middleware_automation.keycloak.keycloak_clienttemplate: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + token: TOKEN + realm: master + name: this_is_a_test + delegate_to: localhost + +- name: Delete Keycloak client template + middleware_automation.keycloak.keycloak_client: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + state: absent + name: test01 + delegate_to: localhost + +- name: Create or update Keycloak client template (with a protocol mapper) + middleware_automation.keycloak.keycloak_client: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + name: this_is_a_test + protocol_mappers: + - config: + access.token.claim: true + claim.name: "family_name" + id.token.claim: true + jsonType.label: String + user.attribute: lastName + userinfo.token.claim: true + consentRequired: true + consentText: "${familyName}" + name: family name + protocol: openid-connect + protocolMapper: oidc-usermodel-property-mapper + full_scope_allowed: false + id: bce6f5e9-d7d3-4955-817e-c5b7f8d65b3f + delegate_to: localhost +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Client template testclient has been updated" + +proposed: + description: Representation of proposed client template. + returned: always + type: dict + sample: {"name": "test01"} + +existing: + description: Representation of existing client template (sample is truncated). + returned: always + type: dict + sample: + { + "description": "test01", + "fullScopeAllowed": false, + "id": "9c3712ab-decd-481e-954f-76da7b006e5f", + "name": "test01", + "protocol": "saml" + } + +end_state: + description: Representation of client template after module execution (sample is truncated). + returned: on success + type: dict + sample: + { + "description": "test01", + "fullScopeAllowed": false, + "id": "9c3712ab-decd-481e-954f-76da7b006e5f", + "name": "test01", + "protocol": "saml" + } +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + protmapper_spec = dict( + consentRequired=dict(type="bool"), + consentText=dict(type="str"), + id=dict(type="str"), + name=dict(type="str"), + protocol=dict(type="str", choices=["openid-connect", "saml", "docker-v2"]), + protocolMapper=dict(type="str"), + config=dict(type="dict"), + ) + + meta_args = dict( + realm=dict(type="str", default="master"), + state=dict(default="present", choices=["present", "absent"]), + id=dict(type="str"), + name=dict(type="str"), + description=dict(type="str"), + protocol=dict(type="str", choices=["openid-connect", "saml", "docker-v2"]), + attributes=dict(type="dict"), + full_scope_allowed=dict(type="bool"), + protocol_mappers=dict(type="list", elements="dict", options=protmapper_spec), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [ + ["id", "name"], + ["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"], + ] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + state = module.params.get("state") + cid = module.params.get("id") + + # Filter and map the parameters names that apply to the client template + clientt_params = [ + x + for x in module.params + if x + not in [ + "state", + "auth_keycloak_url", + "auth_client_id", + "auth_realm", + "auth_client_secret", + "auth_username", + "auth_password", + "validate_certs", + "realm", + ] + and module.params.get(x) is not None + ] + + # See if it already exists in Keycloak + if cid is None: + before_clientt = kc.get_client_template_by_name(module.params.get("name"), realm=realm) + if before_clientt is not None: + cid = before_clientt["id"] + else: + before_clientt = kc.get_client_template_by_id(cid, realm=realm) + + if before_clientt is None: + before_clientt = {} + + result["existing"] = before_clientt + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for clientt_param in clientt_params: + # lists in the Keycloak API are sorted + new_param_value = module.params.get(clientt_param) + if isinstance(new_param_value, list): + try: + new_param_value = sorted(new_param_value) + except TypeError: + pass + changeset[camel(clientt_param)] = new_param_value + + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) + desired_clientt = before_clientt.copy() + desired_clientt.update(changeset) + + result["proposed"] = changeset + + # Cater for when it doesn't exist (an empty dict) + if not before_clientt: + if state == "absent": + # Do nothing and exit + if module._diff: + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = "Client template does not exist, doing nothing." + module.exit_json(**result) + + # Process a creation + result["changed"] = True + + if "name" not in desired_clientt: + module.fail_json(msg="name needs to be specified when creating a new client") + + if module._diff: + result["diff"] = dict(before="", after=desired_clientt) + + if module.check_mode: + module.exit_json(**result) + + # create it + kc.create_client_template(desired_clientt, realm=realm) + after_clientt = kc.get_client_template_by_name(desired_clientt["name"], realm=realm) + + result["end_state"] = after_clientt + + result["msg"] = f"Client template {desired_clientt['name']} has been created." + module.exit_json(**result) + + else: + if state == "present": + # Process an update + + result["changed"] = True + if module.check_mode: + # We can only compare the current client template with the proposed updates we have + if module._diff: + result["diff"] = dict(before=before_clientt, after=desired_clientt) + + module.exit_json(**result) + + # do the update + kc.update_client_template(cid, desired_clientt, realm=realm) + + after_clientt = kc.get_client_template_by_id(cid, realm=realm) + if before_clientt == after_clientt: + result["changed"] = False + + result["end_state"] = after_clientt + + if module._diff: + result["diff"] = dict(before=before_clientt, after=after_clientt) + + result["msg"] = f"Client template {desired_clientt['name']} has been updated." + module.exit_json(**result) + + else: + # Process a deletion (because state was not 'present') + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=before_clientt, after="") + + if module.check_mode: + module.exit_json(**result) + + # delete it + kc.delete_client_template(cid, realm=realm) + result["proposed"] = {} + + result["end_state"] = {} + + result["msg"] = f"Client template {before_clientt['name']} has been deleted." + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_component.py b/plugins/modules/keycloak_component.py new file mode 100644 index 0000000..0993fc8 --- /dev/null +++ b/plugins/modules/keycloak_component.py @@ -0,0 +1,326 @@ + +# Copyright (c) 2024, Björn Bösel +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_component + +short_description: Allows administration of Keycloak components using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows the administration of Keycloak components using the Keycloak REST API. It requires access to the REST + API using OpenID Connect; the user connecting and the realm being used must have the requisite access rights. In a default + Keycloak installation, C(admin-cli) and an C(admin) user would work, as would a separate realm definition with the scope + tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). Aliases are provided so camelCased versions can be + used as well. +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the Keycloak component. + - On V(present), the component is created (or updated if it exists already). + - On V(absent), the component is removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + name: + description: + - Name of the component to create. + type: str + required: true + parent_id: + description: + - The parent_id of the component. In practice the ID (name) of the realm. + type: str + required: true + provider_id: + description: + - The name of the "provider ID" for the key. + type: str + required: true + provider_type: + description: + - The name of the "provider type" for the key. That is, V(org.keycloak.storage.UserStorageProvider), V(org.keycloak.userprofile.UserProfileProvider), + ... + - See U(https://www.keycloak.org/docs/latest/server_development/index.html#_providers). + type: str + required: true + config: + description: + - Configuration properties for the provider. + - Contents vary depending on the provider type. + type: dict + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Björn Bösel (@fivetide) +""" + +EXAMPLES = r""" +- name: Manage Keycloak User Storage Provider + middleware_automation.keycloak.keycloak_component: + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + name: my storage provider + state: present + parent_id: some_realm + provider_id: my storage + provider_type: "org.keycloak.storage.UserStorageProvider" + config: + myCustomKey: "my_custom_key" + cachePolicy: "NO_CACHE" + enabled: true +""" + +RETURN = r""" +end_state: + description: Representation of the keycloak_component after module execution. + returned: on success + type: dict + contains: + id: + description: ID of the component. + type: str + returned: when O(state=present) + sample: 5b7ec13f-99da-46ad-8326-ab4c73cf4ce4 + name: + description: Name of the component. + type: str + returned: when O(state=present) + sample: mykey + parentId: + description: ID of the realm this key belongs to. + type: str + returned: when O(state=present) + sample: myrealm + providerId: + description: The ID of the key provider. + type: str + returned: when O(state=present) + sample: rsa + providerType: + description: The type of provider. + type: str + returned: when O(state=present) + config: + description: Component configuration. + type: dict +""" + +from copy import deepcopy +from urllib.parse import urlencode + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + keycloak_argument_spec, +) + + +def main(): + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(type="str", default="present", choices=["present", "absent"]), + name=dict(type="str", required=True), + parent_id=dict(type="str", required=True), + provider_id=dict(type="str", required=True), + provider_type=dict(type="str", required=True), + config=dict( + type="dict", + ), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", end_state={}, diff=dict(before={}, after={})) + + # This will include the current state of the component if it is already + # present. This is only used for diff-mode. + before_component = {} + before_component["config"] = {} + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + params_to_ignore = list(keycloak_argument_spec().keys()) + ["state", "parent_id"] + + # Filter and map the parameters names that apply to the role + component_params = [x for x in module.params if x not in params_to_ignore and module.params.get(x) is not None] + + provider_type = module.params.get("provider_type") + + # Build a proposed changeset from parameters given to this module + changeset = {} + changeset["config"] = {} + + # Generate a JSON payload for Keycloak Admin API from the module + # parameters. Parameters that do not belong to the JSON payload (e.g. + # "state" or "auth_keycloal_url") have been filtered away earlier (see + # above). + # + # This loop converts Ansible module parameters (snake-case) into + # Keycloak-compatible format (camel-case). For example private_key + # becomes privateKey. + # + # It also converts bool, str and int parameters into lists with a single + # entry of 'str' type. Bool values are also lowercased. This is required + # by Keycloak. + # + for component_param in component_params: + if component_param == "config": + for config_param in module.params.get("config"): + changeset["config"][camel(config_param)] = [] + raw_value = module.params.get("config")[config_param] + if isinstance(raw_value, bool): + value = str(raw_value).lower() + else: + value = str(raw_value) + + changeset["config"][camel(config_param)].append(value) + else: + # No need for camelcase in here as these are one word parameters + new_param_value = module.params.get(component_param) + changeset[camel(component_param)] = new_param_value + + # Make a deep copy of the changeset. This is use when determining + # changes to the current state. + changeset_copy = deepcopy(changeset) + + # Make it easier to refer to current module parameters + name = module.params.get("name") + state = module.params.get("state") + provider_type = module.params.get("provider_type") + parent_id = module.params.get("parent_id") + + # Get a list of all Keycloak components that are of keyprovider type. + current_components = kc.get_components(urlencode(dict(type=provider_type)), parent_id) + + # If this component is present get its key ID. Confusingly the key ID is + # also known as the Provider ID. + component_id = None + + # Track individual parameter changes + changes = "" + + # This tells Ansible whether the key was changed (added, removed, modified) + result["changed"] = False + + # Loop through the list of components. If we encounter a component whose + # name matches the value of the name parameter then assume the key is + # already present. + for component in current_components: + if component["name"] == name: + component_id = component["id"] + changeset["id"] = component_id + changeset_copy["id"] = component_id + + # Compare top-level parameters + for param in changeset: + before_component[param] = component[param] + + if changeset_copy[param] != component[param] and param != "config": + changes += f"{param}: {component[param]} -> {changeset_copy[param]}, " + result["changed"] = True + # Compare parameters under the "config" key + for p, v in changeset_copy["config"].items(): + try: + before_component["config"][p] = component["config"][p] or [] + except KeyError: + before_component["config"][p] = [] + if v != component["config"][p]: + changes += f"config.{p}: {component['config'][p]} -> {v}, " + result["changed"] = True + + # Check all the possible states of the resource and do what is needed to + # converge current state with desired state (create, update or delete + # the key). + if component_id and state == "present": + if result["changed"]: + if module._diff: + result["diff"] = dict(before=before_component, after=changeset_copy) + + if module.check_mode: + result["msg"] = f"Component {name} would be changed: {changes.strip(', ')}" + else: + kc.update_component(changeset, parent_id) + result["msg"] = f"Component {name} changed: {changes.strip(', ')}" + else: + result["msg"] = f"Component {name} was in sync" + + result["end_state"] = changeset_copy + elif component_id and state == "absent": + if module._diff: + result["diff"] = dict(before=before_component, after={}) + + if module.check_mode: + result["changed"] = True + result["msg"] = f"Component {name} would be deleted" + else: + kc.delete_component(component_id, parent_id) + result["changed"] = True + result["msg"] = f"Component {name} deleted" + + result["end_state"] = {} + elif not component_id and state == "present": + if module._diff: + result["diff"] = dict(before={}, after=changeset_copy) + + if module.check_mode: + result["changed"] = True + result["msg"] = f"Component {name} would be created" + else: + kc.create_component(changeset, parent_id) + result["changed"] = True + result["msg"] = f"Component {name} created" + + result["end_state"] = changeset_copy + elif not component_id and state == "absent": + result["changed"] = False + result["msg"] = f"Component {name} not present" + result["end_state"] = {} + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_component_info.py b/plugins/modules/keycloak_component_info.py new file mode 100644 index 0000000..a08c8fb --- /dev/null +++ b/plugins/modules/keycloak_component_info.py @@ -0,0 +1,169 @@ + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_component_info + +short_description: Retrieve component info in Keycloak + +version_added: "3.0.0" + +description: + - This module retrieve information on component from Keycloak. +attributes: + action_group: + version_added: "3.0.0" + +options: + realm: + description: + - The name of the realm. + required: true + type: str + name: + description: + - Name of the Component. + type: str + provider_type: + description: + - Provider type of components. + - 'Examples: V(org.keycloak.storage.UserStorageProvider), V(org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy), + V(org.keycloak.keys.KeyProvider), V(org.keycloak.userprofile.UserProfileProvider), V(org.keycloak.storage.ldap.mappers.LDAPStorageMapper).' + type: str + parent_id: + description: + - Container ID of the components. + type: str + + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.attributes.info_module + +author: + - Andre Desrosiers (@desand01) +""" + +EXAMPLES = r""" +- name: Retrive info of a UserStorageProvider named myldap + middleware_automation.keycloak.keycloak_component_info: + auth_keycloak_url: http://localhost:8080 + auth_username: admin + auth_password: password + auth_realm: master + realm: myrealm + name: myldap + provider_type: org.keycloak.storage.UserStorageProvider + +- name: Retrive key info component + middleware_automation.keycloak.keycloak_component_info: + auth_keycloak_url: http://localhost:8080 + auth_username: admin + auth_password: password + auth_realm: master + realm: myrealm + name: rsa-enc-generated + provider_type: org.keycloak.keys.KeyProvider + +- name: Retrive all component from realm master + middleware_automation.keycloak.keycloak_component_info: + auth_keycloak_url: http://localhost:8080 + auth_username: admin + auth_password: password + auth_realm: master + realm: myrealm + +- name: Retrive all sub components of parent component filter by type + middleware_automation.keycloak.keycloak_component_info: + auth_keycloak_url: http://localhost:8080 + auth_username: admin + auth_password: password + auth_realm: master + realm: myrealm + parent_id: "075ef2fa-19fc-4a6d-bf4c-249f57365fd2" + provider_type: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" +""" + +RETURN = r""" +components: + description: JSON representation of components. + returned: always + type: list + elements: dict +""" + +from urllib.parse import quote + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + name=dict(type="str"), + realm=dict(type="str", required=True), + parent_id=dict(type="str"), + provider_type=dict(type="str"), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + result = dict(changed=False, components=[]) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + parentId = module.params.get("parent_id") + name = module.params.get("name") + providerType = module.params.get("provider_type") + + objRealm = kc.get_realm_by_id(realm) + if not objRealm: + module.fail_json(msg=f"Failed to retrive realm '{realm}'") + + filters = [] + + if parentId: + filters.append(f"parent={quote(parentId, safe='')}") + else: + filters.append(f"parent={quote(objRealm['id'], safe='')}") + + if name: + filters.append(f"name={quote(name, safe='')}") + if providerType: + filters.append(f"type={quote(providerType, safe='')}") + + result["components"] = kc.get_components(filter="&".join(filters), realm=realm) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_group.py b/plugins/modules/keycloak_group.py new file mode 100644 index 0000000..bcf67bd --- /dev/null +++ b/plugins/modules/keycloak_group.py @@ -0,0 +1,492 @@ + +# Copyright (c) 2019, Adam Goossens +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_group + +short_description: Allows administration of Keycloak groups using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to add, remove or modify Keycloak groups using the Keycloak REST API. It requires access to the + REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. In + a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with the + scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/20.0.2/rest-api/index.html). + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way + by this module. You may pass single values for attributes when calling the module, and this is translated into a list + suitable for the API. + - When updating a group, where possible provide the group ID to the module. This removes a lookup to the API to translate + the name into the group ID. +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the group. + - On V(present), the group is created if it does not yet exist, or updated with the parameters you provide. + - On V(absent), the group is removed if it exists. Be aware that absenting a group with subgroups automatically deletes + all its subgroups too. + default: 'present' + type: str + choices: + - present + - absent + + name: + type: str + description: + - Name of the group. + - This parameter is required only when creating or updating the group. + realm: + type: str + description: + - They Keycloak realm under which this group resides. + default: 'master' + + id: + type: str + description: + - The unique identifier for this group. + - This parameter is not required for updating or deleting a group but providing it reduces the number of API calls required. + attributes: + type: dict + description: + - A dict of key/value pairs to set as custom attributes for the group. + - Values may be single values (for example a string) or a list of strings. + parents: + type: list + description: + - List of parent groups for the group to handle sorted top to bottom. + - Set this to create a group as a subgroup of another group or groups (parents) or when accessing an existing subgroup + by name. + - Not necessary to set when accessing an existing subgroup by its C(ID) because in that case the group can be directly + queried without necessarily knowing its parent(s). + elements: dict + suboptions: + id: + type: str + description: + - Identify parent by ID. + - Needs less API calls than using O(parents[].name). + - A deep parent chain can be started at any point when first given parent is given as ID. + - Note that in principle both ID and name can be specified at the same time but current implementation only always + use just one of them, with ID being preferred. + name: + type: str + description: + - Identify parent by name. + - Needs more internal API calls than using O(parents[].id) to map names to ID's under the hood. + - When giving a parent chain with only names it must be complete up to the top. + - Note that in principle both ID and name can be specified at the same time but current implementation only always + use just one of them, with ID being preferred. +notes: + - Presently, the RV(end_state.realmRoles), RV(end_state.clientRoles), and RV(end_state.access) attributes returned by the + Keycloak API are read-only for groups. This limitation will be removed in a later version of this module. +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Adam Goossens (@adamgoossens) +""" + +EXAMPLES = r""" +- name: Create a Keycloak group, authentication with credentials + middleware_automation.keycloak.keycloak_group: + name: my-new-kc-group + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + register: result_new_kcgrp + delegate_to: localhost + +- name: Create a Keycloak group, authentication with token + middleware_automation.keycloak.keycloak_group: + name: my-new-kc-group + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + delegate_to: localhost + +- name: Delete a keycloak group + middleware_automation.keycloak.keycloak_group: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + state: absent + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Delete a Keycloak group based on name + middleware_automation.keycloak.keycloak_group: + name: my-group-for-deletion + state: absent + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Update the name of a Keycloak group + middleware_automation.keycloak.keycloak_group: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + name: an-updated-kc-group-name + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Create a keycloak group with some custom attributes + middleware_automation.keycloak.keycloak_group: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + name: my-new_group + attributes: + attrib1: value1 + attrib2: value2 + attrib3: + - with + - numerous + - individual + - list + - items + delegate_to: localhost + +- name: Create a Keycloak subgroup of a base group (using parent name) + middleware_automation.keycloak.keycloak_group: + name: my-new-kc-group-sub + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parents: + - name: my-new-kc-group + register: result_new_kcgrp_sub + delegate_to: localhost + +- name: Create a Keycloak subgroup of a base group (using parent id) + middleware_automation.keycloak.keycloak_group: + name: my-new-kc-group-sub2 + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parents: + - id: "{{ result_new_kcgrp.end_state.id }}" + delegate_to: localhost + +- name: Create a Keycloak subgroup of a subgroup (using parent names) + middleware_automation.keycloak.keycloak_group: + name: my-new-kc-group-sub-sub + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parents: + - name: my-new-kc-group + - name: my-new-kc-group-sub + delegate_to: localhost + +- name: Create a Keycloak subgroup of a subgroup (using direct parent id) + middleware_automation.keycloak.keycloak_group: + name: my-new-kc-group-sub-sub + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parents: + - id: "{{ result_new_kcgrp_sub.end_state.id }}" + delegate_to: localhost +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +end_state: + description: Representation of the group after module execution (sample is truncated). + returned: on success + type: complex + contains: + id: + description: GUID that identifies the group. + type: str + returned: always + sample: 23f38145-3195-462c-97e7-97041ccea73e + name: + description: Name of the group. + type: str + returned: always + sample: grp-test-123 + attributes: + description: Attributes applied to this group. + type: dict + returned: always + sample: + attr1: ["val1", "val2", "val3"] + path: + description: URI path to the group. + type: str + returned: always + sample: /grp-test-123 + realmRoles: + description: An array of the realm-level roles granted to this group. + type: list + returned: always + sample: [] + subGroups: + description: A list of groups that are children of this group. These groups have the same parameters as documented here. + type: list + returned: always + clientRoles: + description: A list of client-level roles granted to this group. + type: list + returned: always + sample: [] + access: + description: A dict describing the accesses you have to this group based on the credentials used. + type: dict + returned: always + sample: + manage: true + manageMembership: true + view: true +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(default="present", choices=["present", "absent"]), + realm=dict(default="master"), + id=dict(type="str"), + name=dict(type="str"), + attributes=dict(type="dict"), + parents=dict( + type="list", + elements="dict", + options=dict(id=dict(type="str"), name=dict(type="str")), + ), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [ + ["id", "name"], + ["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"], + ] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", diff={}, group="") + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + state = module.params.get("state") + gid = module.params.get("id") + name = module.params.get("name") + attributes = module.params.get("attributes") + + parents = module.params.get("parents") + + # attributes in Keycloak have their values returned as lists + # using the API. attributes is a dict, so we'll transparently convert + # the values to lists. + if attributes is not None: + for key, val in module.params["attributes"].items(): + module.params["attributes"][key] = [val] if not isinstance(val, list) else val + + # Filter and map the parameters names that apply to the group + group_params = [ + x + for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ["state", "realm", "parents"] + and module.params.get(x) is not None + ] + + # See if it already exists in Keycloak + if gid is None: + before_group = kc.get_group_by_name(name, realm=realm, parents=parents) + else: + before_group = kc.get_group_by_groupid(gid, realm=realm) + + if before_group is None: + before_group = {} + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for param in group_params: + new_param_value = module.params.get(param) + old_value = before_group[param] if param in before_group else None + if new_param_value != old_value: + changeset[camel(param)] = new_param_value + + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) + desired_group = before_group.copy() + desired_group.update(changeset) + + # Cater for when it doesn't exist (an empty dict) + if not before_group: + if state == "absent": + # Do nothing and exit + if module._diff: + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = "Group does not exist; doing nothing." + module.exit_json(**result) + + # Process a creation + result["changed"] = True + + if name is None: + module.fail_json(msg="name must be specified when creating a new group") + + if module._diff: + result["diff"] = dict(before="", after=desired_group) + + if module.check_mode: + module.exit_json(**result) + + # create it ... + if parents: + # ... as subgroup of another parent group + kc.create_subgroup(parents, desired_group, realm=realm) + else: + # ... as toplvl base group + kc.create_group(desired_group, realm=realm) + + after_group = kc.get_group_by_name(name, realm, parents=parents) + + result["end_state"] = after_group + + result["msg"] = f"Group {after_group['name']} has been created with ID {after_group['id']}" + module.exit_json(**result) + + else: + if state == "present": + # Process an update + + # no changes + if desired_group == before_group: + result["changed"] = False + result["end_state"] = desired_group + result["msg"] = f"No changes required to group {before_group['name']}." + module.exit_json(**result) + + # doing an update + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=before_group, after=desired_group) + + if module.check_mode: + module.exit_json(**result) + + # do the update + kc.update_group(desired_group, realm=realm) + + after_group = kc.get_group_by_groupid(desired_group["id"], realm=realm) + + result["end_state"] = after_group + + result["msg"] = f"Group {after_group['id']} has been updated" + module.exit_json(**result) + + else: + # Process a deletion (because state was not 'present') + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=before_group, after="") + + if module.check_mode: + module.exit_json(**result) + + # delete it + gid = before_group["id"] + kc.delete_group(groupid=gid, realm=realm) + + result["end_state"] = {} + + result["msg"] = f"Group {before_group['name']} has been deleted" + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_identity_provider.py b/plugins/modules/keycloak_identity_provider.py new file mode 100644 index 0000000..a7052c9 --- /dev/null +++ b/plugins/modules/keycloak_identity_provider.py @@ -0,0 +1,775 @@ + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_identity_provider + +short_description: Allows administration of Keycloak identity providers using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to add, remove or modify Keycloak identity providers using the Keycloak REST API. It requires access + to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. + In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with + the scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/15.0/rest-api/index.html). +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the identity provider. + - On V(present), the identity provider is created if it does not yet exist, or updated with the parameters you provide. + - On V(absent), the identity provider is removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + description: + - The Keycloak realm under which this identity provider resides. + default: 'master' + type: str + + alias: + description: + - The alias uniquely identifies an identity provider and it is also used to build the redirect URI. + required: true + type: str + + display_name: + description: + - Friendly name for identity provider. + aliases: + - displayName + type: str + + enabled: + description: + - Enable/disable this identity provider. + type: bool + + store_token: + description: + - Enable/disable whether tokens must be stored after authenticating users. + aliases: + - storeToken + type: bool + + add_read_token_role_on_create: + description: + - Enable/disable whether new users can read any stored tokens. This assigns the C(broker.read-token) role. + aliases: + - addReadTokenRoleOnCreate + type: bool + + trust_email: + description: + - If enabled, email provided by this provider is not verified even if verification is enabled for the realm. + aliases: + - trustEmail + type: bool + + link_only: + description: + - If true, users cannot log in through this provider. They can only link to this provider. This is useful if you do + not want to allow login from the provider, but want to integrate with a provider. + aliases: + - linkOnly + type: bool + + first_broker_login_flow_alias: + description: + - Alias of authentication flow, which is triggered after first login with this identity provider. + aliases: + - firstBrokerLoginFlowAlias + type: str + + post_broker_login_flow_alias: + description: + - Alias of authentication flow, which is triggered after each login with this identity provider. + aliases: + - postBrokerLoginFlowAlias + type: str + + authenticate_by_default: + description: + - Specifies if this identity provider should be used by default for authentication even before displaying login screen. + aliases: + - authenticateByDefault + type: bool + + provider_id: + description: + - Protocol used by this provider (supported values are V(oidc) or V(saml)). + aliases: + - providerId + type: str + + config: + description: + - Dict specifying the configuration options for the provider; the contents differ depending on the value of O(provider_id). + Examples are given below for V(oidc) and V(saml). It is easiest to obtain valid config values by dumping an already-existing + identity provider configuration through check-mode in the RV(existing) field. + type: dict + suboptions: + hide_on_login_page: + description: + - If hidden, login with this provider is possible only if requested explicitly, for example using the C(kc_idp_hint) + parameter. + aliases: + - hideOnLoginPage + type: bool + + gui_order: + description: + - Number defining order of the provider in GUI (for example, on Login page). + aliases: + - guiOrder + type: int + + sync_mode: + description: + - Default sync mode for all mappers. The sync mode determines when user data is synced using the mappers. + aliases: + - syncMode + type: str + + issuer: + description: + - The issuer identifier for the issuer of the response. If not provided, no validation is performed. + type: str + + authorizationUrl: + description: + - The Authorization URL. + type: str + + tokenUrl: + description: + - The Token URL. + type: str + + logoutUrl: + description: + - End session endpoint to use to logout user from external IDP. + type: str + + userInfoUrl: + description: + - The User Info URL. + type: str + + clientAuthMethod: + description: + - The client authentication method. + type: str + + clientId: + description: + - The client or client identifier registered within the identity provider. + type: str + + clientSecret: + description: + - The client or client secret registered within the identity provider. + type: str + + defaultScope: + description: + - The scopes to be sent when asking for authorization. + type: str + + validateSignature: + description: + - Enable/disable signature validation of external IDP signatures. + type: bool + + useJwksUrl: + description: + - If V(true), identity provider public keys are downloaded from given JWKS URL. + type: bool + + jwksUrl: + description: + - URL where identity provider keys in JWK format are stored. See JWK specification for more details. + type: str + + entityId: + description: + - The Entity ID that is used to uniquely identify this SAML Service Provider. + type: str + + singleSignOnServiceUrl: + description: + - The URL that must be used to send authentication requests (SAML AuthnRequest). + type: str + + singleLogoutServiceUrl: + description: + - The URL that must be used to send logout requests. + type: str + + backchannelSupported: + description: + - Does the external IDP support backchannel logout? + type: str + + nameIDPolicyFormat: + description: + - Specifies the URI reference corresponding to a name identifier format. + type: str + + principalType: + description: + - Way to identify and track external users from the assertion. + type: str + + fromUrl: + description: + - IDP well-known OpenID Connect configuration URL. + - Support only O(provider_id=oidc). + - O(config.fromUrl) is mutually exclusive with O(config.userInfoUrl), O(config.authorizationUrl), + O(config.tokenUrl), O(config.logoutUrl), O(config.issuer) and O(config.jwksUrl). + type: str + + mappers: + description: + - A list of dicts defining mappers associated with this Identity Provider. + type: list + elements: dict + suboptions: + id: + description: + - Unique ID of this mapper. + type: str + + name: + description: + - Name of the mapper. + type: str + + identityProviderAlias: + description: + - Alias of the identity provider for this mapper. + type: str + + identityProviderMapper: + description: + - Type of mapper. + type: str + + config: + description: + - Dict specifying the configuration options for the mapper; the contents differ depending on the value of O(mappers[].identityProviderMapper). + type: dict + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Laurent Paumier (@laurpaum) +""" + +EXAMPLES = r""" +- name: Create OIDC identity provider, authentication with credentials + middleware_automation.keycloak.keycloak_identity_provider: + state: present + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: admin + auth_password: admin + realm: myrealm + alias: oidc-idp + display_name: OpenID Connect IdP + enabled: true + provider_id: oidc + config: + issuer: https://idp.example.com + authorizationUrl: https://idp.example.com + tokenUrl: https://idp.example.com/token + userInfoUrl: https://idp.example.com/userinfo + clientAuthMethod: client_secret_post + clientId: my-client + clientSecret: secret + syncMode: FORCE + mappers: + - name: first_name + identityProviderMapper: oidc-user-attribute-idp-mapper + config: + claim: first_name + user.attribute: first_name + syncMode: INHERIT + - name: last_name + identityProviderMapper: oidc-user-attribute-idp-mapper + config: + claim: last_name + user.attribute: last_name + syncMode: INHERIT + +- name: Create OIDC identity provider, with well-known configuration URL + middleware_automation.keycloak.keycloak_identity_provider: + state: present + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: admin + auth_password: admin + realm: myrealm + alias: oidc-idp + display_name: OpenID Connect IdP + enabled: true + provider_id: oidc + config: + fromUrl: https://the-idp.example.com/realms/idprealm/.well-known/openid-configuration + clientAuthMethod: client_secret_post + clientId: my-client + clientSecret: secret + +- name: Create SAML identity provider, authentication with credentials + middleware_automation.keycloak.keycloak_identity_provider: + state: present + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: admin + auth_password: admin + realm: myrealm + alias: saml-idp + display_name: SAML IdP + enabled: true + provider_id: saml + config: + entityId: https://auth.example.com/realms/myrealm + singleSignOnServiceUrl: https://idp.example.com/login + wantAuthnRequestsSigned: true + wantAssertionsSigned: true + mappers: + - name: roles + identityProviderMapper: saml-user-attribute-idp-mapper + config: + user.attribute: roles + attribute.friendly.name: User Roles + attribute.name: roles + syncMode: INHERIT + +- name: Create OIDC identity provider, authentication with credentials and advanced claim to group + middleware_automation.keycloak.keycloak_identity_provider: + state: present + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: admin + auth_password: admin + realm: myrealm + alias: oidc-idp + display_name: OpenID Connect IdP + enabled: true + provider_id: oidc + config: + issuer: https://idp.example.com + authorizationUrl: https://idp.example.com + tokenUrl: https://idp.example.com/token + userInfoUrl: https://idp.example.com/userinfo + clientAuthMethod: client_secret_post + clientId: my-client + clientSecret: secret + syncMode: FORCE + mappers: + - name: group_name + identityProviderMapper: oidc-advanced-group-idp-mapper + config: + claims: '[{"key":"my_key","value":"my_value"}]' + group: group_name + syncMode: INHERIT +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Identity provider my-idp has been created" + +proposed: + description: Representation of proposed identity provider. + returned: always + type: dict + sample: + { + "config": { + "authorizationUrl": "https://idp.example.com", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "secret", + "issuer": "https://idp.example.com", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "providerId": "oidc" + } + +existing: + description: Representation of existing identity provider. + returned: always + type: dict + sample: + { + "addReadTokenRoleOnCreate": false, + "alias": "my-idp", + "authenticateByDefault": false, + "config": { + "authorizationUrl": "https://old.example.com", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "**********", + "issuer": "https://old.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://old.example.com/token", + "userInfoUrl": "https://old.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": true, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "4d28d7e3-1b80-45bb-8a30-5822bf55aa1c", + "linkOnly": false, + "providerId": "oidc", + "storeToken": false, + "trustEmail": false + } + +end_state: + description: Representation of identity provider after module execution. + returned: on success + type: dict + sample: + { + "addReadTokenRoleOnCreate": false, + "alias": "my-idp", + "authenticateByDefault": false, + "config": { + "authorizationUrl": "https://idp.example.com", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "**********", + "issuer": "https://idp.example.com", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": true, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "4d28d7e3-1b80-45bb-8a30-5822bf55aa1c", + "linkOnly": false, + "providerId": "oidc", + "storeToken": false, + "trustEmail": false + } +""" + +from copy import deepcopy + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + keycloak_argument_spec, +) + + +def sanitize(idp): + idpcopy = deepcopy(idp) + if "config" in idpcopy: + if "clientSecret" in idpcopy["config"]: + idpcopy["config"]["clientSecret"] = "**********" + return idpcopy + + +def get_identity_provider_with_mappers(kc, alias, realm): + idp = kc.get_identity_provider(alias, realm) + if idp is not None: + idp["mappers"] = sorted(kc.get_identity_provider_mappers(alias, realm), key=lambda x: x.get("name")) + # clientSecret returned by API when using `get_identity_provider(alias, realm)` is always ********** + # to detect changes to the secret, we get the actual cleartext secret from the full realm info + if "config" in idp: + if "clientSecret" in idp["config"]: + for idp_from_realm in kc.get_realm_by_id(realm).get("identityProviders", []): + if idp_from_realm["internalId"] == idp["internalId"]: + cleartext_secret = idp_from_realm.get("config", {}).get("clientSecret") + if cleartext_secret: + idp["config"]["clientSecret"] = cleartext_secret + if idp is None: + idp = {} + return idp + + +def fetch_identity_provider_wellknown_config(kc, config): + """ + Fetches OpenID Connect well-known configuration from a given URL and updates the config dict with discovered endpoints. + Support for oidc providers only. + :param kc: KeycloakAPI instance used to fetch endpoints and handle errors. + :param config: Dictionary containing identity provider configuration, must include 'fromUrl' key to trigger fetch. + :return: None. The config dict is updated in-place. + """ + if config and "fromUrl" in config: + if "providerId" in config and config["providerId"] != "oidc": + kc.module.fail_json(msg="Only 'oidc' provider_id is supported when using 'fromUrl'.") + endpoints = ["userInfoUrl", "authorizationUrl", "tokenUrl", "logoutUrl", "issuer", "jwksUrl"] + if any(k in config for k in endpoints): + kc.module.fail_json( + msg="Cannot specify both 'fromUrl' and 'userInfoUrl', 'authorizationUrl', 'tokenUrl', 'logoutUrl', 'issuer' or 'jwksUrl'." + ) + openIdConfig = kc.fetch_idp_endpoints_import_config_url( + fromUrl=config["fromUrl"], realm=kc.module.params.get("realm", "master") + ) + for k in endpoints: + if k in openIdConfig: + config[k] = openIdConfig[k] + del config["fromUrl"] + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + mapper_spec = dict( + id=dict(type="str"), + name=dict(type="str"), + identityProviderAlias=dict(type="str"), + identityProviderMapper=dict(type="str"), + config=dict(type="dict"), + ) + + meta_args = dict( + state=dict(type="str", default="present", choices=["present", "absent"]), + realm=dict(type="str", default="master"), + alias=dict(type="str", required=True), + add_read_token_role_on_create=dict(type="bool", aliases=["addReadTokenRoleOnCreate"]), + authenticate_by_default=dict(type="bool", aliases=["authenticateByDefault"]), + config=dict(type="dict"), + display_name=dict(type="str", aliases=["displayName"]), + enabled=dict(type="bool"), + first_broker_login_flow_alias=dict(type="str", aliases=["firstBrokerLoginFlowAlias"]), + link_only=dict(type="bool", aliases=["linkOnly"]), + post_broker_login_flow_alias=dict(type="str", aliases=["postBrokerLoginFlowAlias"]), + provider_id=dict(type="str", aliases=["providerId"]), + store_token=dict(type="bool", aliases=["storeToken"]), + trust_email=dict(type="bool", aliases=["trustEmail"]), + mappers=dict(type="list", elements="dict", options=mapper_spec), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + alias = module.params.get("alias") + state = module.params.get("state") + config = module.params.get("config") + + fetch_identity_provider_wellknown_config(kc, config) + + # Filter and map the parameters names that apply to the identity provider. + idp_params = [ + x + for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ["state", "realm", "mappers"] + and module.params.get(x) is not None + ] + + # See if it already exists in Keycloak + before_idp = get_identity_provider_with_mappers(kc, alias, realm) + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for param in idp_params: + new_param_value = module.params.get(param) + old_value = before_idp[camel(param)] if camel(param) in before_idp else None + if new_param_value != old_value: + changeset[camel(param)] = new_param_value + + # special handling of mappers list to allow change detection + if module.params.get("mappers") is not None: + for change in module.params["mappers"]: + change = {k: v for k, v in change.items() if v is not None} + if change.get("id") is None and change.get("name") is None: + module.fail_json(msg="Either `name` or `id` has to be specified on each mapper.") + if before_idp == dict(): + old_mapper = dict() + elif change.get("id") is not None: + old_mapper = kc.get_identity_provider_mapper(change["id"], alias, realm) + if old_mapper is None: + old_mapper = dict() + else: + found = [x for x in kc.get_identity_provider_mappers(alias, realm) if x["name"] == change["name"]] + if len(found) == 1: + old_mapper = found[0] + else: + old_mapper = dict() + new_mapper = old_mapper.copy() + new_mapper.update(change) + + if changeset.get("mappers") is None: + changeset["mappers"] = list() + # eventually this holds all desired mappers, unchanged, modified and newly added + changeset["mappers"].append(new_mapper) + + # ensure idempotency in case module.params.mappers is not sorted by name + changeset["mappers"] = sorted( + changeset["mappers"], key=lambda x: x.get("id") if x.get("name") is None else x["name"] + ) + + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) + desired_idp = before_idp.copy() + desired_idp.update(changeset) + + result["proposed"] = sanitize(changeset) + result["existing"] = sanitize(before_idp) + + # Cater for when it doesn't exist (an empty dict) + if not before_idp: + if state == "absent": + # Do nothing and exit + if module._diff: + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = "Identity provider does not exist; doing nothing." + module.exit_json(**result) + + # Process a creation + result["changed"] = True + + if module._diff: + result["diff"] = dict(before="", after=sanitize(desired_idp)) + + if module.check_mode: + module.exit_json(**result) + + # create it + desired_idp = desired_idp.copy() + mappers = desired_idp.pop("mappers", []) + kc.create_identity_provider(desired_idp, realm) + for mapper in mappers: + if mapper.get("identityProviderAlias") is None: + mapper["identityProviderAlias"] = alias + kc.create_identity_provider_mapper(mapper, alias, realm) + after_idp = get_identity_provider_with_mappers(kc, alias, realm) + + result["end_state"] = sanitize(after_idp) + + result["msg"] = f"Identity provider {alias} has been created" + module.exit_json(**result) + + else: + if state == "present": + # Process an update + + # no changes + if desired_idp == before_idp: + result["changed"] = False + result["end_state"] = sanitize(desired_idp) + result["msg"] = f"No changes required to identity provider {alias}." + module.exit_json(**result) + + # doing an update + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=sanitize(before_idp), after=sanitize(desired_idp)) + + if module.check_mode: + module.exit_json(**result) + + # do the update + desired_idp = desired_idp.copy() + updated_mappers = desired_idp.pop("mappers", []) + original_mappers = list(before_idp.get("mappers", [])) + + kc.update_identity_provider(desired_idp, realm) + for mapper in updated_mappers: + if mapper.get("id") is not None: + # only update existing if there is a change + for i, orig in enumerate(original_mappers): + if mapper["id"] == orig["id"]: + del original_mappers[i] + if mapper != orig: + kc.update_identity_provider_mapper(mapper, alias, realm) + else: + if mapper.get("identityProviderAlias") is None: + mapper["identityProviderAlias"] = alias + kc.create_identity_provider_mapper(mapper, alias, realm) + for mapper in [ + x for x in before_idp["mappers"] if [y for y in updated_mappers if y["name"] == x["name"]] == [] + ]: + kc.delete_identity_provider_mapper(mapper["id"], alias, realm) + + after_idp = get_identity_provider_with_mappers(kc, alias, realm) + + result["end_state"] = sanitize(after_idp) + + result["msg"] = f"Identity provider {alias} has been updated" + module.exit_json(**result) + + elif state == "absent": + # Process a deletion + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=sanitize(before_idp), after="") + + if module.check_mode: + module.exit_json(**result) + + # delete it + kc.delete_identity_provider(alias, realm) + + result["end_state"] = {} + + result["msg"] = f"Identity provider {alias} has been deleted" + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_realm.py b/plugins/modules/keycloak_realm.py index a22795b..2799aa4 100644 --- a/plugins/modules/keycloak_realm.py +++ b/plugins/modules/keycloak_realm.py @@ -1,588 +1,804 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- # Copyright (c) 2017, Eike Frost # Copyright (c) 2021, Christophe Gilles # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later -from __future__ import absolute_import, division, print_function -__metaclass__ = type +from __future__ import annotations -DOCUMENTATION = ''' ---- +DOCUMENTATION = r""" module: keycloak_realm -short_description: Allows administration of Keycloak realm via Keycloak API +short_description: Allows administration of Keycloak realm using Keycloak API -version_added: 3.0.0 +version_added: "3.0.0" description: - - This module allows the administration of Keycloak realm via the Keycloak REST API. It - requires access to the REST API via OpenID Connect; the user connecting and the realm being - used must have the requisite access rights. In a default Keycloak installation, admin-cli - and an admin user would work, as would a separate realm definition with the scope tailored - to your needs and a user having the expected roles. - - - The names of module options are snake_cased versions of the camelCase ones found in the - Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html). - Aliases are provided so camelCased versions can be used as well. - - - The Keycloak API does not always sanity check inputs e.g. you can set - SAML-specific settings on an OpenID Connect client for instance and vice versa. Be careful. - If you do not specify a setting, usually a sensible default is chosen. - + - This module allows the administration of Keycloak realm using the Keycloak REST API. It requires access to the REST API + using OpenID Connect; the user connecting and the realm being used must have the requisite access rights. In a default + Keycloak installation, admin-cli and an admin user would work, as would a separate realm definition with the scope tailored + to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). Aliases are provided so camelCased versions can be used + as well. + - The Keycloak API does not always sanity check inputs, for example you can set SAML-specific settings on an OpenID Connect + client for instance and also the other way around. B(Be careful). If you do not specify a setting, usually a sensible + default is chosen. attributes: - check_mode: - support: full - diff_mode: - support: full + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" options: - state: - description: - - State of the realm. - - On V(present), the realm will be created (or updated if it exists already). - - On V(absent), the realm will be removed if it exists. - choices: ['present', 'absent'] - default: 'present' - type: str + state: + description: + - State of the realm. + - On V(present), the realm is created (or updated if it exists already). + - On V(absent), the realm is removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str - id: - description: - - The realm to create. - type: str - realm: - description: - - The realm name. - type: str - access_code_lifespan: - description: - - The realm access code lifespan. - aliases: - - accessCodeLifespan - type: int - access_code_lifespan_login: - description: - - The realm access code lifespan login. - aliases: - - accessCodeLifespanLogin - type: int - access_code_lifespan_user_action: - description: - - The realm access code lifespan user action. - aliases: - - accessCodeLifespanUserAction - type: int - access_token_lifespan: - description: - - The realm access token lifespan. - aliases: - - accessTokenLifespan - type: int - access_token_lifespan_for_implicit_flow: - description: - - The realm access token lifespan for implicit flow. - aliases: - - accessTokenLifespanForImplicitFlow - type: int - account_theme: - description: - - The realm account theme. - aliases: - - accountTheme - type: str - action_token_generated_by_admin_lifespan: - description: - - The realm action token generated by admin lifespan. - aliases: - - actionTokenGeneratedByAdminLifespan - type: int - action_token_generated_by_user_lifespan: - description: - - The realm action token generated by user lifespan. - aliases: - - actionTokenGeneratedByUserLifespan - type: int - admin_events_details_enabled: - description: - - The realm admin events details enabled. - aliases: - - adminEventsDetailsEnabled - type: bool - admin_events_enabled: - description: - - The realm admin events enabled. - aliases: - - adminEventsEnabled - type: bool - admin_theme: - description: - - The realm admin theme. - aliases: - - adminTheme - type: str - attributes: - description: - - The realm attributes. - type: dict - browser_flow: - description: - - The realm browser flow. - aliases: - - browserFlow - type: str - browser_security_headers: - description: - - The realm browser security headers. - aliases: - - browserSecurityHeaders - type: dict - brute_force_protected: - description: - - The realm brute force protected. - aliases: - - bruteForceProtected - type: bool - client_authentication_flow: - description: - - The realm client authentication flow. - aliases: - - clientAuthenticationFlow - type: str - client_scope_mappings: - description: - - The realm client scope mappings. - aliases: - - clientScopeMappings - type: dict - default_default_client_scopes: - description: - - The realm default default client scopes. - aliases: - - defaultDefaultClientScopes - type: list - elements: str - default_groups: - description: - - The realm default groups. - aliases: - - defaultGroups - type: list - elements: str - default_locale: - description: - - The realm default locale. - aliases: - - defaultLocale - type: str - default_optional_client_scopes: - description: - - The realm default optional client scopes. - aliases: - - defaultOptionalClientScopes - type: list - elements: str - default_roles: - description: - - The realm default roles. - aliases: - - defaultRoles - type: list - elements: str - default_signature_algorithm: - description: - - The realm default signature algorithm. - aliases: - - defaultSignatureAlgorithm - type: str - direct_grant_flow: - description: - - The realm direct grant flow. - aliases: - - directGrantFlow - type: str - display_name: - description: - - The realm display name. - aliases: - - displayName - type: str - display_name_html: - description: - - The realm display name HTML. - aliases: - - displayNameHtml - type: str - docker_authentication_flow: - description: - - The realm docker authentication flow. - aliases: - - dockerAuthenticationFlow - type: str - duplicate_emails_allowed: - description: - - The realm duplicate emails allowed option. - aliases: - - duplicateEmailsAllowed - type: bool - edit_username_allowed: - description: - - The realm edit username allowed option. - aliases: - - editUsernameAllowed - type: bool - email_theme: - description: - - The realm email theme. - aliases: - - emailTheme - type: str - enabled: - description: - - The realm enabled option. - type: bool - enabled_event_types: - description: - - The realm enabled event types. - aliases: - - enabledEventTypes - type: list - elements: str - events_enabled: - description: - - Enables or disables login events for this realm. - aliases: - - eventsEnabled - type: bool - version_added: 3.6.0 - events_expiration: - description: - - The realm events expiration. - aliases: - - eventsExpiration - type: int - events_listeners: - description: - - The realm events listeners. - aliases: - - eventsListeners - type: list - elements: str - failure_factor: - description: - - The realm failure factor. - aliases: - - failureFactor - type: int - internationalization_enabled: - description: - - The realm internationalization enabled option. - aliases: - - internationalizationEnabled - type: bool - login_theme: - description: - - The realm login theme. - aliases: - - loginTheme - type: str - login_with_email_allowed: - description: - - The realm login with email allowed option. - aliases: - - loginWithEmailAllowed - type: bool - max_delta_time_seconds: - description: - - The realm max delta time in seconds. - aliases: - - maxDeltaTimeSeconds - type: int - max_failure_wait_seconds: - description: - - The realm max failure wait in seconds. - aliases: - - maxFailureWaitSeconds - type: int - minimum_quick_login_wait_seconds: - description: - - The realm minimum quick login wait in seconds. - aliases: - - minimumQuickLoginWaitSeconds - type: int - not_before: - description: - - The realm not before. - aliases: - - notBefore - type: int - offline_session_idle_timeout: - description: - - The realm offline session idle timeout. - aliases: - - offlineSessionIdleTimeout - type: int - offline_session_max_lifespan: - description: - - The realm offline session max lifespan. - aliases: - - offlineSessionMaxLifespan - type: int - offline_session_max_lifespan_enabled: - description: - - The realm offline session max lifespan enabled option. - aliases: - - offlineSessionMaxLifespanEnabled - type: bool - otp_policy_algorithm: - description: - - The realm otp policy algorithm. - aliases: - - otpPolicyAlgorithm - type: str - otp_policy_digits: - description: - - The realm otp policy digits. - aliases: - - otpPolicyDigits - type: int - otp_policy_initial_counter: - description: - - The realm otp policy initial counter. - aliases: - - otpPolicyInitialCounter - type: int - otp_policy_look_ahead_window: - description: - - The realm otp policy look ahead window. - aliases: - - otpPolicyLookAheadWindow - type: int - otp_policy_period: - description: - - The realm otp policy period. - aliases: - - otpPolicyPeriod - type: int - otp_policy_type: - description: - - The realm otp policy type. - aliases: - - otpPolicyType - type: str - otp_supported_applications: - description: - - The realm otp supported applications. - aliases: - - otpSupportedApplications - type: list - elements: str - password_policy: - description: - - The realm password policy. - aliases: - - passwordPolicy - type: str - permanent_lockout: - description: - - The realm permanent lockout. - aliases: - - permanentLockout - type: bool - quick_login_check_milli_seconds: - description: - - The realm quick login check in milliseconds. - aliases: - - quickLoginCheckMilliSeconds - type: int - refresh_token_max_reuse: - description: - - The realm refresh token max reuse. - aliases: - - refreshTokenMaxReuse - type: int - registration_allowed: - description: - - The realm registration allowed option. - aliases: - - registrationAllowed - type: bool - registration_email_as_username: - description: - - The realm registration email as username option. - aliases: - - registrationEmailAsUsername - type: bool - registration_flow: - description: - - The realm registration flow. - aliases: - - registrationFlow - type: str - remember_me: - description: - - The realm remember me option. - aliases: - - rememberMe - type: bool - reset_credentials_flow: - description: - - The realm reset credentials flow. - aliases: - - resetCredentialsFlow - type: str - reset_password_allowed: - description: - - The realm reset password allowed option. - aliases: - - resetPasswordAllowed - type: bool - revoke_refresh_token: - description: - - The realm revoke refresh token option. - aliases: - - revokeRefreshToken - type: bool - smtp_server: - description: - - The realm smtp server. - aliases: - - smtpServer - type: dict - ssl_required: - description: - - The realm ssl required option. - choices: ['all', 'external', 'none'] - aliases: - - sslRequired - type: str - sso_session_idle_timeout: - description: - - The realm sso session idle timeout. - aliases: - - ssoSessionIdleTimeout - type: int - sso_session_idle_timeout_remember_me: - description: - - The realm sso session idle timeout remember me. - aliases: - - ssoSessionIdleTimeoutRememberMe - type: int - sso_session_max_lifespan: - description: - - The realm sso session max lifespan. - aliases: - - ssoSessionMaxLifespan - type: int - sso_session_max_lifespan_remember_me: - description: - - The realm sso session max lifespan remember me. - aliases: - - ssoSessionMaxLifespanRememberMe - type: int - supported_locales: - description: - - The realm supported locales. - aliases: - - supportedLocales - type: list - elements: str - user_managed_access_allowed: - description: - - The realm user managed access allowed option. - aliases: - - userManagedAccessAllowed - type: bool - verify_email: - description: - - The realm verify email option. - aliases: - - verifyEmail - type: bool - wait_increment_seconds: - description: - - The realm wait increment in seconds. - aliases: - - waitIncrementSeconds - type: int + id: + description: + - The realm to create. + type: str + realm: + description: + - The realm name. + type: str + access_code_lifespan: + description: + - The realm access code lifespan. + aliases: + - accessCodeLifespan + type: int + access_code_lifespan_login: + description: + - The realm access code lifespan login. + aliases: + - accessCodeLifespanLogin + type: int + access_code_lifespan_user_action: + description: + - The realm access code lifespan user action. + aliases: + - accessCodeLifespanUserAction + type: int + access_token_lifespan: + description: + - The realm access token lifespan. + aliases: + - accessTokenLifespan + type: int + access_token_lifespan_for_implicit_flow: + description: + - The realm access token lifespan for implicit flow. + aliases: + - accessTokenLifespanForImplicitFlow + type: int + account_theme: + description: + - The realm account theme. + aliases: + - accountTheme + type: str + action_token_generated_by_admin_lifespan: + description: + - The realm action token generated by admin lifespan. + aliases: + - actionTokenGeneratedByAdminLifespan + type: int + action_token_generated_by_user_lifespan: + description: + - The realm action token generated by user lifespan. + aliases: + - actionTokenGeneratedByUserLifespan + type: int + admin_events_details_enabled: + description: + - The realm admin events details enabled. + aliases: + - adminEventsDetailsEnabled + type: bool + admin_events_enabled: + description: + - The realm admin events enabled. + aliases: + - adminEventsEnabled + type: bool + admin_permissions_enabled: + description: + - The realm admin permissions enabled. + aliases: + - adminPermissionsEnabled + type: bool + admin_theme: + description: + - The realm admin theme. + aliases: + - adminTheme + type: str + attributes: + description: + - The realm attributes. + type: dict + browser_flow: + description: + - The realm browser flow. + aliases: + - browserFlow + type: str + browser_security_headers: + description: + - The realm browser security headers. + aliases: + - browserSecurityHeaders + type: dict + brute_force_protected: + description: + - The realm brute force protected. + aliases: + - bruteForceProtected + type: bool + brute_force_strategy: + description: + - The realm brute force strategy. + aliases: + - bruteForceStrategy + choices: ['LINEAR', 'MULTIPLE'] + type: str + client_authentication_flow: + description: + - The realm client authentication flow. + aliases: + - clientAuthenticationFlow + type: str + client_scope_mappings: + description: + - The realm client scope mappings. + aliases: + - clientScopeMappings + type: dict + default_default_client_scopes: + description: + - The realm default default client scopes. + aliases: + - defaultDefaultClientScopes + type: list + elements: str + default_groups: + description: + - The realm default groups. + aliases: + - defaultGroups + type: list + elements: str + default_locale: + description: + - The realm default locale. + aliases: + - defaultLocale + type: str + default_optional_client_scopes: + description: + - The realm default optional client scopes. + aliases: + - defaultOptionalClientScopes + type: list + elements: str + default_roles: + description: + - The realm default roles. + aliases: + - defaultRoles + type: list + elements: str + default_signature_algorithm: + description: + - The realm default signature algorithm. + aliases: + - defaultSignatureAlgorithm + type: str + direct_grant_flow: + description: + - The realm direct grant flow. + aliases: + - directGrantFlow + type: str + display_name: + description: + - The realm display name. + aliases: + - displayName + type: str + display_name_html: + description: + - The realm display name HTML. + aliases: + - displayNameHtml + type: str + docker_authentication_flow: + description: + - The realm docker authentication flow. + aliases: + - dockerAuthenticationFlow + type: str + duplicate_emails_allowed: + description: + - The realm duplicate emails allowed option. + aliases: + - duplicateEmailsAllowed + type: bool + edit_username_allowed: + description: + - The realm edit username allowed option. + aliases: + - editUsernameAllowed + type: bool + email_theme: + description: + - The realm email theme. + aliases: + - emailTheme + type: str + enabled: + description: + - The realm enabled option. + type: bool + enabled_event_types: + description: + - The realm enabled event types. + aliases: + - enabledEventTypes + type: list + elements: str + events_enabled: + description: + - Enables or disables login events for this realm. + aliases: + - eventsEnabled + type: bool + events_expiration: + description: + - The realm events expiration. + aliases: + - eventsExpiration + type: int + events_listeners: + description: + - The realm events listeners. + aliases: + - eventsListeners + type: list + elements: str + failure_factor: + description: + - The realm failure factor. + aliases: + - failureFactor + type: int + first_broker_login_flow: + description: + - The realm first broker login flow. + aliases: + - firstBrokerLoginFlow + type: str + internationalization_enabled: + description: + - The realm internationalization enabled option. + aliases: + - internationalizationEnabled + type: bool + localization_texts: + description: + - The custom localization texts for a realm. + aliases: + - localizationTexts + type: dict + login_theme: + description: + - The realm login theme. + aliases: + - loginTheme + type: str + login_with_email_allowed: + description: + - The realm login with email allowed option. + aliases: + - loginWithEmailAllowed + type: bool + max_delta_time_seconds: + description: + - The realm max delta time in seconds. + aliases: + - maxDeltaTimeSeconds + type: int + max_failure_wait_seconds: + description: + - The realm max failure wait in seconds. + aliases: + - maxFailureWaitSeconds + type: int + max_temporary_lockouts: + description: + - The realm max temporary lockouts. + aliases: + - maxTemporaryLockouts + type: int + minimum_quick_login_wait_seconds: + description: + - The realm minimum quick login wait in seconds. + aliases: + - minimumQuickLoginWaitSeconds + type: int + not_before: + description: + - The realm not before. + aliases: + - notBefore + type: int + offline_session_idle_timeout: + description: + - The realm offline session idle timeout. + aliases: + - offlineSessionIdleTimeout + type: int + offline_session_max_lifespan: + description: + - The realm offline session max lifespan. + aliases: + - offlineSessionMaxLifespan + type: int + offline_session_max_lifespan_enabled: + description: + - The realm offline session max lifespan enabled option. + aliases: + - offlineSessionMaxLifespanEnabled + type: bool + otp_policy_algorithm: + description: + - The realm otp policy algorithm. + aliases: + - otpPolicyAlgorithm + type: str + otp_policy_digits: + description: + - The realm otp policy digits. + aliases: + - otpPolicyDigits + type: int + otp_policy_initial_counter: + description: + - The realm otp policy initial counter. + aliases: + - otpPolicyInitialCounter + type: int + otp_policy_look_ahead_window: + description: + - The realm otp policy look ahead window. + aliases: + - otpPolicyLookAheadWindow + type: int + otp_policy_period: + description: + - The realm otp policy period. + aliases: + - otpPolicyPeriod + type: int + otp_policy_type: + description: + - The realm otp policy type. + aliases: + - otpPolicyType + type: str + otp_supported_applications: + description: + - The realm otp supported applications. + aliases: + - otpSupportedApplications + type: list + elements: str + password_policy: + description: + - The realm password policy. + aliases: + - passwordPolicy + type: str + organizations_enabled: + description: + - Enables support for experimental organization feature. + aliases: + - organizationsEnabled + type: bool + permanent_lockout: + description: + - The realm permanent lockout. + aliases: + - permanentLockout + type: bool + quick_login_check_milli_seconds: + description: + - The realm quick login check in milliseconds. + aliases: + - quickLoginCheckMilliSeconds + type: int + refresh_token_max_reuse: + description: + - The realm refresh token max reuse. + aliases: + - refreshTokenMaxReuse + type: int + registration_allowed: + description: + - The realm registration allowed option. + aliases: + - registrationAllowed + type: bool + registration_email_as_username: + description: + - The realm registration email as username option. + aliases: + - registrationEmailAsUsername + type: bool + registration_flow: + description: + - The realm registration flow. + aliases: + - registrationFlow + type: str + remember_me: + description: + - The realm remember me option. + aliases: + - rememberMe + type: bool + reset_credentials_flow: + description: + - The realm reset credentials flow. + aliases: + - resetCredentialsFlow + type: str + reset_password_allowed: + description: + - The realm reset password allowed option. + aliases: + - resetPasswordAllowed + type: bool + revoke_refresh_token: + description: + - The realm revoke refresh token option. + aliases: + - revokeRefreshToken + type: bool + smtp_server: + description: + - The realm smtp server. + aliases: + - smtpServer + type: dict + ssl_required: + description: + - The realm ssl required option. + choices: ['all', 'external', 'none'] + aliases: + - sslRequired + type: str + sso_session_idle_timeout: + description: + - The realm sso session idle timeout. + aliases: + - ssoSessionIdleTimeout + type: int + sso_session_idle_timeout_remember_me: + description: + - The realm sso session idle timeout remember me. + aliases: + - ssoSessionIdleTimeoutRememberMe + type: int + sso_session_max_lifespan: + description: + - The realm sso session max lifespan. + aliases: + - ssoSessionMaxLifespan + type: int + sso_session_max_lifespan_remember_me: + description: + - The realm sso session max lifespan remember me. + aliases: + - ssoSessionMaxLifespanRememberMe + type: int + supported_locales: + description: + - The realm supported locales. + aliases: + - supportedLocales + type: list + elements: str + user_managed_access_allowed: + description: + - The realm user managed access allowed option. + aliases: + - userManagedAccessAllowed + type: bool + verify_email: + description: + - The realm verify email option. + aliases: + - verifyEmail + type: bool + wait_increment_seconds: + description: + - The realm wait increment in seconds. + aliases: + - waitIncrementSeconds + type: int + client_session_idle_timeout: + description: + - All Clients inherit from this setting, time a session is allowed to be idle before it expires. + aliases: + - clientSessionIdleTimeout + type: int + client_session_max_lifespan: + description: + - All Clients inherit from this setting, max time before a session is expired. + aliases: + - clientSessionMaxLifespan + type: int + client_offline_session_idle_timeout: + description: + - All Clients inherit from this setting, time an offline session is allowed to be idle before it expires. + aliases: + - clientOfflineSessionIdleTimeout + type: int + client_offline_session_max_lifespan: + description: + - All Clients inherit from this setting, max time before an offline session is expired regardless of activity. + aliases: + - clientOfflineSessionMaxLifespan + type: int + oauth2_device_code_lifespan: + description: + - Max time before the device code and user code are expired. + aliases: + - oauth2DeviceCodeLifespan + type: int + oauth2_device_polling_interval: + description: + - The minimum amount of time in seconds that the client should wait between polling requests to the token endpoint. + aliases: + - oauth2DevicePollingInterval + type: int + web_authn_policy_rp_entity_name: + description: + - WebAuthn Relying Party Entity Name. + aliases: + - webAuthnPolicyRpEntityName + type: str + web_authn_policy_signature_algorithms: + description: + - List of acceptable WebAuthn signature algorithms. + aliases: + - webAuthnPolicySignatureAlgorithms + type: list + elements: str + web_authn_policy_rp_id: + description: + - WebAuthn Relying Party ID (domain). Empty string means use request host. + aliases: + - webAuthnPolicyRpId + type: str + web_authn_policy_attestation_conveyance_preference: + description: + - Attestation conveyance preference for WebAuthn. + aliases: + - webAuthnPolicyAttestationConveyancePreference + type: str + web_authn_policy_authenticator_attachment: + description: + - Authenticator attachment preference for WebAuthn authenticators. + aliases: + - webAuthnPolicyAuthenticatorAttachment + type: str + web_authn_policy_require_resident_key: + description: + - Whether resident keys are required for WebAuthn (Yes/No/not specified). + aliases: + - webAuthnPolicyRequireResidentKey + type: str + web_authn_policy_user_verification_requirement: + description: + - User verification requirement for WebAuthn. + aliases: + - webAuthnPolicyUserVerificationRequirement + type: str + web_authn_policy_create_timeout: + description: + - Timeout for WebAuthn credential creation (ms). + aliases: + - webAuthnPolicyCreateTimeout + type: int + web_authn_policy_avoid_same_authenticator_register: + description: + - Avoid registering the same authenticator multiple times. + aliases: + - webAuthnPolicyAvoidSameAuthenticatorRegister + type: bool + web_authn_policy_acceptable_aaguids: + description: + - List of acceptable AAGUIDs for WebAuthn authenticators. + aliases: + - webAuthnPolicyAcceptableAaguids + type: list + elements: str + web_authn_policy_extra_origins: + description: + - Additional acceptable origins for WebAuthn requests. + aliases: + - webAuthnPolicyExtraOrigins + type: list + elements: str + web_authn_policy_passwordless_rp_entity_name: + description: + - WebAuthn Passwordless Relying Party Entity Name. + aliases: + - webAuthnPolicyPasswordlessRpEntityName + type: str + web_authn_policy_passwordless_signature_algorithms: + description: + - List of acceptable WebAuthn signature algorithms for passwordless. + aliases: + - webAuthnPolicyPasswordlessSignatureAlgorithms + type: list + elements: str + web_authn_policy_passwordless_rp_id: + description: + - WebAuthn Passwordless Relying Party ID (domain). + aliases: + - webAuthnPolicyPasswordlessRpId + type: str + web_authn_policy_passwordless_attestation_conveyance_preference: + description: + - Attestation conveyance preference for WebAuthn passwordless. + aliases: + - webAuthnPolicyPasswordlessAttestationConveyancePreference + type: str + web_authn_policy_passwordless_authenticator_attachment: + description: + - Authenticator attachment for WebAuthn passwordless. + aliases: + - webAuthnPolicyPasswordlessAuthenticatorAttachment + type: str + web_authn_policy_passwordless_require_resident_key: + description: + - Whether resident keys are required for WebAuthn passwordless (V(Yes)/V(No)/V(not specified)). + aliases: + - webAuthnPolicyPasswordlessRequireResidentKey + type: str + web_authn_policy_passwordless_user_verification_requirement: + description: + - User verification requirement for WebAuthn passwordless. + aliases: + - webAuthnPolicyPasswordlessUserVerificationRequirement + type: str + web_authn_policy_passwordless_create_timeout: + description: + - Timeout for WebAuthn passwordless credential creation (ms). + aliases: + - webAuthnPolicyPasswordlessCreateTimeout + type: int + web_authn_policy_passwordless_avoid_same_authenticator_register: + description: + - Avoid registering the same authenticator multiple times for passwordless. + aliases: + - webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister + type: bool + web_authn_policy_passwordless_acceptable_aaguids: + description: + - List of acceptable AAGUIDs for WebAuthn passwordless authenticators. + aliases: + - webAuthnPolicyPasswordlessAcceptableAaguids + type: list + elements: str + web_authn_policy_passwordless_extra_origins: + description: + - Additional acceptable origins for WebAuthn passwordless requests. + aliases: + - webAuthnPolicyPasswordlessExtraOrigins + type: list + elements: str + web_authn_policy_passwordless_passkeys_enabled: + description: + - Enable passkeys (conditional UI) authentication in the username forms. + aliases: + - webAuthnPolicyPasswordlessPasskeysEnabled + type: bool extends_documentation_fragment: - - middleware_automation.keycloak.keycloak - - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes author: - - Christophe Gilles (@kris2kris) -''' + - Christophe Gilles (@kris2kris) +""" -EXAMPLES = ''' +EXAMPLES = r""" - name: Create or update Keycloak realm (minimal example) middleware_automation.keycloak.keycloak_realm: auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD - id: realm - realm: realm + realm: unique_realm_name state: present - name: Delete a Keycloak realm middleware_automation.keycloak.keycloak_realm: auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD - id: test + realm: unique_realm_name state: absent -''' +""" -RETURN = ''' +RETURN = r""" msg: - description: Message as to what action was taken. - returned: always - type: str - sample: "Realm testrealm has been updated" + description: Message as to what action was taken. + returned: always + type: str + sample: "Realm testrealm has been updated" proposed: - description: Representation of proposed realm. - returned: always - type: dict - sample: { - id: "test" - } + description: Representation of proposed realm. + returned: always + type: dict + sample: {"realm": "test"} existing: - description: Representation of existing realm (sample is truncated). - returned: always - type: dict - sample: { - "adminUrl": "http://www.example.com/admin_url", - "attributes": { - "request.object.signature.alg": "RS256", - } + description: Representation of existing realm (sample is truncated). + returned: always + type: dict + sample: + { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256" + } } end_state: - description: Representation of realm after module execution (sample is truncated). - returned: on success - type: dict - sample: { - "adminUrl": "http://www.example.com/admin_url", - "attributes": { - "request.object.signature.alg": "RS256", - } + description: Representation of realm after module execution (sample is truncated). + returned: on success + type: dict + sample: + { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256" + } } -''' +""" -from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, get_token, KeycloakError from ansible.module_utils.basic import AnsibleModule +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + keycloak_argument_spec, +) + def normalise_cr(realmrep): - """ Re-sorts any properties where the order is important so that diff's is minimised and the change detection is more effective. + """Re-sorts any properties where the order is important so that diff's is minimised and the change detection is more effective. :param realmrep: the realmrep dict to be sanitized :return: normalised realmrep dict @@ -590,31 +806,34 @@ def normalise_cr(realmrep): # Avoid the dict passed in to be modified realmrep = realmrep.copy() - if 'enabledEventTypes' in realmrep: - realmrep['enabledEventTypes'] = list(sorted(realmrep['enabledEventTypes'])) + if "enabledEventTypes" in realmrep: + realmrep["enabledEventTypes"] = list(sorted(realmrep["enabledEventTypes"])) - if 'otpSupportedApplications' in realmrep: - realmrep['otpSupportedApplications'] = list(sorted(realmrep['otpSupportedApplications'])) + if "otpSupportedApplications" in realmrep: + realmrep["otpSupportedApplications"] = list(sorted(realmrep["otpSupportedApplications"])) - if 'supportedLocales' in realmrep: - realmrep['supportedLocales'] = list(sorted(realmrep['supportedLocales'])) + if "supportedLocales" in realmrep: + realmrep["supportedLocales"] = list(sorted(realmrep["supportedLocales"])) return realmrep def sanitize_cr(realmrep): - """ Removes probably sensitive details from a realm representation. + """Removes probably sensitive details from a realm representation. :param realmrep: the realmrep dict to be sanitized :return: sanitized realmrep dict """ + if not realmrep: + return realmrep + result = realmrep.copy() - if 'secret' in result: - result['secret'] = '********' - if 'attributes' in result: - if 'saml.signing.private.key' in result['attributes']: - result['attributes'] = result['attributes'].copy() - result['attributes']['saml.signing.private.key'] = '********' + if "secret" in result: + result["secret"] = "********" + if "attributes" in result: + if "saml.signing.private.key" in result["attributes"]: + result["attributes"] = result["attributes"].copy() + result["attributes"]["saml.signing.private.key"] = "********" return normalise_cr(result) @@ -627,95 +846,176 @@ def main(): argument_spec = keycloak_argument_spec() meta_args = dict( - state=dict(default='present', choices=['present', 'absent']), - - id=dict(type='str'), - realm=dict(type='str'), - access_code_lifespan=dict(type='int', aliases=['accessCodeLifespan']), - access_code_lifespan_login=dict(type='int', aliases=['accessCodeLifespanLogin']), - access_code_lifespan_user_action=dict(type='int', aliases=['accessCodeLifespanUserAction']), - access_token_lifespan=dict(type='int', aliases=['accessTokenLifespan'], no_log=False), - access_token_lifespan_for_implicit_flow=dict(type='int', aliases=['accessTokenLifespanForImplicitFlow'], no_log=False), - account_theme=dict(type='str', aliases=['accountTheme']), - action_token_generated_by_admin_lifespan=dict(type='int', aliases=['actionTokenGeneratedByAdminLifespan'], no_log=False), - action_token_generated_by_user_lifespan=dict(type='int', aliases=['actionTokenGeneratedByUserLifespan'], no_log=False), - admin_events_details_enabled=dict(type='bool', aliases=['adminEventsDetailsEnabled']), - admin_events_enabled=dict(type='bool', aliases=['adminEventsEnabled']), - admin_theme=dict(type='str', aliases=['adminTheme']), - attributes=dict(type='dict'), - browser_flow=dict(type='str', aliases=['browserFlow']), - browser_security_headers=dict(type='dict', aliases=['browserSecurityHeaders']), - brute_force_protected=dict(type='bool', aliases=['bruteForceProtected']), - client_authentication_flow=dict(type='str', aliases=['clientAuthenticationFlow']), - client_scope_mappings=dict(type='dict', aliases=['clientScopeMappings']), - default_default_client_scopes=dict(type='list', elements='str', aliases=['defaultDefaultClientScopes']), - default_groups=dict(type='list', elements='str', aliases=['defaultGroups']), - default_locale=dict(type='str', aliases=['defaultLocale']), - default_optional_client_scopes=dict(type='list', elements='str', aliases=['defaultOptionalClientScopes']), - default_roles=dict(type='list', elements='str', aliases=['defaultRoles']), - default_signature_algorithm=dict(type='str', aliases=['defaultSignatureAlgorithm']), - direct_grant_flow=dict(type='str', aliases=['directGrantFlow']), - display_name=dict(type='str', aliases=['displayName']), - display_name_html=dict(type='str', aliases=['displayNameHtml']), - docker_authentication_flow=dict(type='str', aliases=['dockerAuthenticationFlow']), - duplicate_emails_allowed=dict(type='bool', aliases=['duplicateEmailsAllowed']), - edit_username_allowed=dict(type='bool', aliases=['editUsernameAllowed']), - email_theme=dict(type='str', aliases=['emailTheme']), - enabled=dict(type='bool'), - enabled_event_types=dict(type='list', elements='str', aliases=['enabledEventTypes']), - events_enabled=dict(type='bool', aliases=['eventsEnabled']), - events_expiration=dict(type='int', aliases=['eventsExpiration']), - events_listeners=dict(type='list', elements='str', aliases=['eventsListeners']), - failure_factor=dict(type='int', aliases=['failureFactor']), - internationalization_enabled=dict(type='bool', aliases=['internationalizationEnabled']), - login_theme=dict(type='str', aliases=['loginTheme']), - login_with_email_allowed=dict(type='bool', aliases=['loginWithEmailAllowed']), - max_delta_time_seconds=dict(type='int', aliases=['maxDeltaTimeSeconds']), - max_failure_wait_seconds=dict(type='int', aliases=['maxFailureWaitSeconds']), - minimum_quick_login_wait_seconds=dict(type='int', aliases=['minimumQuickLoginWaitSeconds']), - not_before=dict(type='int', aliases=['notBefore']), - offline_session_idle_timeout=dict(type='int', aliases=['offlineSessionIdleTimeout']), - offline_session_max_lifespan=dict(type='int', aliases=['offlineSessionMaxLifespan']), - offline_session_max_lifespan_enabled=dict(type='bool', aliases=['offlineSessionMaxLifespanEnabled']), - otp_policy_algorithm=dict(type='str', aliases=['otpPolicyAlgorithm']), - otp_policy_digits=dict(type='int', aliases=['otpPolicyDigits']), - otp_policy_initial_counter=dict(type='int', aliases=['otpPolicyInitialCounter']), - otp_policy_look_ahead_window=dict(type='int', aliases=['otpPolicyLookAheadWindow']), - otp_policy_period=dict(type='int', aliases=['otpPolicyPeriod']), - otp_policy_type=dict(type='str', aliases=['otpPolicyType']), - otp_supported_applications=dict(type='list', elements='str', aliases=['otpSupportedApplications']), - password_policy=dict(type='str', aliases=['passwordPolicy'], no_log=False), - permanent_lockout=dict(type='bool', aliases=['permanentLockout']), - quick_login_check_milli_seconds=dict(type='int', aliases=['quickLoginCheckMilliSeconds']), - refresh_token_max_reuse=dict(type='int', aliases=['refreshTokenMaxReuse'], no_log=False), - registration_allowed=dict(type='bool', aliases=['registrationAllowed']), - registration_email_as_username=dict(type='bool', aliases=['registrationEmailAsUsername']), - registration_flow=dict(type='str', aliases=['registrationFlow']), - remember_me=dict(type='bool', aliases=['rememberMe']), - reset_credentials_flow=dict(type='str', aliases=['resetCredentialsFlow']), - reset_password_allowed=dict(type='bool', aliases=['resetPasswordAllowed'], no_log=False), - revoke_refresh_token=dict(type='bool', aliases=['revokeRefreshToken']), - smtp_server=dict(type='dict', aliases=['smtpServer']), - ssl_required=dict(choices=["external", "all", "none"], aliases=['sslRequired']), - sso_session_idle_timeout=dict(type='int', aliases=['ssoSessionIdleTimeout']), - sso_session_idle_timeout_remember_me=dict(type='int', aliases=['ssoSessionIdleTimeoutRememberMe']), - sso_session_max_lifespan=dict(type='int', aliases=['ssoSessionMaxLifespan']), - sso_session_max_lifespan_remember_me=dict(type='int', aliases=['ssoSessionMaxLifespanRememberMe']), - supported_locales=dict(type='list', elements='str', aliases=['supportedLocales']), - user_managed_access_allowed=dict(type='bool', aliases=['userManagedAccessAllowed']), - verify_email=dict(type='bool', aliases=['verifyEmail']), - wait_increment_seconds=dict(type='int', aliases=['waitIncrementSeconds']), + state=dict(default="present", choices=["present", "absent"]), + id=dict(type="str"), + realm=dict(type="str"), + access_code_lifespan=dict(type="int", aliases=["accessCodeLifespan"]), + access_code_lifespan_login=dict(type="int", aliases=["accessCodeLifespanLogin"]), + access_code_lifespan_user_action=dict(type="int", aliases=["accessCodeLifespanUserAction"]), + access_token_lifespan=dict(type="int", aliases=["accessTokenLifespan"], no_log=False), + access_token_lifespan_for_implicit_flow=dict( + type="int", aliases=["accessTokenLifespanForImplicitFlow"], no_log=False + ), + account_theme=dict(type="str", aliases=["accountTheme"]), + action_token_generated_by_admin_lifespan=dict( + type="int", aliases=["actionTokenGeneratedByAdminLifespan"], no_log=False + ), + action_token_generated_by_user_lifespan=dict( + type="int", aliases=["actionTokenGeneratedByUserLifespan"], no_log=False + ), + admin_events_details_enabled=dict(type="bool", aliases=["adminEventsDetailsEnabled"]), + admin_events_enabled=dict(type="bool", aliases=["adminEventsEnabled"]), + admin_permissions_enabled=dict(type="bool", aliases=["adminPermissionsEnabled"]), + admin_theme=dict(type="str", aliases=["adminTheme"]), + attributes=dict(type="dict"), + browser_flow=dict(type="str", aliases=["browserFlow"]), + browser_security_headers=dict(type="dict", aliases=["browserSecurityHeaders"]), + brute_force_protected=dict(type="bool", aliases=["bruteForceProtected"]), + brute_force_strategy=dict(type="str", choices=["LINEAR", "MULTIPLE"], aliases=["bruteForceStrategy"]), + client_authentication_flow=dict(type="str", aliases=["clientAuthenticationFlow"]), + client_scope_mappings=dict(type="dict", aliases=["clientScopeMappings"]), + default_default_client_scopes=dict(type="list", elements="str", aliases=["defaultDefaultClientScopes"]), + default_groups=dict(type="list", elements="str", aliases=["defaultGroups"]), + default_locale=dict(type="str", aliases=["defaultLocale"]), + default_optional_client_scopes=dict(type="list", elements="str", aliases=["defaultOptionalClientScopes"]), + default_roles=dict(type="list", elements="str", aliases=["defaultRoles"]), + default_signature_algorithm=dict(type="str", aliases=["defaultSignatureAlgorithm"]), + direct_grant_flow=dict(type="str", aliases=["directGrantFlow"]), + display_name=dict(type="str", aliases=["displayName"]), + display_name_html=dict(type="str", aliases=["displayNameHtml"]), + docker_authentication_flow=dict(type="str", aliases=["dockerAuthenticationFlow"]), + duplicate_emails_allowed=dict(type="bool", aliases=["duplicateEmailsAllowed"]), + edit_username_allowed=dict(type="bool", aliases=["editUsernameAllowed"]), + email_theme=dict(type="str", aliases=["emailTheme"]), + enabled=dict(type="bool"), + enabled_event_types=dict(type="list", elements="str", aliases=["enabledEventTypes"]), + events_enabled=dict(type="bool", aliases=["eventsEnabled"]), + events_expiration=dict(type="int", aliases=["eventsExpiration"]), + events_listeners=dict(type="list", elements="str", aliases=["eventsListeners"]), + failure_factor=dict(type="int", aliases=["failureFactor"]), + first_broker_login_flow=dict(type="str", aliases=["firstBrokerLoginFlow"]), + internationalization_enabled=dict(type="bool", aliases=["internationalizationEnabled"]), + localization_texts=dict(type="dict", aliases=["localizationTexts"]), + login_theme=dict(type="str", aliases=["loginTheme"]), + login_with_email_allowed=dict(type="bool", aliases=["loginWithEmailAllowed"]), + max_delta_time_seconds=dict(type="int", aliases=["maxDeltaTimeSeconds"]), + max_failure_wait_seconds=dict(type="int", aliases=["maxFailureWaitSeconds"]), + max_temporary_lockouts=dict(type="int", aliases=["maxTemporaryLockouts"]), + minimum_quick_login_wait_seconds=dict(type="int", aliases=["minimumQuickLoginWaitSeconds"]), + not_before=dict(type="int", aliases=["notBefore"]), + offline_session_idle_timeout=dict(type="int", aliases=["offlineSessionIdleTimeout"]), + offline_session_max_lifespan=dict(type="int", aliases=["offlineSessionMaxLifespan"]), + offline_session_max_lifespan_enabled=dict(type="bool", aliases=["offlineSessionMaxLifespanEnabled"]), + otp_policy_algorithm=dict(type="str", aliases=["otpPolicyAlgorithm"]), + otp_policy_digits=dict(type="int", aliases=["otpPolicyDigits"]), + otp_policy_initial_counter=dict(type="int", aliases=["otpPolicyInitialCounter"]), + otp_policy_look_ahead_window=dict(type="int", aliases=["otpPolicyLookAheadWindow"]), + otp_policy_period=dict(type="int", aliases=["otpPolicyPeriod"]), + otp_policy_type=dict(type="str", aliases=["otpPolicyType"]), + otp_supported_applications=dict(type="list", elements="str", aliases=["otpSupportedApplications"]), + password_policy=dict(type="str", aliases=["passwordPolicy"], no_log=False), + organizations_enabled=dict(type="bool", aliases=["organizationsEnabled"]), + permanent_lockout=dict(type="bool", aliases=["permanentLockout"]), + quick_login_check_milli_seconds=dict(type="int", aliases=["quickLoginCheckMilliSeconds"]), + refresh_token_max_reuse=dict(type="int", aliases=["refreshTokenMaxReuse"], no_log=False), + registration_allowed=dict(type="bool", aliases=["registrationAllowed"]), + registration_email_as_username=dict(type="bool", aliases=["registrationEmailAsUsername"]), + registration_flow=dict(type="str", aliases=["registrationFlow"]), + remember_me=dict(type="bool", aliases=["rememberMe"]), + reset_credentials_flow=dict(type="str", aliases=["resetCredentialsFlow"]), + reset_password_allowed=dict(type="bool", aliases=["resetPasswordAllowed"], no_log=False), + revoke_refresh_token=dict(type="bool", aliases=["revokeRefreshToken"]), + smtp_server=dict(type="dict", aliases=["smtpServer"]), + ssl_required=dict(choices=["external", "all", "none"], aliases=["sslRequired"]), + sso_session_idle_timeout=dict(type="int", aliases=["ssoSessionIdleTimeout"]), + sso_session_idle_timeout_remember_me=dict(type="int", aliases=["ssoSessionIdleTimeoutRememberMe"]), + sso_session_max_lifespan=dict(type="int", aliases=["ssoSessionMaxLifespan"]), + sso_session_max_lifespan_remember_me=dict(type="int", aliases=["ssoSessionMaxLifespanRememberMe"]), + supported_locales=dict(type="list", elements="str", aliases=["supportedLocales"]), + user_managed_access_allowed=dict(type="bool", aliases=["userManagedAccessAllowed"]), + verify_email=dict(type="bool", aliases=["verifyEmail"]), + wait_increment_seconds=dict(type="int", aliases=["waitIncrementSeconds"]), + client_session_idle_timeout=dict(type="int", aliases=["clientSessionIdleTimeout"]), + client_session_max_lifespan=dict(type="int", aliases=["clientSessionMaxLifespan"]), + client_offline_session_idle_timeout=dict(type="int", aliases=["clientOfflineSessionIdleTimeout"]), + client_offline_session_max_lifespan=dict(type="int", aliases=["clientOfflineSessionMaxLifespan"]), + oauth2_device_code_lifespan=dict(type="int", aliases=["oauth2DeviceCodeLifespan"]), + oauth2_device_polling_interval=dict(type="int", aliases=["oauth2DevicePollingInterval"]), + web_authn_policy_rp_entity_name=dict(type="str", aliases=["webAuthnPolicyRpEntityName"]), + web_authn_policy_signature_algorithms=dict( + type="list", elements="str", aliases=["webAuthnPolicySignatureAlgorithms"] + ), + web_authn_policy_rp_id=dict(type="str", aliases=["webAuthnPolicyRpId"]), + web_authn_policy_attestation_conveyance_preference=dict( + type="str", aliases=["webAuthnPolicyAttestationConveyancePreference"] + ), + web_authn_policy_authenticator_attachment=dict(type="str", aliases=["webAuthnPolicyAuthenticatorAttachment"]), + web_authn_policy_require_resident_key=dict( + type="str", aliases=["webAuthnPolicyRequireResidentKey"], no_log=False + ), + web_authn_policy_user_verification_requirement=dict( + type="str", aliases=["webAuthnPolicyUserVerificationRequirement"] + ), + web_authn_policy_create_timeout=dict(type="int", aliases=["webAuthnPolicyCreateTimeout"]), + web_authn_policy_avoid_same_authenticator_register=dict( + type="bool", aliases=["webAuthnPolicyAvoidSameAuthenticatorRegister"] + ), + web_authn_policy_acceptable_aaguids=dict( + type="list", elements="str", aliases=["webAuthnPolicyAcceptableAaguids"] + ), + web_authn_policy_extra_origins=dict(type="list", elements="str", aliases=["webAuthnPolicyExtraOrigins"]), + web_authn_policy_passwordless_rp_entity_name=dict( + type="str", aliases=["webAuthnPolicyPasswordlessRpEntityName"] + ), + web_authn_policy_passwordless_signature_algorithms=dict( + type="list", elements="str", aliases=["webAuthnPolicyPasswordlessSignatureAlgorithms"], no_log=False + ), + web_authn_policy_passwordless_rp_id=dict(type="str", aliases=["webAuthnPolicyPasswordlessRpId"]), + web_authn_policy_passwordless_attestation_conveyance_preference=dict( + type="str", aliases=["webAuthnPolicyPasswordlessAttestationConveyancePreference"], no_log=False + ), + web_authn_policy_passwordless_authenticator_attachment=dict( + type="str", aliases=["webAuthnPolicyPasswordlessAuthenticatorAttachment"], no_log=False + ), + web_authn_policy_passwordless_require_resident_key=dict( + type="str", aliases=["webAuthnPolicyPasswordlessRequireResidentKey"], no_log=False + ), + web_authn_policy_passwordless_user_verification_requirement=dict( + type="str", aliases=["webAuthnPolicyPasswordlessUserVerificationRequirement"], no_log=False + ), + web_authn_policy_passwordless_create_timeout=dict( + type="int", aliases=["webAuthnPolicyPasswordlessCreateTimeout"] + ), + web_authn_policy_passwordless_avoid_same_authenticator_register=dict( + type="bool", aliases=["webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister"] + ), + web_authn_policy_passwordless_acceptable_aaguids=dict( + type="list", elements="str", aliases=["webAuthnPolicyPasswordlessAcceptableAaguids"], no_log=False + ), + web_authn_policy_passwordless_extra_origins=dict( + type="list", elements="str", aliases=["webAuthnPolicyPasswordlessExtraOrigins"], no_log=False + ), + web_authn_policy_passwordless_passkeys_enabled=dict( + type="bool", aliases=["webAuthnPolicyPasswordlessPasskeysEnabled"] + ), ) argument_spec.update(meta_args) - module = AnsibleModule(argument_spec=argument_spec, - supports_check_mode=True, - required_one_of=([['id', 'realm', 'enabled'], - ['token', 'auth_realm', 'auth_username', 'auth_password']]), - required_together=([['auth_realm', 'auth_username', 'auth_password']])) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [ + ["id", "realm", "enabled"], + ["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"], + ] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) - result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API try: @@ -725,16 +1025,14 @@ def main(): kc = KeycloakAPI(module, connection_header) - realm = module.params.get('realm') - state = module.params.get('state') + realm = module.params.get("realm") + state = module.params.get("state") # convert module parameters to realm representation parameters (if they belong in there) - params_to_ignore = list(keycloak_argument_spec().keys()) + ['state'] + params_to_ignore = list(keycloak_argument_spec().keys()) + ["state"] # Filter and map the parameters names that apply to the role - realm_params = [x for x in module.params - if x not in params_to_ignore and - module.params.get(x) is not None] + realm_params = [x for x in module.params if x not in params_to_ignore and module.params.get(x) is not None] # See whether the realm already exists in Keycloak before_realm = kc.get_realm_by_id(realm=realm) @@ -753,56 +1051,52 @@ def main(): desired_realm = before_realm.copy() desired_realm.update(changeset) - result['proposed'] = sanitize_cr(changeset) + result["proposed"] = sanitize_cr(changeset) before_realm_sanitized = sanitize_cr(before_realm) - result['existing'] = before_realm_sanitized + result["existing"] = before_realm_sanitized # Cater for when it doesn't exist (an empty dict) if not before_realm: - if state == 'absent': + if state == "absent": # Do nothing and exit if module._diff: - result['diff'] = dict(before='', after='') - result['changed'] = False - result['end_state'] = {} - result['msg'] = 'Realm does not exist, doing nothing.' + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = "Realm does not exist, doing nothing." module.exit_json(**result) # Process a creation - result['changed'] = True - - if 'id' not in desired_realm: - module.fail_json(msg='id needs to be specified when creating a new realm') + result["changed"] = True if module._diff: - result['diff'] = dict(before='', after=sanitize_cr(desired_realm)) + result["diff"] = dict(before="", after=sanitize_cr(desired_realm)) if module.check_mode: module.exit_json(**result) # create it kc.create_realm(desired_realm) - after_realm = kc.get_realm_by_id(desired_realm['id']) + after_realm = kc.get_realm_by_id(desired_realm["realm"]) - result['end_state'] = sanitize_cr(after_realm) + result["end_state"] = sanitize_cr(after_realm) - result['msg'] = 'Realm %s has been created.' % desired_realm['id'] + result["msg"] = f"Realm {desired_realm['realm']} has been created." module.exit_json(**result) else: - if state == 'present': + if state == "present": # Process an update # doing an update - result['changed'] = True + result["changed"] = True if module.check_mode: # We can only compare the current realm with the proposed updates we have before_norm = normalise_cr(before_realm) desired_norm = normalise_cr(desired_realm) if module._diff: - result['diff'] = dict(before=sanitize_cr(before_norm), - after=sanitize_cr(desired_norm)) - result['changed'] = (before_norm != desired_norm) + result["diff"] = dict(before=sanitize_cr(before_norm), after=sanitize_cr(desired_norm)) + result["changed"] = before_norm != desired_norm module.exit_json(**result) @@ -812,23 +1106,22 @@ def main(): after_realm = kc.get_realm_by_id(realm=realm) if before_realm == after_realm: - result['changed'] = False + result["changed"] = False - result['end_state'] = sanitize_cr(after_realm) + result["end_state"] = sanitize_cr(after_realm) if module._diff: - result['diff'] = dict(before=before_realm_sanitized, - after=sanitize_cr(after_realm)) + result["diff"] = dict(before=before_realm_sanitized, after=sanitize_cr(after_realm)) - result['msg'] = 'Realm %s has been updated.' % desired_realm['id'] + result["msg"] = f"Realm {desired_realm['realm']} has been updated." module.exit_json(**result) else: # Process a deletion (because state was not 'present') - result['changed'] = True + result["changed"] = True if module._diff: - result['diff'] = dict(before=before_realm_sanitized, after='') + result["diff"] = dict(before=before_realm_sanitized, after="") if module.check_mode: module.exit_json(**result) @@ -836,13 +1129,13 @@ def main(): # delete it kc.delete_realm(realm=realm) - result['proposed'] = {} - result['end_state'] = {} + result["proposed"] = {} + result["end_state"] = {} - result['msg'] = 'Realm %s has been deleted.' % before_realm['id'] + result["msg"] = f"Realm {before_realm['realm']} has been deleted." module.exit_json(**result) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/plugins/modules/keycloak_realm_info.py b/plugins/modules/keycloak_realm_info.py new file mode 100644 index 0000000..2aaf2a3 --- /dev/null +++ b/plugins/modules/keycloak_realm_info.py @@ -0,0 +1,130 @@ + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_realm_info + +short_description: Allows obtaining Keycloak realm public information using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to get Keycloak realm public information using the Keycloak REST API. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way + by this module. You may pass single values for attributes when calling the module, and this is translated into a list + suitable for the API. +extends_documentation_fragment: + - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.attributes.info_module + +options: + auth_keycloak_url: + description: + - URL to the Keycloak instance. + type: str + required: true + aliases: + - url + validate_certs: + description: + - Verify TLS certificates (do not disable this in production). + type: bool + default: true + + realm: + type: str + description: + - They Keycloak realm ID. + default: 'master' + +author: + - Fynn Chen (@fynncfchen) +""" + +EXAMPLES = r""" +- name: Get a Keycloak public key + middleware_automation.keycloak.keycloak_realm_info: + realm: MyCustomRealm + auth_keycloak_url: https://auth.example.com + delegate_to: localhost +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +realm_info: + description: + - Representation of the realm public information. + returned: always + type: dict + contains: + realm: + description: Realm ID. + type: str + returned: always + sample: MyRealm + public_key: + description: Public key of the realm. + type: str + returned: always + sample: MIIBIjANBgkqhkiG9w0BAQEFAAO... + token-service: + description: Token endpoint URL. + type: str + returned: always + sample: https://auth.example.com/realms/MyRealm/protocol/openid-connect + account-service: + description: Account console URL. + type: str + returned: always + sample: https://auth.example.com/realms/MyRealm/account + tokens-not-before: + description: The token not before. + type: int + returned: always + sample: 0 +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = dict( + auth_keycloak_url=dict(type="str", aliases=["url"], required=True, no_log=False), + validate_certs=dict(type="bool", default=True), + realm=dict(default="master"), + ) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + result = dict(changed=False, msg="", realm_info="") + + kc = KeycloakAPI(module, {}) + + realm = module.params.get("realm") + + realm_info = kc.get_realm_info_by_id(realm=realm) + + result["realm_info"] = realm_info + result["msg"] = f"Get realm public info successful for ID {realm}" + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_realm_key.py b/plugins/modules/keycloak_realm_key.py new file mode 100644 index 0000000..297e5f5 --- /dev/null +++ b/plugins/modules/keycloak_realm_key.py @@ -0,0 +1,1058 @@ + +# Copyright (c) 2017, Eike Frost +# Copyright (c) 2021, Christophe Gilles +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_realm_key + +short_description: Allows administration of Keycloak realm keys using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows the administration of Keycloak realm keys using the Keycloak REST API. It requires access to the REST + API using OpenID Connect; the user connecting and the realm being used must have the requisite access rights. In a default + Keycloak installation, admin-cli and an admin user would work, as would a separate realm definition with the scope tailored + to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). Aliases are provided so camelCased versions can be used + as well. + - This module is unable to detect changes to the actual cryptographic key after importing it. However, if some other property + is changed alongside the cryptographic key, then the key also changes as a side-effect, as the JSON payload needs to include + the private key. This can be considered either a bug or a feature, as the alternative would be to always update the realm + key whether it has changed or not. +attributes: + check_mode: + support: full + diff_mode: + support: partial + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the keycloak realm key. + - On V(present), the realm key is created (or updated if it exists already). + - On V(absent), the realm key is removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + name: + description: + - Name of the realm key to create. + type: str + required: true + force: + description: + - Enforce the state of the private key and certificate. This is not automatically the case as this module is unable + to determine the current state of the private key and thus cannot trigger an update based on an actual divergence. + That said, a private key update may happen even if force is false as a side-effect of other changes. + default: false + type: bool + parent_id: + description: + - The parent_id of the realm key. In practice the name of the realm. + type: str + required: true + provider_id: + description: + - The name of the "provider ID" for the key. + - The value V(rsa-enc) has been added in middleware_automation.keycloak 8.2.0. + - The value V(java-keystore) has been added in middleware_automation.keycloak 12.4.0. This provider imports keys from + a Java Keystore (JKS or PKCS12) file located on the Keycloak server filesystem. + - The values V(rsa-generated), V(hmac-generated), V(aes-generated), and V(ecdsa-generated) have been added in + middleware_automation.keycloak 3.0.0. These are auto-generated key providers where Keycloak manages the key material. + - The values V(rsa-enc-generated), V(ecdh-generated), and V(eddsa-generated) have been added in + middleware_automation.keycloak 3.0.0. These correspond to the auto-generated key providers available in Keycloak 26. + choices: + - rsa + - rsa-enc + - java-keystore + - rsa-generated + - rsa-enc-generated + - hmac-generated + - aes-generated + - ecdsa-generated + - ecdh-generated + - eddsa-generated + default: 'rsa' + type: str + config: + description: + - Dict specifying the key and its properties. + type: dict + suboptions: + active: + description: + - Whether they key is active or inactive. Not to be confused with the state of the Ansible resource managed by the + O(state) parameter. + default: true + type: bool + enabled: + description: + - Whether the key is enabled or disabled. Not to be confused with the state of the Ansible resource managed by the + O(state) parameter. + default: true + type: bool + priority: + description: + - The priority of the key. + type: int + required: true + algorithm: + description: + - Key algorithm. + - The values V(RS384), V(RS512), V(PS256), V(PS384), V(PS512), V(RSA1_5), V(RSA-OAEP), V(RSA-OAEP-256) have been + added in middleware_automation.keycloak 8.2.0. + - The values V(HS256), V(HS384), V(HS512) (for HMAC), V(ES256), V(ES384), V(ES512) (for ECDSA), and V(AES) + have been added in middleware_automation.keycloak 12.4.0. + - The values V(ECDH_ES), V(ECDH_ES_A128KW), V(ECDH_ES_A192KW), V(ECDH_ES_A256KW) (for ECDH key exchange), + and V(Ed25519), V(Ed448) (for EdDSA signing) have been added in middleware_automation.keycloak 12.4.0. + - For O(provider_id=rsa), O(provider_id=rsa-generated), and O(provider_id=java-keystore), defaults to V(RS256). + - For O(provider_id=rsa-enc) and O(provider_id=rsa-enc-generated), must be one of V(RSA1_5), V(RSA-OAEP), V(RSA-OAEP-256) (required, no default). + - For O(provider_id=hmac-generated), must be one of V(HS256), V(HS384), V(HS512) (required, no default). + - For O(provider_id=ecdsa-generated), must be one of V(ES256), V(ES384), V(ES512) (required, no default). + - For O(provider_id=ecdh-generated), must be one of V(ECDH_ES), V(ECDH_ES_A128KW), V(ECDH_ES_A192KW), V(ECDH_ES_A256KW) (required, no default). + - For O(provider_id=eddsa-generated), this option is not used (the algorithm is determined by O(config.elliptic_curve)). + - For O(provider_id=aes-generated), this option is not used (AES is always used). + choices: + - RS256 + - RS384 + - RS512 + - PS256 + - PS384 + - PS512 + - RSA1_5 + - RSA-OAEP + - RSA-OAEP-256 + - HS256 + - HS384 + - HS512 + - ES256 + - ES384 + - ES512 + - AES + - ECDH_ES + - ECDH_ES_A128KW + - ECDH_ES_A192KW + - ECDH_ES_A256KW + - Ed25519 + - Ed448 + default: RS256 + type: str + private_key: + description: + - The private key as an ASCII string. Contents of the key must match O(config.algorithm) and O(provider_id). + - Please note that the module cannot detect whether the private key specified differs from the current state's private + key. Use O(force=true) to force the module to update the private key if you expect it to be updated. + - Required when O(provider_id) is V(rsa) or V(rsa-enc). Not used for auto-generated providers. + type: str + certificate: + description: + - A certificate signed with the private key as an ASCII string. Contents of the key must match O(config.algorithm) + and O(provider_id). + - If you want Keycloak to automatically generate a certificate using your private key then set this to an empty + string. + - Required when O(provider_id) is V(rsa) or V(rsa-enc). Not used for auto-generated providers. + type: str + secret_size: + description: + - The size of the generated secret key in bytes. + - Only applicable to O(provider_id=hmac-generated) and O(provider_id=aes-generated). + - Valid values are V(16), V(24), V(32), V(64), V(128), V(256), V(512). + - Default is V(64) for HMAC, V(16) for AES. + type: int + key_size: + description: + - The size of the generated key in bits. + - Only applicable to O(provider_id=rsa-generated) and O(provider_id=rsa-enc-generated). + - Valid values are V(1024), V(2048), V(4096). Default is V(2048). + type: int + elliptic_curve: + description: + - The elliptic curve to use for ECDSA, ECDH, or EdDSA keys. + - For O(provider_id=ecdsa-generated) and O(provider_id=ecdh-generated), valid values are V(P-256), V(P-384), V(P-521). Default is V(P-256). + - For O(provider_id=eddsa-generated), valid values are V(Ed25519), V(Ed448). Default is V(Ed25519). + type: str + choices: ['P-256', 'P-384', 'P-521', 'Ed25519', 'Ed448'] + keystore: + description: + - Path to the Java Keystore file on the Keycloak server filesystem. + - Required when O(provider_id=java-keystore). + type: str + keystore_password: + description: + - Password for the Java Keystore. + - Required when O(provider_id=java-keystore). + type: str + key_alias: + description: + - Alias of the key within the keystore. + - Required when O(provider_id=java-keystore). + type: str + key_password: + description: + - Password for the key within the keystore. + - If not specified, the O(config.keystore_password) is used. + - Only applicable to O(provider_id=java-keystore). + type: str + update_password: + description: + - Controls when passwords are sent to Keycloak for V(java-keystore) provider. + - V(always) - Always send passwords. Keycloak will update the component even if passwords + have not changed. Use when you need to ensure passwords are updated. + - V(on_create) - Only send passwords when creating a new component. When updating an + existing component, send the masked value to preserve existing passwords. This makes + the module idempotent for password fields. + - This is necessary because Keycloak masks passwords in API responses (returns C(**********)), + making comparison impossible. + - Has no effect for providers other than V(java-keystore). + type: str + choices: ['always', 'on_create'] + default: always +notes: + - Current value of the private key cannot be fetched from Keycloak. Therefore comparing its desired state to the current + state is not possible. + - If O(config.certificate) is not explicitly provided it is dynamically created by Keycloak. Therefore comparing the current + state of the certificate to the desired state (which may be empty) is not possible. + - Due to the private key and certificate options the module is B(not fully idempotent). You can use O(force=true) to force + the module to ensure updating if you know that the private key might have changed. + - For auto-generated providers (V(rsa-generated), V(rsa-enc-generated), V(hmac-generated), V(aes-generated), V(ecdsa-generated), + V(ecdh-generated), V(eddsa-generated)), Keycloak manages the key material automatically. The O(config.private_key) and + O(config.certificate) options are not used. + - For V(java-keystore) provider, the O(config.keystore_password) and O(config.key_password) values are returned masked by + Keycloak. Therefore comparing their current state to the desired state is not possible. Use O(update_password=on_create) + for idempotent playbooks, or use O(update_password=always) (default) if you need to ensure passwords are updated. +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Samuli Seppänen (@mattock) + - Ivan Kokalović (@koke1997) +""" + +EXAMPLES = r""" +- name: Manage Keycloak realm key (certificate autogenerated by Keycloak) + middleware_automation.keycloak.keycloak_realm_key: + name: custom + state: present + parent_id: master + provider_id: rsa + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + config: + private_key: "{{ private_key }}" + certificate: "" + enabled: true + active: true + priority: 120 + algorithm: RS256 + +- name: Manage Keycloak realm key and certificate + middleware_automation.keycloak.keycloak_realm_key: + name: custom + state: present + parent_id: master + provider_id: rsa + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + config: + private_key: "{{ private_key }}" + certificate: "{{ certificate }}" + enabled: true + active: true + priority: 120 + algorithm: RS256 + +- name: Create HMAC signing key (auto-generated) + middleware_automation.keycloak.keycloak_realm_key: + name: hmac-custom + state: present + parent_id: master + provider_id: hmac-generated + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + config: + enabled: true + active: true + priority: 100 + algorithm: HS256 + secret_size: 64 + +- name: Create AES encryption key (auto-generated) + middleware_automation.keycloak.keycloak_realm_key: + name: aes-custom + state: present + parent_id: master + provider_id: aes-generated + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + config: + enabled: true + active: true + priority: 100 + secret_size: 16 + +- name: Create ECDSA signing key (auto-generated) + middleware_automation.keycloak.keycloak_realm_key: + name: ecdsa-custom + state: present + parent_id: master + provider_id: ecdsa-generated + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + config: + enabled: true + active: true + priority: 100 + algorithm: ES256 + elliptic_curve: P-256 + +- name: Create RSA signing key (auto-generated) + middleware_automation.keycloak.keycloak_realm_key: + name: rsa-auto + state: present + parent_id: master + provider_id: rsa-generated + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + key_size: 2048 + +- name: Remove default HMAC key + middleware_automation.keycloak.keycloak_realm_key: + name: hmac-generated + state: absent + parent_id: myrealm + provider_id: hmac-generated + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + config: + priority: 100 + +- name: Create RSA encryption key (auto-generated) + middleware_automation.keycloak.keycloak_realm_key: + name: rsa-enc-auto + state: present + parent_id: master + provider_id: rsa-enc-generated + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + config: + enabled: true + active: true + priority: 100 + algorithm: RSA-OAEP + key_size: 2048 + +- name: Create ECDH key exchange key (auto-generated) + middleware_automation.keycloak.keycloak_realm_key: + name: ecdh-custom + state: present + parent_id: master + provider_id: ecdh-generated + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + config: + enabled: true + active: true + priority: 100 + algorithm: ECDH_ES + elliptic_curve: P-256 + +- name: Create EdDSA signing key (auto-generated) + middleware_automation.keycloak.keycloak_realm_key: + name: eddsa-custom + state: present + parent_id: master + provider_id: eddsa-generated + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + config: + enabled: true + active: true + priority: 100 + elliptic_curve: Ed25519 + +- name: Import key from Java Keystore (always update passwords) + middleware_automation.keycloak.keycloak_realm_key: + name: jks-imported + state: present + parent_id: master + provider_id: java-keystore + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + # update_password: always is the default - passwords are always sent to Keycloak + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + keystore: /opt/keycloak/conf/keystore.jks + keystore_password: "{{ keystore_password }}" + key_alias: mykey + key_password: "{{ key_password }}" + +- name: Import key from Java Keystore (idempotent - only set password on create) + middleware_automation.keycloak.keycloak_realm_key: + name: jks-idempotent + state: present + parent_id: master + provider_id: java-keystore + auth_keycloak_url: http://localhost:8080 + auth_username: keycloak + auth_password: keycloak + auth_realm: master + update_password: on_create # Only send passwords when creating, preserve existing on update + config: + enabled: true + active: true + priority: 100 + algorithm: RS256 + keystore: /opt/keycloak/conf/keystore.jks + keystore_password: "{{ keystore_password }}" + key_alias: mykey + key_password: "{{ key_password }}" +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +end_state: + description: Representation of the keycloak_realm_key after module execution. + returned: on success + type: dict + contains: + id: + description: ID of the realm key. + type: str + returned: when O(state=present) + sample: 5b7ec13f-99da-46ad-8326-ab4c73cf4ce4 + name: + description: Name of the realm key. + type: str + returned: when O(state=present) + sample: mykey + parentId: + description: ID of the realm this key belongs to. + type: str + returned: when O(state=present) + sample: myrealm + providerId: + description: The ID of the key provider. + type: str + returned: when O(state=present) + sample: rsa + providerType: + description: The type of provider. + type: str + returned: when O(state=present) + config: + description: Realm key configuration. + type: dict + returned: when O(state=present) + sample: + { + "active": [ + "true" + ], + "algorithm": [ + "RS256" + ], + "enabled": [ + "true" + ], + "priority": [ + "140" + ] + } + key_info: + description: + - Cryptographic key metadata fetched from the realm keys endpoint. + - Only returned for V(java-keystore) provider when O(state=present) and not in check mode. + - This includes the key ID (kid) and certificate fingerprint, which can be used to detect + if the actual cryptographic key changed. + type: dict + returned: when O(provider_id=java-keystore) and O(state=present) + contains: + kid: + description: The key ID (kid) - unique identifier for the cryptographic key. + type: str + sample: bN7p5Nc_V2M7N_-mb5vVSRVPKq5qD_OuARInB9ofsJ0 + certificate_fingerprint: + description: SHA256 fingerprint of the certificate in colon-separated hex format. + type: str + sample: "A1:B2:C3:D4:E5:F6:..." + status: + description: The key status (ACTIVE, PASSIVE, DISABLED). + type: str + sample: ACTIVE + valid_to: + description: Certificate expiration timestamp in milliseconds since epoch. + type: int + sample: 1801789047000 +""" + +import base64 +import binascii +import hashlib +from copy import deepcopy +from urllib.parse import urlencode + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + keycloak_argument_spec, +) + +# Provider IDs that require private_key and certificate +IMPORTED_KEY_PROVIDERS = ["rsa", "rsa-enc"] +# Provider IDs that import keys from Java Keystore +KEYSTORE_PROVIDERS = ["java-keystore"] +# Provider IDs that auto-generate keys +GENERATED_KEY_PROVIDERS = [ + "rsa-generated", + "rsa-enc-generated", + "hmac-generated", + "aes-generated", + "ecdsa-generated", + "ecdh-generated", + "eddsa-generated", +] + +# Mapping of Ansible parameter names to Keycloak config property names +# for cases where camel() conversion doesn't produce the correct result. +# Each provider type may use a different config key for elliptic curve. +CONFIG_PARAM_MAPPING = { + "elliptic_curve": "ecdsaEllipticCurveKey", +} + +# Provider-specific config key names for elliptic_curve parameter +# ECDSA and ECDH both use the same curves (P-256, P-384, P-521) but different config keys +# EdDSA uses different curves (Ed25519, Ed448) with its own config key +ELLIPTIC_CURVE_CONFIG_KEYS = { + "ecdsa-generated": "ecdsaEllipticCurveKey", + "ecdh-generated": "ecdhEllipticCurveKey", + "eddsa-generated": "eddsaEllipticCurveKey", +} + +# Valid algorithm choices per provider type +# Note: aes-generated and eddsa-generated don't use algorithm config +PROVIDER_ALGORITHMS = { + "rsa": ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"], + "rsa-enc": ["RSA1_5", "RSA-OAEP", "RSA-OAEP-256"], + "java-keystore": ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"], + "rsa-generated": ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"], + "rsa-enc-generated": ["RSA1_5", "RSA-OAEP", "RSA-OAEP-256"], + "hmac-generated": ["HS256", "HS384", "HS512"], + "ecdsa-generated": ["ES256", "ES384", "ES512"], + "ecdh-generated": ["ECDH_ES", "ECDH_ES_A128KW", "ECDH_ES_A192KW", "ECDH_ES_A256KW"], +} + +# Providers that don't use the algorithm config parameter +# eddsa-generated: algorithm is determined by the elliptic curve (Ed25519 or Ed448) +# aes-generated: always uses AES algorithm +PROVIDERS_WITHOUT_ALGORITHM = ["aes-generated", "eddsa-generated"] + +# Providers where the RS256 default is valid (for backward compatibility) +PROVIDERS_WITH_RS256_DEFAULT = ["rsa", "rsa-generated", "java-keystore"] + +# Config keys that cannot be compared and must be removed from changesets/diffs. +# privateKey/certificate: Keycloak doesn't return private keys, certificates are generated dynamically. +# keystorePassword/keyPassword: Keycloak masks these with "**********" in API responses. +SENSITIVE_CONFIG_KEYS = ["privateKey", "certificate", "keystorePassword", "keyPassword"] + + +def remove_sensitive_config_keys(config): + for key in SENSITIVE_CONFIG_KEYS: + config.pop(key, None) + + +def get_keycloak_config_key(param_name, provider_id=None): + """Convert Ansible parameter name to Keycloak config key. + + Uses explicit mapping if available, otherwise applies camelCase conversion. + For elliptic_curve, the config key depends on the provider type. + """ + # Handle elliptic_curve specially - each provider uses a different config key + if param_name == "elliptic_curve" and provider_id in ELLIPTIC_CURVE_CONFIG_KEYS: + return ELLIPTIC_CURVE_CONFIG_KEYS[param_name] + if param_name in CONFIG_PARAM_MAPPING: + return CONFIG_PARAM_MAPPING[param_name] + return camel(param_name) + + +def compute_certificate_fingerprint(certificate_pem): + try: + cert_der = base64.b64decode(certificate_pem) + fingerprint = hashlib.sha256(cert_der).hexdigest().upper() + return ":".join(fingerprint[i : i + 2] for i in range(0, len(fingerprint), 2)) + except (ValueError, binascii.Error, TypeError): + return None + + +def get_key_info_for_component(kc, realm, component_id): + try: + keys_response = kc.get_realm_keys_metadata_by_id(realm) + if not keys_response or "keys" not in keys_response: + return None + + for key in keys_response.get("keys", []): + if key.get("providerId") == component_id: + return { + "kid": key.get("kid"), + "certificate_fingerprint": compute_certificate_fingerprint(key.get("certificate")), + "public_key": key.get("publicKey"), + "valid_to": key.get("validTo"), + "status": key.get("status"), + "algorithm": key.get("algorithm"), + "type": key.get("type"), + } + return None + except (KeyError, TypeError): + return None + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(type="str", default="present", choices=["present", "absent"]), + name=dict(type="str", required=True), + force=dict(type="bool", default=False), + parent_id=dict(type="str", required=True), + provider_id=dict( + type="str", + default="rsa", + choices=[ + "rsa", + "rsa-enc", + "java-keystore", + "rsa-generated", + "rsa-enc-generated", + "hmac-generated", + "aes-generated", + "ecdsa-generated", + "ecdh-generated", + "eddsa-generated", + ], + ), + config=dict( + type="dict", + options=dict( + active=dict(type="bool", default=True), + enabled=dict(type="bool", default=True), + priority=dict(type="int", required=True), + algorithm=dict( + type="str", + default="RS256", + choices=[ + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "RSA1_5", + "RSA-OAEP", + "RSA-OAEP-256", + "HS256", + "HS384", + "HS512", + "ES256", + "ES384", + "ES512", + "AES", + "ECDH_ES", + "ECDH_ES_A128KW", + "ECDH_ES_A192KW", + "ECDH_ES_A256KW", + "Ed25519", + "Ed448", + ], + ), + private_key=dict(type="str", no_log=True), + certificate=dict(type="str"), + secret_size=dict(type="int", no_log=False), + key_size=dict(type="int"), + elliptic_curve=dict(type="str", choices=["P-256", "P-384", "P-521", "Ed25519", "Ed448"]), + keystore=dict(type="str", no_log=False), + keystore_password=dict(type="str", no_log=True), + key_alias=dict(type="str", no_log=False), + key_password=dict(type="str", no_log=True), + ), + ), + update_password=dict( + type="str", + default="always", + choices=["always", "on_create"], + no_log=False, + ), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + provider_id = module.params["provider_id"] + config = module.params["config"] or {} + state = module.params["state"] + + # Validate that imported key providers have the required parameters + if state == "present" and provider_id in IMPORTED_KEY_PROVIDERS: + if not config.get("private_key"): + module.fail_json(msg=f"config.private_key is required for provider_id '{provider_id}'") + if config.get("certificate") is None: + module.fail_json( + msg=f"config.certificate is required for provider_id '{provider_id}' (use empty string for auto-generation)" + ) + + # Validate that java-keystore providers have the required parameters + if state == "present" and provider_id in KEYSTORE_PROVIDERS: + required_params = ["keystore", "keystore_password", "key_alias"] + missing = [p for p in required_params if not config.get(p)] + if missing: + module.fail_json( + msg=f"For provider_id=java-keystore, the following config options are required: {', '.join(missing)}" + ) + + # Validate algorithm for providers that use it + if state == "present": + algorithm = config.get("algorithm") + if provider_id in PROVIDER_ALGORITHMS: + valid_algorithms = PROVIDER_ALGORITHMS[provider_id] + if algorithm not in valid_algorithms: + msg = f"algorithm '{algorithm}' is not valid for provider_id '{provider_id}'." + if algorithm == "RS256" and provider_id not in PROVIDERS_WITH_RS256_DEFAULT: + msg += " The default 'RS256' is not valid for this provider." + msg += f" Valid choices are: {', '.join(valid_algorithms)}" + module.fail_json(msg=msg) + elif provider_id in PROVIDERS_WITHOUT_ALGORITHM and algorithm is not None and algorithm != "RS256": + # aes-generated and eddsa-generated don't use algorithm - only warn if user explicitly set a non-default value + module.warn(f"algorithm is ignored for provider_id '{provider_id}'") + + # Validate elliptic curve for providers that use it + if state == "present": + elliptic_curve = config.get("elliptic_curve") + if provider_id in ["ecdsa-generated", "ecdh-generated"] and elliptic_curve is not None: + valid_curves = ["P-256", "P-384", "P-521"] + if elliptic_curve not in valid_curves: + module.fail_json( + msg=f"elliptic_curve '{elliptic_curve}' is not valid for provider_id '{provider_id}'. " + f"Valid choices are: {', '.join(valid_curves)}" + ) + elif provider_id == "eddsa-generated" and elliptic_curve is not None: + valid_curves = ["Ed25519", "Ed448"] + if elliptic_curve not in valid_curves: + module.fail_json( + msg=f"elliptic_curve '{elliptic_curve}' is not valid for provider_id '{provider_id}'. " + f"Valid choices are: {', '.join(valid_curves)}" + ) + + result = dict(changed=False, msg="", end_state={}, diff=dict(before={}, after={})) + + # This will include the current state of the realm key if it is already + # present. This is only used for diff-mode. + before_realm_key = {} + before_realm_key["config"] = {} + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + params_to_ignore = list(keycloak_argument_spec().keys()) + ["state", "force", "parent_id", "update_password"] + + # Filter and map the parameters names that apply to the role + component_params = [x for x in module.params if x not in params_to_ignore and module.params.get(x) is not None] + + # We only support one component provider type in this module + provider_type = "org.keycloak.keys.KeyProvider" + + # Build a proposed changeset from parameters given to this module + changeset = {} + changeset["config"] = {} + + # Generate a JSON payload for Keycloak Admin API from the module + # parameters. Parameters that do not belong to the JSON payload (e.g. + # "state" or "auth_keycloal_url") have been filtered away earlier (see + # above). + # + # This loop converts Ansible module parameters (snake-case) into + # Keycloak-compatible format (camel-case). For example private_key + # becomes privateKey. + # + # It also converts bool, str and int parameters into lists with a single + # entry of 'str' type. Bool values are also lowercased. This is required + # by Keycloak. + # + for component_param in component_params: + if component_param == "config": + for config_param in module.params["config"]: + raw_value = module.params["config"][config_param] + # Optional params (secret_size, key_size, elliptic_curve) default to None. + # Skip them to avoid sending str(None) = "None" as a config value to Keycloak. + if raw_value is None: + continue + # Use custom mapping if available, otherwise camelCase + # Pass provider_id for elliptic_curve which uses different config keys per provider + keycloak_key = get_keycloak_config_key(config_param, provider_id) + changeset["config"][keycloak_key] = [] + if isinstance(raw_value, bool): + value = str(raw_value).lower() + else: + value = str(raw_value) + + changeset["config"][keycloak_key].append(value) + else: + # No need for camelcase in here as these are one word parameters + new_param_value = module.params[component_param] + changeset[camel(component_param)] = new_param_value + + # As provider_type is not a module parameter we have to add it to the + # changeset explicitly. + changeset["providerType"] = provider_type + + # Make a deep copy of the changeset. This is use when determining + # changes to the current state. + changeset_copy = deepcopy(changeset) + + # Remove keys that cannot be compared: privateKey/certificate (not returned + # by Keycloak API) and keystore passwords (masked with "**********"). + # The actual values remain in 'changeset' for the API payload. + remove_sensitive_config_keys(changeset_copy["config"]) + + name = module.params["name"] + force = module.params["force"] + parent_id = module.params["parent_id"] + + # Get a list of all Keycloak components that are of keyprovider type. + realm_keys = kc.get_components(urlencode(dict(type=provider_type)), parent_id) + + # If this component is present get its key ID. Confusingly the key ID is + # also known as the Provider ID. + key_id = None + + # Track individual parameter changes + changes = "" + + # This tells Ansible whether the key was changed (added, removed, modified) + result["changed"] = False + + # Loop through the list of components. If we encounter a component whose + # name matches the value of the name parameter then assume the key is + # already present. + for key in realm_keys: + if key["name"] == name: + key_id = key["id"] + changeset["id"] = key_id + changeset_copy["id"] = key_id + + # Compare top-level parameters + for param in changeset: + before_realm_key[param] = key[param] + + if changeset_copy[param] != key[param] and param != "config": + changes += f"{param}: {key[param]} -> {changeset_copy[param]}, " + result["changed"] = True + + # Compare parameters under the "config" key + # Note: Keycloak API may not return all config fields for default keys + # (e.g., 'active', 'enabled', 'algorithm' may be missing). Handle this + # gracefully by using .get() with defaults. + for p, v in changeset_copy["config"].items(): + # Get the current value, defaulting to our expected value if not present + # This handles the case where Keycloak doesn't return certain fields + # for default/generated keys + current_value = key["config"].get(p, v) + before_realm_key["config"][p] = current_value + if v != current_value: + changes += f"config.{p}: {current_value} -> {v}, " + result["changed"] = True + + # For java-keystore provider, also fetch and compare key info (kid) + # This detects if the actual cryptographic key changed even when + # other config parameters remain the same + if provider_id in KEYSTORE_PROVIDERS: + current_key_info = get_key_info_for_component(kc, parent_id, key_id) + if current_key_info: + before_realm_key["key_info"] = { + "kid": current_key_info.get("kid"), + "certificate_fingerprint": current_key_info.get("certificate_fingerprint"), + } + + # Sanitize linefeeds for the privateKey and certificate (only for imported providers). + # Without this the JSON payload will be invalid. + if "privateKey" in changeset["config"]: + changeset["config"]["privateKey"][0] = changeset["config"]["privateKey"][0].replace("\\n", "\n") + if "certificate" in changeset["config"]: + changeset["config"]["certificate"][0] = changeset["config"]["certificate"][0].replace("\\n", "\n") + + # For java-keystore provider: handle update_password parameter + # When update_password=on_create and we're updating an existing component, + # replace actual passwords with the masked value ("**********") that Keycloak + # returns in API responses. When Keycloak receives this masked value, it + # preserves the existing password instead of updating it. + # This makes the module idempotent for password fields. + update_password = module.params["update_password"] + if provider_id in KEYSTORE_PROVIDERS and key_id and update_password == "on_create": + SECRET_VALUE = "**********" + if "keystorePassword" in changeset["config"]: + changeset["config"]["keystorePassword"] = [SECRET_VALUE] + if "keyPassword" in changeset["config"]: + changeset["config"]["keyPassword"] = [SECRET_VALUE] + + # Check all the possible states of the resource and do what is needed to + # converge current state with desired state (create, update or delete + # the key). + if key_id and state == "present": + if result["changed"]: + if module._diff: + remove_sensitive_config_keys(before_realm_key["config"]) + result["diff"] = dict(before=before_realm_key, after=changeset_copy) + + if module.check_mode: + result["msg"] = f"Realm key {name} would be changed: {changes.strip(', ')}" + else: + kc.update_component(changeset, parent_id) + result["msg"] = f"Realm key {name} changed: {changes.strip(', ')}" + elif not result["changed"] and force: + kc.update_component(changeset, parent_id) + result["changed"] = True + result["msg"] = f"Realm key {name} was forcibly updated" + else: + result["msg"] = f"Realm key {name} was in sync" + + result["end_state"] = changeset_copy + + # For java-keystore provider, include key info in end_state + if provider_id in KEYSTORE_PROVIDERS: + if not module.check_mode: + key_info = get_key_info_for_component(kc, parent_id, key_id) + if key_info: + result["end_state"]["key_info"] = { + "kid": key_info.get("kid"), + "certificate_fingerprint": key_info.get("certificate_fingerprint"), + "status": key_info.get("status"), + "valid_to": key_info.get("valid_to"), + } + else: + module.warn( + f"Key component '{name}' exists but no active key was found. " + "This may indicate an incorrect keystore password, path, or alias." + ) + elif key_id and state == "absent": + if module._diff: + remove_sensitive_config_keys(before_realm_key["config"]) + result["diff"] = dict(before=before_realm_key, after={}) + + if module.check_mode: + result["changed"] = True + result["msg"] = f"Realm key {name} would be deleted" + else: + kc.delete_component(key_id, parent_id) + result["changed"] = True + result["msg"] = f"Realm key {name} deleted" + + result["end_state"] = {} + elif not key_id and state == "present": + if module._diff: + result["diff"] = dict(before={}, after=changeset_copy) + + if module.check_mode: + result["changed"] = True + result["msg"] = f"Realm key {name} would be created" + else: + kc.create_component(changeset, parent_id) + result["changed"] = True + result["msg"] = f"Realm key {name} created" + + # For java-keystore provider, fetch and include key info after creation + if provider_id in KEYSTORE_PROVIDERS: + # We need to get the component ID first (it was just created) + realm_keys_after = kc.get_components(urlencode(dict(type=provider_type)), parent_id) + for k in realm_keys_after: + if k["name"] == name: + new_key_id = k["id"] + key_info = get_key_info_for_component(kc, parent_id, new_key_id) + if key_info: + changeset_copy["key_info"] = { + "kid": key_info.get("kid"), + "certificate_fingerprint": key_info.get("certificate_fingerprint"), + "status": key_info.get("status"), + "valid_to": key_info.get("valid_to"), + } + else: + module.warn( + f"Key component '{name}' was created but no active key was found. " + "This may indicate an incorrect keystore password, path, or alias." + ) + break + + result["end_state"] = changeset_copy + elif not key_id and state == "absent": + result["changed"] = False + result["msg"] = f"Realm key {name} not present" + result["end_state"] = {} + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_realm_keys_metadata_info.py b/plugins/modules/keycloak_realm_keys_metadata_info.py new file mode 100644 index 0000000..4c110e8 --- /dev/null +++ b/plugins/modules/keycloak_realm_keys_metadata_info.py @@ -0,0 +1,135 @@ + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_realm_keys_metadata_info + +short_description: Allows obtaining Keycloak realm keys metadata using Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to get Keycloak realm keys metadata using the Keycloak REST API. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). +attributes: + action_group: + version_added: "3.0.0" + +options: + realm: + type: str + description: + - They Keycloak realm to fetch keys metadata. + default: 'master' + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.attributes.info_module + +author: + - Thomas Bach (@thomasbach-dev) +""" + +EXAMPLES = r""" +- name: Fetch Keys metadata + middleware_automation.keycloak.keycloak_realm_keys_metadata_info: + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + delegate_to: localhost + register: keycloak_keys_metadata + +- name: Write the Keycloak keys certificate into a file + ansible.builtin.copy: + dest: /tmp/keycloak.cert + content: | + {{ keys_metadata['keycloak_keys_metadata']['keys'] + | selectattr('algorithm', 'equalto', 'RS256') + | map(attribute='certificate') + | first + }} + delegate_to: localhost +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +keys_metadata: + description: + + - Representation of the realm keys metadata (see U(https://www.keycloak.org/docs-api/latest/rest-api/index.html#KeysMetadataRepresentation)). + returned: always + type: dict + contains: + active: + description: A mapping (that is, a dict) from key algorithms to UUIDs. + type: dict + returned: always + keys: + description: A list of dicts providing detailed information on the keys. + type: list + elements: dict + returned: always +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + argument_spec = keycloak_argument_spec() + + meta_args = dict( + realm=dict(default="master"), + ) + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", keys_metadata="") + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + + keys_metadata = kc.get_realm_keys_metadata_by_id(realm=realm) + + result["keys_metadata"] = keys_metadata + result["msg"] = f"Get realm keys metadata successful for ID {realm}" + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py new file mode 100644 index 0000000..b380590 --- /dev/null +++ b/plugins/modules/keycloak_realm_localization.py @@ -0,0 +1,398 @@ +# !/usr/bin/python +# Copyright Jakub Danek +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or +# https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_realm_localization + +short_description: Allows management of Keycloak realm localization overrides via the Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to manage per-locale message overrides for a Keycloak realm using the Keycloak Admin REST API. + - Requires access via OpenID Connect; the connecting user/client must have sufficient privileges. + - The names of module options are snake_cased versions of the names found in the Keycloak API. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + force: + description: + - If V(false), only the keys listed in the O(overrides) are modified by this module. Any other pre-existing + keys are ignored. + - If V(true), all locale overrides are made to match configuration of this module. For example any keys + missing from the O(overrides) are removed regardless of O(state) value. + type: bool + default: false + locale: + description: + - Locale code for which the overrides apply (for example, V(en), V(fi), V(de)). + type: str + required: true + parent_id: + description: + - Name of the realm that owns the locale overrides. + type: str + required: true + state: + description: + - Desired state of localization overrides for the given locale. + - On V(present), the set of overrides for the locale are made to match O(overrides). + If O(force) is V(true) keys not listed in O(overrides) are removed, + and the listed keys are created or updated. + If O(force) is V(false) keys not listed in O(overrides) are ignored, + and the listed keys are created or updated. + - On V(absent), overrides for the locale is removed. If O(force) is V(true), all keys are removed. + If O(force) is V(false), only the keys listed in O(overrides) are removed. + type: str + choices: ['present', 'absent'] + default: present + overrides: + description: + - List of overrides to ensure for the locale when O(state=present). Each item is a mapping with + the record's O(overrides[].key) and its O(overrides[].value). + - Ignored when O(state=absent). + type: list + elements: dict + default: [] + suboptions: + key: + description: + - The message key to override. + type: str + required: true + value: + description: + - The override value for the message key. If omitted, value defaults to an empty string. + type: str + default: "" + required: false + +seealso: + - module: middleware_automation.keycloak.keycloak_realm + description: You can specify list of supported locales using O(middleware_automation.keycloak.keycloak_realm#module:supported_locales). + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: Jakub Danek (@danekja) +""" + +EXAMPLES = r""" +- name: Replace all overrides for locale "en" (credentials auth) + middleware_automation.keycloak.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: en + state: present + force: true + overrides: + - key: greeting + value: "Hello" + - key: farewell + value: "Bye" + delegate_to: localhost + +- name: Replace listed overrides for locale "en" (credentials auth) + middleware_automation.keycloak.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: en + state: present + force: false + overrides: + - key: greeting + value: "Hello" + - key: farewell + value: "Bye" + delegate_to: localhost + +- name: Ensure only one override exists for locale "fi" (token auth) + middleware_automation.keycloak.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + parent_id: my-realm + locale: fi + state: present + force: true + overrides: + - key: app.title + value: "Sovellukseni" + delegate_to: localhost + +- name: Remove all overrides for locale "de" + middleware_automation.keycloak.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: de + state: absent + force: true + delegate_to: localhost + +- name: Remove only the listed overrides for locale "de" + middleware_automation.keycloak.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: de + state: absent + force: false + overrides: + - key: app.title + - key: foo + - key: bar + delegate_to: localhost +""" + +RETURN = r""" +end_state: + description: + - Final state of localization overrides for the locale after module execution. + - Contains the O(locale) and the list of O(overrides) as key/value items. + returned: on success + type: dict + contains: + locale: + description: The locale code affected. + type: str + sample: en + overrides: + description: The list of overrides that exist after execution. + type: list + elements: dict + sample: + - key: greeting + value: Hello + - key: farewell + value: Bye +""" + +from copy import deepcopy + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def _normalize_overrides(current: dict | None) -> list[dict]: + """ + Accepts: + - dict: {'k1': 'v1', ...} + Return a sorted list of {'key', 'value'}. + + This helper provides a consistent shape for downstream comparison/diff logic. + """ + if not current: + return [] + + return [{"key": k, "value": v} for k, v in sorted(current.items())] + + +def main(): + argument_spec = keycloak_argument_spec() + + # Single override record structure + overrides_spec = dict( + key=dict(type="str", no_log=False, required=True), + value=dict(type="str", default=""), + ) + + meta_args = dict( + locale=dict(type="str", required=True), + parent_id=dict(type="str", required=True), + state=dict(type="str", default="present", choices=["present", "absent"]), + overrides=dict(type="list", elements="dict", options=overrides_spec, default=[]), + force=dict(type="bool", default=False), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([["token", "auth_realm", "auth_username", "auth_password"]]), + required_together=([["auth_realm", "auth_username", "auth_password"]]), + ) + + result = dict(changed=False, msg="", end_state={}, diff=dict(before={}, after={})) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Convenience locals for frequently used parameters + locale = module.params["locale"] + state = module.params["state"] + parent_id = module.params["parent_id"] + force = module.params["force"] + + desired_raw = module.params["overrides"] + desired_overrides = _normalize_overrides({r["key"]: r.get("value") for r in desired_raw}) + + old_overrides = _normalize_overrides(kc.get_localization_values(locale, parent_id) or {}) + before = { + "locale": locale, + "overrides": deepcopy(old_overrides), + } + + # Proposed state used for diff reporting + changeset = { + "locale": locale, + "overrides": [], + } + + result["changed"] = False + + if state == "present": + changeset["overrides"] = deepcopy(desired_overrides) + + # Compute two sets: + # - to_update: keys missing or with different values + # - to_remove: keys existing in current state but not in desired + to_update = [] + to_remove = deepcopy(old_overrides) + + # Mark updates and remove matched ones from to_remove + for record in desired_overrides: + override_found = False + + for override in to_remove: + if override["key"] == record["key"]: + override_found = True + + # Value differs -> update needed + if override["value"] != record["value"]: + result["changed"] = True + to_update.append(record) + + # Remove processed item so what's left in to_remove are deletions + to_remove.remove(override) + break + + if not override_found: + # New key, must be created + to_update.append(record) + result["changed"] = True + + # ignore any left-overs in to_remove, force is false + if not force: + changeset["overrides"].extend(to_remove) + to_remove = [] + + if to_remove: + result["changed"] = True + + if result["changed"]: + if module._diff: + result["diff"] = dict(before=before, after=changeset) + + if module.check_mode: + result["msg"] = f"Locale {locale} overrides would be updated." + + else: + for override in to_remove: + kc.delete_localization_value(locale, override["key"], parent_id) + + for override in to_update: + kc.set_localization_value(locale, override["key"], override["value"], parent_id) + + result["msg"] = f"Locale {locale} overrides have been updated." + + else: + result["msg"] = f"Locale {locale} overrides are in sync." + + # For accurate end_state, read back from API unless we are in check_mode + if not module.check_mode: + final_overrides = _normalize_overrides(kc.get_localization_values(locale, parent_id) or {}) + + else: + final_overrides = ["overrides"] + + result["end_state"] = {"locale": locale, "overrides": final_overrides} + + elif state == "absent": + if force: + to_remove = old_overrides + + else: + # touch only overrides listed in parameters, leave the rest be + to_remove = deepcopy(desired_overrides) + to_keep = deepcopy(old_overrides) + + for override in to_remove: + found = False + for keep in to_keep: + if override["key"] == keep["key"]: + to_keep.remove(keep) + found = True + break + + if not found: + to_remove.remove(override) + + changeset["overrides"] = to_keep + + if to_remove: + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=before, after=changeset) + + if module.check_mode: + if result["changed"]: + result["msg"] = f"{len(to_remove)} overrides for locale {locale} would be deleted." + else: + result["msg"] = f"No overrides for locale {locale} to be deleted." + + else: + for override in to_remove: + kc.delete_localization_value(locale, override["key"], parent_id) + + if result["changed"]: + result["msg"] = f"{len(to_remove)} overrides for locale {locale} deleted." + else: + result["msg"] = f"No overrides for locale {locale} to be deleted." + + result["end_state"] = changeset + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_realm_rolemapping.py b/plugins/modules/keycloak_realm_rolemapping.py new file mode 100644 index 0000000..473aca1 --- /dev/null +++ b/plugins/modules/keycloak_realm_rolemapping.py @@ -0,0 +1,390 @@ + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_realm_rolemapping + +short_description: Allows administration of Keycloak realm role mappings into groups with the Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to add, remove or modify Keycloak realm role mappings into groups with the Keycloak REST API. It + requires access to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite + access rights. In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client + definition with the scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/18.0/rest-api/index.html). + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way + by this module. You may pass single values for attributes when calling the module, and this is translated into a list + suitable for the API. + - When updating a group_rolemapping, where possible provide the role ID to the module. This removes a lookup to the API + to translate the name into the role ID. +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the realm_rolemapping. + - On C(present), the realm_rolemapping is created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the realm_rolemapping is removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this role_representation resides. + default: 'master' + + group_name: + type: str + description: + - Name of the group to be mapped. + - This parameter is required (can be replaced by gid for less API call). + parents: + type: list + description: + - List of parent groups for the group to handle sorted top to bottom. + - Set this if your group is a subgroup and you do not provide the GID in O(gid). + elements: dict + suboptions: + id: + type: str + description: + - Identify parent by ID. + - Needs less API calls than using O(parents[].name). + - A deep parent chain can be started at any point when first given parent is given as ID. + - Note that in principle both ID and name can be specified at the same time but current implementation only always + use just one of them, with ID being preferred. + name: + type: str + description: + - Identify parent by name. + - Needs more internal API calls than using O(parents[].id) to map names to ID's under the hood. + - When giving a parent chain with only names it must be complete up to the top. + - Note that in principle both ID and name can be specified at the same time but current implementation only always + use just one of them, with ID being preferred. + gid: + type: str + description: + - ID of the group to be mapped. + - This parameter is not required for updating or deleting the rolemapping but providing it reduces the number of API + calls required. + roles: + description: + - Roles to be mapped to the group. + type: list + elements: dict + suboptions: + name: + type: str + description: + - Name of the role_representation. + - This parameter is required only when creating or updating the role_representation. + id: + type: str + description: + - The unique identifier for this role_representation. + - This parameter is not required for updating or deleting a role_representation but providing it reduces the number + of API calls required. +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Gaëtan Daubresse (@Gaetan2907) + - Marius Huysamen (@mhuysamen) + - Alexander Groß (@agross) +""" + +EXAMPLES = r""" +- name: Map a client role to a group, authentication with credentials + middleware_automation.keycloak.keycloak_realm_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a group, authentication with token + middleware_automation.keycloak.keycloak_realm_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + state: present + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a subgroup, authentication with token + middleware_automation.keycloak.keycloak_realm_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + state: present + group_name: subgroup1 + parents: + - name: parent-group + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Unmap realm role from a group + middleware_automation.keycloak.keycloak_realm_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: absent + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Role role1 assigned to group group1." + +proposed: + description: Representation of proposed client role mapping. + returned: always + type: dict + sample: {"clientId": "test"} + +existing: + description: + - Representation of existing client role mapping. + - The sample is truncated. + returned: always + type: dict + sample: + { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256" + } + } + +end_state: + description: + - Representation of client role mapping after module execution. + - The sample is truncated. + returned: on success + type: dict + sample: + { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256" + } + } +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + roles_spec = dict( + name=dict(type="str"), + id=dict(type="str"), + ) + + meta_args = dict( + state=dict(default="present", choices=["present", "absent"]), + realm=dict(default="master"), + gid=dict(type="str"), + group_name=dict(type="str"), + parents=dict( + type="list", + elements="dict", + options=dict(id=dict(type="str"), name=dict(type="str")), + ), + roles=dict(type="list", elements="dict", options=roles_spec), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + state = module.params.get("state") + gid = module.params.get("gid") + group_name = module.params.get("group_name") + roles = module.params.get("roles") + parents = module.params.get("parents") + + # Check the parameters + if gid is None and group_name is None: + module.fail_json(msg="Either the `group_name` or `gid` has to be specified.") + + # Get the potential missing parameters + if gid is None: + group_rep = kc.get_group_by_name(group_name, realm=realm, parents=parents) + if group_rep is not None: + gid = group_rep["id"] + else: + module.fail_json(msg=f"Could not fetch group {group_name}:") + else: + group_rep = kc.get_group_by_groupid(gid, realm=realm) + + if roles is None: + module.exit_json(msg="Nothing to do (no roles specified).") + else: + for role in roles: + if role["name"] is None and role["id"] is None: + module.fail_json(msg="Either the `name` or `id` has to be specified on each role.") + # Fetch missing role_id + if role["id"] is None: + role_rep = kc.get_realm_role(role["name"], realm=realm) + if role_rep is not None: + role["id"] = role_rep["id"] + else: + module.fail_json(msg=f"Could not fetch realm role {role['name']} by name:") + # Fetch missing role_name + else: + for realm_role in kc.get_realm_roles(realm=realm): + if realm_role["id"] == role["id"]: + role["name"] = realm_role["name"] + break + + if role["name"] is None: + module.fail_json(msg=f"Could not fetch realm role {role['id']} by ID") + + assigned_roles_before = group_rep.get("realmRoles", []) + + result["existing"] = assigned_roles_before + result["proposed"] = list(assigned_roles_before) if assigned_roles_before else [] + + update_roles = [] + for role in roles: + # Fetch roles to assign if state present + if state == "present": + if any(assigned == role["name"] for assigned in assigned_roles_before): + pass + else: + update_roles.append( + { + "id": role["id"], + "name": role["name"], + } + ) + result["proposed"].append(role["name"]) + # Fetch roles to remove if state absent + else: + if any(assigned == role["name"] for assigned in assigned_roles_before): + update_roles.append( + { + "id": role["id"], + "name": role["name"], + } + ) + if role["name"] in result["proposed"]: # Handle double removal + result["proposed"].remove(role["name"]) + + if len(update_roles): + result["changed"] = True + if module._diff: + result["diff"] = dict(before=assigned_roles_before, after=result["proposed"]) + if module.check_mode: + module.exit_json(**result) + + if state == "present": + # Assign roles + kc.add_group_realm_rolemapping(gid=gid, role_rep=update_roles, realm=realm) + result["msg"] = f"Realm roles {update_roles} assigned to groupId {gid}." + else: + # Remove mapping of role + kc.delete_group_realm_rolemapping(gid=gid, role_rep=update_roles, realm=realm) + result["msg"] = f"Realm roles {update_roles} removed from groupId {gid}." + + if gid is None: + assigned_roles_after = kc.get_group_by_name(group_name, realm=realm, parents=parents).get("realmRoles", []) + else: + assigned_roles_after = kc.get_group_by_groupid(gid, realm=realm).get("realmRoles", []) + result["end_state"] = assigned_roles_after + module.exit_json(**result) + # Do nothing + else: + result["changed"] = False + result["msg"] = ( + f"Nothing to do, roles {roles} are {'mapped' if state == 'present' else 'not mapped'} with group {group_name}." + ) + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_role.py b/plugins/modules/keycloak_role.py index c48e9c9..114c650 100644 --- a/plugins/modules/keycloak_role.py +++ b/plugins/modules/keycloak_role.py @@ -1,135 +1,124 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- # Copyright (c) 2019, Adam Goossens # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later -from __future__ import absolute_import, division, print_function -__metaclass__ = type +from __future__ import annotations -DOCUMENTATION = ''' ---- +DOCUMENTATION = r""" module: keycloak_role -short_description: Allows administration of Keycloak roles via Keycloak API +short_description: Allows administration of Keycloak roles using Keycloak API -version_added: 3.4.0 +version_added: "3.0.0" description: - - This module allows you to add, remove or modify Keycloak roles via the Keycloak REST API. - It requires access to the REST API via OpenID Connect; the user connecting and the client being - used must have the requisite access rights. In a default Keycloak installation, admin-cli - and an admin user would work, as would a separate client definition with the scope tailored - to your needs and a user having the expected roles. - - - The names of module options are snake_cased versions of the camelCase ones found in the - Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html). - - - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will - be returned that way by this module. You may pass single values for attributes when calling the module, - and this will be translated into a list suitable for the API. - + - This module allows you to add, remove or modify Keycloak roles using the Keycloak REST API. It requires access to the + REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. In + a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with the + scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way + by this module. You may pass single values for attributes when calling the module, and this is translated into a list + suitable for the API. attributes: - check_mode: - support: full - diff_mode: - support: full + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" options: - state: - description: - - State of the role. - - On V(present), the role will be created if it does not yet exist, or updated with the parameters you provide. - - On V(absent), the role will be removed if it exists. - default: 'present' - type: str - choices: - - present - - absent + state: + description: + - State of the role. + - On V(present), the role is created if it does not yet exist, or updated with the parameters you provide. + - On V(absent), the role is removed if it exists. + default: 'present' + type: str + choices: + - present + - absent - name: + name: + type: str + required: true + description: + - Name of the role. + - This parameter is required. + description: + type: str + description: + - The role description. + realm: + type: str + description: + - The Keycloak realm under which this role resides. + default: 'master' + + client_id: + type: str + description: + - If the role is a client role, the client ID under which it resides. + - If this parameter is absent, the role is considered a realm role. + attributes: + type: dict + description: + - A dict of key/value pairs to set as custom attributes for the role. + - Values may be single values (for example a string) or a list of strings. + composite: + description: + - If V(true), the role is a composition of other realm and/or client role. + default: false + type: bool + composites: + description: + - List of roles to include to the composite realm role. + - If the composite role is a client role, the C(clientId) (not ID of the client) must be specified. + default: [] + type: list + elements: dict + suboptions: + name: + description: + - Name of the role. This can be the name of a REALM role or a client role. type: str required: true + client_id: description: - - Name of the role. - - This parameter is required. - - description: + - Client ID if the role is a client role. Do not include this option for a REALM role. + - Use the client ID you can see in the Keycloak console, not the technical ID of the client. type: str + aliases: + - clientId + state: description: - - The role description. - - realm: + - Create the composite if present, remove it if absent. type: str - description: - - The Keycloak realm under which this role resides. - default: 'master' - - client_id: - type: str - description: - - If the role is a client role, the client id under which it resides. - - If this parameter is absent, the role is considered a realm role. - - attributes: - type: dict - description: - - A dict of key/value pairs to set as custom attributes for the role. - - Values may be single values (e.g. a string) or a list of strings. - composite: - description: - - If V(true), the role is a composition of other realm and/or client role. - default: false - type: bool - version_added: 7.1.0 - composites: - description: - - List of roles to include to the composite realm role. - - If the composite role is a client role, the C(clientId) (not ID of the client) must be specified. - default: [] - type: list - elements: dict - version_added: 7.1.0 - suboptions: - name: - description: - - Name of the role. This can be the name of a REALM role or a client role. - type: str - required: true - client_id: - description: - - Client ID if the role is a client role. Do not include this option for a REALM role. - - Use the client ID you can see in the Keycloak console, not the technical ID of the client. - type: str - required: false - aliases: - - clientId - state: - description: - - Create the composite if present, remove it if absent. - type: str - choices: - - present - - absent - default: present + choices: + - present + - absent + default: present extends_documentation_fragment: - - middleware_automation.keycloak.keycloak - - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes author: - - Laurent Paumier (@laurpaum) -''' + - Laurent Paumier (@laurpaum) +""" -EXAMPLES = ''' +EXAMPLES = r""" - name: Create a Keycloak realm role, authentication with credentials middleware_automation.keycloak.keycloak_role: name: my-new-kc-role realm: MyCustomRealm state: present auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD @@ -141,7 +130,7 @@ EXAMPLES = ''' realm: MyCustomRealm state: present auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com token: TOKEN delegate_to: localhost @@ -152,7 +141,7 @@ EXAMPLES = ''' client_id: MyClient state: present auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD @@ -163,7 +152,7 @@ EXAMPLES = ''' name: my-role-for-deletion state: absent auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD @@ -172,72 +161,80 @@ EXAMPLES = ''' - name: Create a keycloak role with some custom attributes middleware_automation.keycloak.keycloak_role: auth_client_id: admin-cli - auth_keycloak_url: https://auth.example.com/auth + auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD name: my-new-role attributes: - attrib1: value1 - attrib2: value2 - attrib3: - - with - - numerous - - individual - - list - - items + attrib1: value1 + attrib2: value2 + attrib3: + - with + - numerous + - individual + - list + - items delegate_to: localhost -''' +""" -RETURN = ''' +RETURN = r""" msg: - description: Message as to what action was taken. - returned: always - type: str - sample: "Role myrole has been updated" + description: Message as to what action was taken. + returned: always + type: str + sample: "Role myrole has been updated" proposed: - description: Representation of proposed role. - returned: always - type: dict - sample: { - "description": "My updated test description" - } + description: Representation of proposed role. + returned: always + type: dict + sample: {"description": "My updated test description"} existing: - description: Representation of existing role. - returned: always - type: dict - sample: { - "attributes": {}, - "clientRole": true, - "composite": false, - "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", - "description": "My client test role", - "id": "561703dd-0f38-45ff-9a5a-0c978f794547", - "name": "myrole" + description: Representation of existing role. + returned: always + type: dict + sample: + { + "attributes": {}, + "clientRole": true, + "composite": false, + "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", + "description": "My client test role", + "id": "561703dd-0f38-45ff-9a5a-0c978f794547", + "name": "myrole" } end_state: - description: Representation of role after module execution (sample is truncated). - returned: on success - type: dict - sample: { - "attributes": {}, - "clientRole": true, - "composite": false, - "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", - "description": "My updated client test role", - "id": "561703dd-0f38-45ff-9a5a-0c978f794547", - "name": "myrole" + description: Representation of role after module execution (sample is truncated). + returned: on success + type: dict + sample: + { + "attributes": {}, + "clientRole": true, + "composite": false, + "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", + "description": "My updated client test role", + "id": "561703dd-0f38-45ff-9a5a-0c978f794547", + "name": "myrole" } -''' +""" -from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, get_token, KeycloakError, is_struct_included -from ansible.module_utils.basic import AnsibleModule import copy +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + is_struct_included, + keycloak_argument_spec, +) + def main(): """ @@ -248,30 +245,35 @@ def main(): argument_spec = keycloak_argument_spec() composites_spec = dict( - name=dict(type='str', required=True), - client_id=dict(type='str', aliases=['clientId'], required=False), - state=dict(type='str', default='present', choices=['present', 'absent']) + name=dict(type="str", required=True), + client_id=dict(type="str", aliases=["clientId"]), + state=dict(type="str", default="present", choices=["present", "absent"]), ) meta_args = dict( - state=dict(type='str', default='present', choices=['present', 'absent']), - name=dict(type='str', required=True), - description=dict(type='str'), - realm=dict(type='str', default='master'), - client_id=dict(type='str'), - attributes=dict(type='dict'), - composites=dict(type='list', default=[], options=composites_spec, elements='dict'), - composite=dict(type='bool', default=False), + state=dict(type="str", default="present", choices=["present", "absent"]), + name=dict(type="str", required=True), + description=dict(type="str"), + realm=dict(type="str", default="master"), + client_id=dict(type="str"), + attributes=dict(type="dict"), + composites=dict(type="list", default=[], options=composites_spec, elements="dict"), + composite=dict(type="bool", default=False), ) argument_spec.update(meta_args) - module = AnsibleModule(argument_spec=argument_spec, - supports_check_mode=True, - required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), - required_together=([['auth_realm', 'auth_username', 'auth_password']])) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) - result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API try: @@ -281,22 +283,25 @@ def main(): kc = KeycloakAPI(module, connection_header) - realm = module.params.get('realm') - clientid = module.params.get('client_id') - name = module.params.get('name') - state = module.params.get('state') + realm = module.params.get("realm") + clientid = module.params.get("client_id") + name = module.params.get("name") + state = module.params.get("state") # attributes in Keycloak have their values returned as lists - # via the API. attributes is a dict, so we'll transparently convert + # using the API. attributes is a dict, so we'll transparently convert # the values to lists. - if module.params.get('attributes') is not None: - for key, val in module.params['attributes'].items(): - module.params['attributes'][key] = [val] if not isinstance(val, list) else val + if module.params.get("attributes") is not None: + for key, val in module.params["attributes"].items(): + module.params["attributes"][key] = [val] if not isinstance(val, list) else val # Filter and map the parameters names that apply to the role - role_params = [x for x in module.params - if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'client_id'] and - module.params.get(x) is not None] + role_params = [ + x + for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ["state", "realm", "client_id"] + and module.params.get(x) is not None + ] # See if it already exists in Keycloak if clientid is None: @@ -320,28 +325,28 @@ def main(): desired_role = copy.deepcopy(before_role) desired_role.update(changeset) - result['proposed'] = changeset - result['existing'] = before_role + result["proposed"] = changeset + result["existing"] = before_role # Cater for when it doesn't exist (an empty dict) if not before_role: - if state == 'absent': + if state == "absent": # Do nothing and exit if module._diff: - result['diff'] = dict(before='', after='') - result['changed'] = False - result['end_state'] = {} - result['msg'] = 'Role does not exist, doing nothing.' + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = "Role does not exist, doing nothing." module.exit_json(**result) # Process a creation - result['changed'] = True + result["changed"] = True if name is None: - module.fail_json(msg='name must be specified when creating a new role') + module.fail_json(msg="name must be specified when creating a new role") if module._diff: - result['diff'] = dict(before='', after=desired_role) + result["diff"] = dict(before="", after=desired_role) if module.check_mode: module.exit_json(**result) @@ -354,45 +359,49 @@ def main(): kc.create_client_role(desired_role, clientid, realm) after_role = kc.get_client_role(name, clientid, realm) - if after_role['composite']: - after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) + if after_role["composite"]: + after_role["composites"] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) - result['end_state'] = after_role + result["end_state"] = after_role - result['msg'] = 'Role {name} has been created'.format(name=name) + result["msg"] = f"Role {name} has been created" module.exit_json(**result) else: - if state == 'present': - compare_exclude = [] - if 'composites' in desired_role and isinstance(desired_role['composites'], list) and len(desired_role['composites']) > 0: + if state == "present": + compare_exclude = ["clientId"] + if ( + "composites" in desired_role + and isinstance(desired_role["composites"], list) + and len(desired_role["composites"]) > 0 + ): composites = kc.get_role_composites(rolerep=before_role, clientid=clientid, realm=realm) - before_role['composites'] = [] + before_role["composites"] = [] for composite in composites: before_composite = {} - if composite['clientRole']: - composite_client = kc.get_client_by_id(id=composite['containerId'], realm=realm) - before_composite['client_id'] = composite_client['clientId'] + if composite["clientRole"]: + composite_client = kc.get_client_by_id(id=composite["containerId"], realm=realm) + before_composite["client_id"] = composite_client["clientId"] else: - before_composite['client_id'] = None - before_composite['name'] = composite['name'] - before_composite['state'] = 'present' - before_role['composites'].append(before_composite) + before_composite["client_id"] = None + before_composite["name"] = composite["name"] + before_composite["state"] = "present" + before_role["composites"].append(before_composite) else: - compare_exclude.append('composites') + compare_exclude.append("composites") # Process an update # no changes if is_struct_included(desired_role, before_role, exclude=compare_exclude): - result['changed'] = False - result['end_state'] = desired_role - result['msg'] = "No changes required to role {name}.".format(name=name) + result["changed"] = False + result["end_state"] = desired_role + result["msg"] = f"No changes required to role {name}." module.exit_json(**result) # doing an update - result['changed'] = True + result["changed"] = True if module._diff: - result['diff'] = dict(before=before_role, after=desired_role) + result["diff"] = dict(before=before_role, after=desired_role) if module.check_mode: module.exit_json(**result) @@ -404,20 +413,20 @@ def main(): else: kc.update_client_role(desired_role, clientid, realm) after_role = kc.get_client_role(name, clientid, realm) - if after_role['composite']: - after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) + if after_role["composite"]: + after_role["composites"] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) - result['end_state'] = after_role + result["end_state"] = after_role - result['msg'] = "Role {name} has been updated".format(name=name) + result["msg"] = f"Role {name} has been updated" module.exit_json(**result) else: # Process a deletion (because state was not 'present') - result['changed'] = True + result["changed"] = True if module._diff: - result['diff'] = dict(before=before_role, after='') + result["diff"] = dict(before=before_role, after="") if module.check_mode: module.exit_json(**result) @@ -428,12 +437,12 @@ def main(): else: kc.delete_client_role(name, clientid, realm) - result['end_state'] = {} + result["end_state"] = {} - result['msg'] = "Role {name} has been deleted".format(name=name) + result["msg"] = f"Role {name} has been deleted" module.exit_json(**result) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/plugins/modules/keycloak_user.py b/plugins/modules/keycloak_user.py new file mode 100644 index 0000000..67dcc04 --- /dev/null +++ b/plugins/modules/keycloak_user.py @@ -0,0 +1,561 @@ + +# Copyright (c) 2019, INSPQ (@elfelip) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_user +short_description: Create and configure a user in Keycloak +description: + - This module creates, removes, or updates Keycloak users. +version_added: "3.0.0" +options: + auth_username: + aliases: [] + realm: + description: + - The name of the realm in which is the client. + default: master + type: str + username: + description: + - Username for the user. + required: true + type: str + id: + description: + - ID of the user on the Keycloak server if known. + type: str + enabled: + description: + - Enabled user. + type: bool + email_verified: + description: + - Check the validity of user email. + default: false + type: bool + aliases: + - emailVerified + first_name: + description: + - The user's first name. + type: str + aliases: + - firstName + last_name: + description: + - The user's last name. + type: str + aliases: + - lastName + email: + description: + - User email. + type: str + federation_link: + description: + - Federation Link. + type: str + aliases: + - federationLink + service_account_client_id: + description: + - Description of the client Application. + type: str + aliases: + - serviceAccountClientId + client_consents: + description: + - Client Authenticator Type. + type: list + elements: dict + default: [] + aliases: + - clientConsents + suboptions: + client_id: + description: + - Client ID of the client role. Not the technical ID of the client. + type: str + required: true + aliases: + - clientId + roles: + description: + - List of client roles to assign to the user. + type: list + required: true + elements: str + groups: + description: + - List of groups for the user. + - Groups can be referenced by their name, like V(staff), or their path, like V(/staff/engineering). The path syntax + allows you to reference subgroups, which is not possible otherwise. + - Using the path is possible since middleware_automation.keycloak 3.0.0. + type: list + elements: dict + default: [] + suboptions: + name: + description: + - Name of the group. + type: str + state: + description: + - Control whether the user must be member of this group or not. + choices: ["present", "absent"] + default: present + type: str + credentials: + description: + - User credentials. + default: [] + type: list + elements: dict + suboptions: + type: + description: + - Credential type. + type: str + required: true + value: + description: + - Value of the credential. + type: str + required: true + temporary: + description: + - If V(true), the users are required to reset their credentials at next login. + type: bool + default: false + required_actions: + description: + - RequiredActions user Auth. + default: [] + type: list + elements: str + aliases: + - requiredActions + federated_identities: + description: + - List of IDPs of user. + default: [] + type: list + elements: str + aliases: + - federatedIdentities + attributes: + description: + - List of user attributes. + type: list + elements: dict + suboptions: + name: + description: + - Name of the attribute. + type: str + values: + description: + - Values for the attribute as list. + type: list + elements: str + state: + description: + - Control whether the attribute must exists or not. + choices: ["present", "absent"] + default: present + type: str + access: + description: + - List user access. + type: dict + disableable_credential_types: + description: + - List user Credential Type. + default: [] + type: list + elements: str + aliases: + - disableableCredentialTypes + origin: + description: + - User origin. + type: str + self: + description: + - User self administration. + type: str + state: + description: + - Control whether the user should exists or not. + choices: ["present", "absent"] + default: present + type: str + force: + description: + - If V(true), allows to remove user and recreate it. + type: bool + default: false +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" +notes: + - The module does not modify the user ID of an existing user. +author: + - Philippe Gauthier (@elfelip) +""" + +EXAMPLES = r""" +- name: Create a user user1 + middleware_automation.keycloak.keycloak_user: + auth_keycloak_url: http://localhost:8080 + auth_username: admin + auth_password: password + realm: master + username: user1 + firstName: user1 + lastName: user1 + email: user1 + enabled: true + emailVerified: false + credentials: + - type: password + value: password + temporary: false + attributes: + - name: attr1 + values: + - value1 + state: present + - name: attr2 + values: + - value2 + state: absent + groups: + - name: group1 + state: present + state: present + +- name: Re-create a User + middleware_automation.keycloak.keycloak_user: + auth_keycloak_url: http://localhost:8080 + auth_username: admin + auth_password: password + realm: master + username: user1 + firstName: user1 + lastName: user1 + email: user1 + enabled: true + emailVerified: false + credentials: + - type: password + value: password + temporary: false + attributes: + - name: attr1 + values: + - value1 + state: present + - name: attr2 + values: + - value2 + state: absent + groups: + - name: group1 + state: present + state: present + +- name: Re-create a User + middleware_automation.keycloak.keycloak_user: + auth_keycloak_url: http://localhost:8080 + auth_username: admin + auth_password: password + realm: master + username: user1 + firstName: user1 + lastName: user1 + email: user1 + enabled: true + emailVerified: false + credentials: + - type: password + value: password + temporary: false + attributes: + - name: attr1 + values: + - value1 + state: present + - name: attr2 + values: + - value2 + state: absent + groups: + - name: group1 + state: present + state: present + force: true + +- name: Remove User + middleware_automation.keycloak.keycloak_user: + auth_keycloak_url: http://localhost:8080 + auth_username: admin + auth_password: password + realm: master + username: user1 + state: absent +""" + +RETURN = r""" +proposed: + description: Representation of the proposed user. + returned: on success + type: dict +existing: + description: Representation of the existing user. + returned: on success + type: dict +end_state: + description: Representation of the user after module execution. + returned: on success + type: dict +user_created: + description: Indicates whether a user was created. + returned: in success + type: bool +""" + +import copy + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + is_struct_included, + keycloak_argument_spec, +) + + +def main(): + argument_spec = keycloak_argument_spec() + argument_spec["auth_username"]["aliases"] = [] + credential_spec = dict( + type=dict(type="str", required=True), + value=dict(type="str", required=True, no_log=True), + temporary=dict(type="bool", default=False), + ) + client_consents_spec = dict( + client_id=dict(type="str", required=True, aliases=["clientId"]), + roles=dict(type="list", elements="str", required=True), + ) + attributes_spec = dict( + name=dict(type="str"), + values=dict(type="list", elements="str"), + state=dict(type="str", choices=["present", "absent"], default="present"), + ) + groups_spec = dict(name=dict(type="str"), state=dict(type="str", choices=["present", "absent"], default="present")) + meta_args = dict( + realm=dict(type="str", default="master"), + self=dict(type="str"), + id=dict(type="str"), + username=dict(type="str", required=True), + first_name=dict(type="str", aliases=["firstName"]), + last_name=dict(type="str", aliases=["lastName"]), + email=dict(type="str"), + enabled=dict(type="bool"), + email_verified=dict(type="bool", default=False, aliases=["emailVerified"]), + federation_link=dict(type="str", aliases=["federationLink"]), + service_account_client_id=dict(type="str", aliases=["serviceAccountClientId"]), + attributes=dict(type="list", elements="dict", options=attributes_spec), + access=dict(type="dict"), + groups=dict(type="list", default=[], elements="dict", options=groups_spec), + disableable_credential_types=dict( + type="list", default=[], aliases=["disableableCredentialTypes"], elements="str" + ), + required_actions=dict(type="list", default=[], aliases=["requiredActions"], elements="str"), + credentials=dict(type="list", default=[], elements="dict", options=credential_spec), + federated_identities=dict(type="list", default=[], aliases=["federatedIdentities"], elements="str"), + client_consents=dict( + type="list", default=[], aliases=["clientConsents"], elements="dict", options=client_consents_spec + ), + origin=dict(type="str"), + state=dict(choices=["absent", "present"], default="present"), + force=dict(type="bool", default=False), + ) + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + state = module.params.get("state") + force = module.params.get("force") + username = module.params.get("username") + groups = module.params.get("groups") + + # Filter and map the parameters names that apply to the user + user_params = [ + x + for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ["state", "realm", "force", "groups"] + and module.params.get(x) is not None + ] + + before_user = kc.get_user_by_username(username=username, realm=realm) + + if before_user is None: + before_user = {} + + changeset = {} + + for param in user_params: + new_param_value = module.params.get(param) + if param == "attributes" and param in before_user: + old_value = kc.convert_keycloak_user_attributes_dict_to_module_list(attributes=before_user["attributes"]) + else: + old_value = before_user[param] if param in before_user else None + if new_param_value != old_value: + if old_value is not None and param == "attributes": + for old_attribute in old_value: + old_attribute_found = False + for new_attribute in new_param_value: + if new_attribute["name"] == old_attribute["name"]: + old_attribute_found = True + if not old_attribute_found: + new_param_value.append(copy.deepcopy(old_attribute)) + if isinstance(new_param_value, dict): + changeset[camel(param)] = copy.deepcopy(new_param_value) + else: + changeset[camel(param)] = new_param_value + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) + desired_user = copy.deepcopy(before_user) + desired_user.update(changeset) + + result["proposed"] = changeset + result["existing"] = before_user + # Default values for user_created + result["user_created"] = False + changed = False + + # Cater for when it doesn't exist (an empty dict) + if state == "absent": + if not before_user: + # Do nothing and exit + if module._diff: + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = "Role does not exist, doing nothing." + module.exit_json(**result) + else: + # Delete user + kc.delete_user(user_id=before_user["id"], realm=realm) + result["msg"] = f"User {before_user['username']} deleted" + changed = True + + else: + after_user = {} + if force and before_user: # If the force option is set to true + # Delete the existing user + kc.delete_user(user_id=before_user["id"], realm=realm) + + if not before_user or force: + # Process a creation + changed = True + + if username is None: + module.fail_json(msg="username must be specified when creating a new user") + + if module._diff: + result["diff"] = dict(before="", after=desired_user) + + if module.check_mode: + # Set user_created flag explicit for check_mode + # create_user could have failed, but we don't know for sure until we try to create the user.' + result["user_created"] = True + module.exit_json(**result) + + # Create the user + after_user = kc.create_user(userrep=desired_user, realm=realm) + if after_user is None: + module.fail_json( + msg=f"User {desired_user['username']} was created in realm {realm} but could not be retrieved", + ) + result["msg"] = f"User {desired_user['username']} created" + # Add user ID to new representation + desired_user["id"] = after_user["id"] + # Set user_created flag + result["user_created"] = True + else: + excludes = [ + "access", + "notBefore", + "createdTimestamp", + "totp", + "credentials", + "disableableCredentialTypes", + "groups", + "clientConsents", + "federatedIdentities", + "requiredActions", + ] + # Add user ID to new representation + desired_user["id"] = before_user["id"] + + # Compare users + if not ( + is_struct_included(desired_user, before_user, excludes) + ): # If the new user does not introduce a change to the existing user + # Update the user + after_user = kc.update_user(userrep=desired_user, realm=realm) + changed = True + + # set user groups + if kc.update_user_groups_membership(userrep=desired_user, groups=groups, realm=realm): + changed = True + # Get the user groups + after_user["groups"] = kc.get_user_groups(user_id=desired_user["id"], realm=realm) + result["end_state"] = after_user + if changed: + result["msg"] = f"User {desired_user['username']} updated" + else: + result["msg"] = f"No changes made for user {desired_user['username']}" + + result["changed"] = changed + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_user_execute_actions_email.py b/plugins/modules/keycloak_user_execute_actions_email.py new file mode 100644 index 0000000..9a8765a --- /dev/null +++ b/plugins/modules/keycloak_user_execute_actions_email.py @@ -0,0 +1,204 @@ + +# Copyright (c) 2025, mariusbertram +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_user_execute_actions_email + +short_description: Send a Keycloak execute-actions email to a user + +version_added: "3.0.0" + +description: + - Triggers the Keycloak endpoint C(execute-actions-email) for a user. + This sends an email with one or more required actions the user must complete (for example resetting the password). + - If no O(actions) list is provided, the default action C(UPDATE_PASSWORD) is used. + - You must supply either the user's O(id) or O(username). Supplying only C(username) causes an extra lookup call. + - This module always reports RV(ignore:changed=true) because sending an email is a side effect and cannot be made idempotent. +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + auth_username: + aliases: [] + realm: + description: + - The Keycloak realm where the user resides. + type: str + default: master + id: + description: + - The unique ID (UUID) of the user. + - Mutually exclusive with O(username). + type: str + username: + description: + - Username of the user. + - Mutually exclusive with O(id). + type: str + actions: + description: + - List of required actions to include in the email. + type: list + elements: str + default: + - UPDATE_PASSWORD + client_id: + description: + - Optional client ID used for the redirect link. + aliases: [clientId] + type: str + redirect_uri: + description: + - Optional redirect URI. Must be valid for the given client if O(client_id) is set. + aliases: [redirectUri] + type: str + lifespan: + description: + - Optional lifespan (in seconds) for the action token (supported on newer Keycloak versions). Forwarded as query parameter if provided. + type: int +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes +author: + - Marius Bertram (@mariusbertram) +""" + +EXAMPLES = r""" +- name: Password reset email (default action) with 1h lifespan + middleware_automation.keycloak.keycloak_user_execute_actions_email: + username: johndoe + realm: MyRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: ADMIN + auth_password: SECRET + lifespan: 3600 + delegate_to: localhost + +- name: Multiple required actions using token auth + middleware_automation.keycloak.keycloak_user_execute_actions_email: + username: johndoe + actions: + - UPDATE_PASSWORD + - VERIFY_EMAIL + realm: MyRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + delegate_to: localhost + +- name: Email by user id with redirect + middleware_automation.keycloak.keycloak_user_execute_actions_email: + id: 9d59aa76-2755-48c6-b1af-beb70a82c3cd + client_id: my-frontend + redirect_uri: https://app.example.com/post-actions + actions: + - UPDATE_PASSWORD + realm: MyRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: ADMIN + auth_password: SECRET + delegate_to: localhost +""" + +RETURN = r""" +user_id: + description: The user ID the email was (or would be, in check mode) sent to. + returned: success + type: str +actions: + description: List of actions included in the email. + returned: success + type: list + elements: str +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + argument_spec = keycloak_argument_spec() + # Avoid alias collision as in keycloak_user: clear auth_username aliases locally + argument_spec["auth_username"]["aliases"] = [] + + meta_args = dict( + realm=dict(type="str", default="master"), + id=dict(type="str"), + username=dict(type="str"), + actions=dict(type="list", elements="str", default=["UPDATE_PASSWORD"]), + client_id=dict(type="str", aliases=["clientId"]), + redirect_uri=dict(type="str", aliases=["redirectUri"]), + lifespan=dict(type="int"), + ) + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[["id", "username"]], + mutually_exclusive=[["id", "username"]], + ) + + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + user_id = module.params.get("id") + username = module.params.get("username") + actions = module.params.get("actions") + client_id = module.params.get("client_id") + redirect_uri = module.params.get("redirect_uri") + lifespan = module.params.get("lifespan") + + # Resolve user ID if only username is provided + if user_id is None: + user_obj = kc.get_user_by_username(username=username, realm=realm) + if user_obj is None: + module.fail_json(msg=f"User '{username}' not found in realm {realm}") + user_id = user_obj["id"] + + if module.check_mode: + module.exit_json( + changed=True, msg=f"Would send execute-actions email to user {user_id}", user_id=user_id, actions=actions + ) + + try: + kc.send_execute_actions_email( + user_id=user_id, + realm=realm, + client_id=client_id, + data=actions, + redirect_uri=redirect_uri, + lifespan=lifespan, + ) + except Exception as e: + module.fail_json(msg=str(e)) + + module.exit_json( + changed=True, msg=f"Execute-actions email sent to user {user_id}", user_id=user_id, actions=actions + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_user_federation.py b/plugins/modules/keycloak_user_federation.py index 864cfbc..8fbc7d1 100644 --- a/plugins/modules/keycloak_user_federation.py +++ b/plugins/modules/keycloak_user_federation.py @@ -1,523 +1,496 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- # Copyright (c) Ansible project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later -from __future__ import absolute_import, division, print_function -__metaclass__ = type +from __future__ import annotations -DOCUMENTATION = ''' ---- +DOCUMENTATION = r""" module: keycloak_user_federation -short_description: Allows administration of Keycloak user federations via Keycloak API +short_description: Allows administration of Keycloak user federations using Keycloak API -version_added: 3.7.0 +version_added: "3.0.0" description: - - This module allows you to add, remove or modify Keycloak user federations via the Keycloak REST API. - It requires access to the REST API via OpenID Connect; the user connecting and the client being - used must have the requisite access rights. In a default Keycloak installation, admin-cli - and an admin user would work, as would a separate client definition with the scope tailored - to your needs and a user having the expected roles. - - - The names of module options are snake_cased versions of the camelCase ones found in the - Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/20.0.2/rest-api/index.html). - + - This module allows you to add, remove or modify Keycloak user federations using the Keycloak REST API. It requires access + to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. + In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with + the scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/20.0.2/rest-api/index.html). attributes: - check_mode: - support: full - diff_mode: - support: full + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" options: - state: - description: - - State of the user federation. - - On V(present), the user federation will be created if it does not yet exist, or updated with - the parameters you provide. - - On V(absent), the user federation will be removed if it exists. - default: 'present' - type: str - choices: - - present - - absent + state: + description: + - State of the user federation. + - On V(present), the user federation is created if it does not yet exist, or updated with the parameters you provide. + - On V(absent), the user federation is removed if it exists. + default: 'present' + type: str + choices: + - present + - absent - realm: - description: - - The Keycloak realm under which this user federation resides. - default: 'master' - type: str + realm: + description: + - The Keycloak realm under which this user federation resides. + default: 'master' + type: str - id: - description: - - The unique ID for this user federation. If left empty, the user federation will be searched - by its O(name). - type: str + id: + description: + - The unique ID for this user federation. If left empty, the user federation is searched by its O(name). + type: str - name: - description: - - Display name of provider when linked in admin console. - type: str + name: + description: + - Display name of provider when linked in admin console. + type: str - provider_id: - description: - - Provider for this user federation. Built-in providers are V(ldap), V(kerberos), and V(sssd). - Custom user storage providers can also be used. - aliases: - - providerId - type: str + provider_id: + description: + - Provider for this user federation. Built-in providers are V(ldap), V(kerberos), and V(sssd). Custom user storage providers + can also be used. + aliases: + - providerId + type: str - provider_type: - description: - - Component type for user federation (only supported value is V(org.keycloak.storage.UserStorageProvider)). - aliases: - - providerType - default: org.keycloak.storage.UserStorageProvider - type: str + provider_type: + description: + - Component type for user federation (only supported value is V(org.keycloak.storage.UserStorageProvider)). + aliases: + - providerType + default: org.keycloak.storage.UserStorageProvider + type: str - parent_id: - description: - - Unique ID for the parent of this user federation. Realm ID will be automatically used if left blank. - aliases: - - parentId - type: str + parent_id: + description: + - Unique ID for the parent of this user federation. Realm ID is automatically used if left blank. + aliases: + - parentId + type: str - remove_unspecified_mappers: + remove_unspecified_mappers: + description: + - Remove mappers that are not specified in the configuration for this federation. + - Set to V(false) to keep mappers that are not listed in O(mappers). + type: bool + default: true + + bind_credential_update_mode: + description: + - The value of the config parameter O(config.bindCredential) is redacted in the Keycloak responses. Comparing the redacted + value with the desired value always evaluates to not equal. This means the before and desired states are never equal + if the parameter is set. + - Set to V(always) to include O(config.bindCredential) in the comparison of before and desired state. Because of the + redacted value returned by Keycloak the module always detects a change and make an update if a O(config.bindCredential) + value is set. + - Set to V(only_indirect) to exclude O(config.bindCredential) when comparing the before state with the desired state. + The value of O(config.bindCredential) is only updated if there are other changes to the user federation that require + an update. + type: str + default: always + choices: + - always + - only_indirect + + config: + description: + - Dict specifying the configuration options for the provider; the contents differ depending on the value of O(provider_id). + Examples are given below for V(ldap), V(kerberos) and V(sssd). It is easiest to obtain valid config values by dumping + an already-existing user federation configuration through check-mode in the RV(existing) field. + - The value V(sssd) has been supported since middleware_automation.keycloak 4.2.0. + type: dict + suboptions: + enabled: description: - - Remove mappers that are not specified in the configuration for this federation. - - Set to V(false) to keep mappers that are not listed in O(mappers). - type: bool + - Enable/disable this user federation. default: true + type: bool - bind_credential_update_mode: + priority: description: - - The value of the config parameter O(config.bindCredential) is redacted in the Keycloak responses. - Comparing the redacted value with the desired value always evaluates to not equal. This means - the before and desired states are never equal if the parameter is set. - - Set to V(always) to include O(config.bindCredential) in the comparison of before and desired state. - Because of the redacted value returned by Keycloak the module will always detect a change - and make an update if a O(config.bindCredential) value is set. - - Set to V(only_indirect) to exclude O(config.bindCredential) when comparing the before state with the - desired state. The value of O(config.bindCredential) will only be updated if there are other changes - to the user federation that require an update. + - Priority of provider when doing a user lookup. Lowest first. + default: 0 + type: int + + importEnabled: + description: + - If V(true), LDAP users are imported into Keycloak DB and synced by the configured sync policies. + default: true + type: bool + + editMode: + description: + - V(READ_ONLY) is a read-only LDAP store. V(WRITABLE) means data is synced back to LDAP on demand. V(UNSYNCED) means + user data is imported, but not synced back to LDAP. type: str - default: always choices: - - always - - only_indirect + - READ_ONLY + - WRITABLE + - UNSYNCED - config: + syncRegistrations: description: - - Dict specifying the configuration options for the provider; the contents differ depending on - the value of O(provider_id). Examples are given below for V(ldap), V(kerberos) and V(sssd). - It is easiest to obtain valid config values by dumping an already-existing user federation - configuration through check-mode in the RV(existing) field. - - The value V(sssd) has been supported since middleware_automation.keycloak 2.0.0. + - Should newly created users be created within LDAP store? Priority effects which provider is chosen to sync the + new user. + default: false + type: bool + + vendor: + description: + - LDAP vendor (provider). + - Use short name. For instance, write V(rhds) for "Red Hat Directory Server". + type: str + + usernameLDAPAttribute: + description: + - Name of LDAP attribute, which is mapped as Keycloak username. For many LDAP server vendors it can be V(uid). For + Active directory it can be V(sAMAccountName) or V(cn). The attribute should be filled for all LDAP user records + you want to import from LDAP to Keycloak. + type: str + + rdnLDAPAttribute: + description: + - Name of LDAP attribute, which is used as RDN (top attribute) of typical user DN. Usually it is the same as Username + LDAP attribute, however it is not required. For example for Active directory, it is common to use V(cn) as RDN + attribute when username attribute might be V(sAMAccountName). + type: str + + uuidLDAPAttribute: + description: + - Name of LDAP attribute, which is used as unique object identifier (UUID) for objects in LDAP. For many LDAP server + vendors, it is V(entryUUID); however some are different. For example for Active directory it should be V(objectGUID). + If your LDAP server does not support the notion of UUID, you can use any other attribute that is supposed to be + unique among LDAP users in tree. + type: str + + userObjectClasses: + description: + - All values of LDAP objectClass attribute for users in LDAP divided by comma. For example V(inetOrgPerson, organizationalPerson). + Newly created Keycloak users are written to LDAP with all those object classes and existing LDAP user records + are found just if they contain all those object classes. + type: str + + connectionUrl: + description: + - Connection URL to your LDAP server. + type: str + + usersDn: + description: + - Full DN of LDAP tree where your users are. This DN is the parent of LDAP users. + type: str + + customUserSearchFilter: + description: + - Additional LDAP Filter for filtering searched users. Leave this empty if you do not need additional filter. + type: str + + searchScope: + description: + - For one level, the search applies only for users in the DNs specified by User DNs. For subtree, the search applies + to the whole subtree. See LDAP documentation for more details. + default: '1' + type: str + choices: + - '1' + - '2' + + authType: + description: + - Type of the Authentication method used during LDAP Bind operation. It is used in most of the requests sent to + the LDAP server. + default: 'none' + type: str + choices: + - none + - simple + + bindDn: + description: + - DN of LDAP user which is used by Keycloak to access LDAP server. + type: str + + bindCredential: + description: + - Password of LDAP admin. + type: str + + startTls: + description: + - Encrypts the connection to LDAP using STARTTLS, which disables connection pooling. + default: false + type: bool + + usePasswordModifyExtendedOp: + description: + - Use the LDAPv3 Password Modify Extended Operation (RFC-3062). The password modify extended operation usually requires + that LDAP user already has password in the LDAP server. So when this is used with 'Sync Registrations', it can + be good to add also 'Hardcoded LDAP attribute mapper' with randomly generated initial password. + default: false + type: bool + + validatePasswordPolicy: + description: + - Determines if Keycloak should validate the password with the realm password policy before updating it. + default: false + type: bool + + trustEmail: + description: + - If enabled, email provided by this provider is not verified even if verification is enabled for the realm. + default: false + type: bool + + useTruststoreSpi: + description: + - Specifies whether LDAP connection uses the truststore SPI with the truststore configured in standalone.xml/domain.xml. + V(always) means that it always uses it. V(never) means that it does not use it. V(ldapsOnly) means that it uses + if your connection URL use ldaps. + - Note even if standalone.xml/domain.xml is not configured, the default Java cacerts or certificate specified by + C(javax.net.ssl.trustStore) property is used. + default: ldapsOnly + type: str + choices: + - always + - ldapsOnly + - never + + connectionTimeout: + description: + - LDAP Connection Timeout in milliseconds. + type: int + + readTimeout: + description: + - LDAP Read Timeout in milliseconds. This timeout applies for LDAP read operations. + type: int + + pagination: + description: + - Does the LDAP server support pagination. + default: true + type: bool + + connectionPooling: + description: + - Determines if Keycloak should use connection pooling for accessing LDAP server. + default: true + type: bool + + connectionPoolingAuthentication: + description: + - A list of space-separated authentication types of connections that may be pooled. + type: str + choices: + - none + - simple + - DIGEST-MD5 + + connectionPoolingDebug: + description: + - A string that indicates the level of debug output to produce. Example valid values are V(fine) (trace connection + creation and removal) and V(all) (all debugging information). + type: str + + connectionPoolingInitSize: + description: + - The number of connections per connection identity to create when initially creating a connection for the identity. + type: int + + connectionPoolingMaxSize: + description: + - The maximum number of connections per connection identity that can be maintained concurrently. + type: int + + connectionPoolingPrefSize: + description: + - The preferred number of connections per connection identity that should be maintained concurrently. + type: int + + connectionPoolingProtocol: + description: + - A list of space-separated protocol types of connections that may be pooled. Valid types are V(plain) and V(ssl). + type: str + + connectionPoolingTimeout: + description: + - The number of milliseconds that an idle connection may remain in the pool without being closed and removed from + the pool. + type: int + + allowKerberosAuthentication: + description: + - Enable/disable HTTP authentication of users with SPNEGO/Kerberos tokens. The data about authenticated users is + provisioned from this LDAP server. + default: false + type: bool + + kerberosRealm: + description: + - Name of kerberos realm. + type: str + + krbPrincipalAttribute: + description: + - Name of the LDAP attribute, which refers to Kerberos principal. This is used to lookup appropriate LDAP user after + successful Kerberos/SPNEGO authentication in Keycloak. When this is empty, the LDAP user is looked up based on + LDAP username corresponding to the first part of his Kerberos principal. For instance, for principal C(john@KEYCLOAK.ORG), + it assumes that LDAP username is V(john). + type: str + + serverPrincipal: + description: + - Full name of server principal for HTTP service including server and domain name. For example V(HTTP/host.foo.org@FOO.ORG). + Use V(*) to accept any service principal in the KeyTab file. + type: str + + keyTab: + description: + - Location of Kerberos KeyTab file containing the credentials of server principal. For example V(/etc/krb5.keytab). + type: str + + debug: + description: + - Enable/disable debug logging to standard output for Krb5LoginModule. + type: bool + + useKerberosForPasswordAuthentication: + description: + - Use Kerberos login module for authenticate username/password against Kerberos server instead of authenticating + against LDAP server with Directory Service API. + default: false + type: bool + + allowPasswordAuthentication: + description: + - Enable/disable possibility of username/password authentication against Kerberos database. + type: bool + + batchSizeForSync: + description: + - Count of LDAP users to be imported from LDAP to Keycloak within a single transaction. + default: 1000 + type: int + + fullSyncPeriod: + description: + - Period for full synchronization in seconds. + default: -1 + type: int + + changedSyncPeriod: + description: + - Period for synchronization of changed or newly created LDAP users in seconds. + default: -1 + type: int + + updateProfileFirstLogin: + description: + - Update profile on first login. + type: bool + + cachePolicy: + description: + - Cache Policy for this storage provider. + type: str + default: 'DEFAULT' + choices: + - DEFAULT + - EVICT_DAILY + - EVICT_WEEKLY + - MAX_LIFESPAN + - NO_CACHE + + evictionDay: + description: + - Day of the week the entry is set to become invalid on. + type: str + + evictionHour: + description: + - Hour of day the entry is set to become invalid on. + type: str + + evictionMinute: + description: + - Minute of day the entry is set to become invalid on. + type: str + + maxLifespan: + description: + - Max lifespan of cache entry in milliseconds. + type: int + + referral: + description: + - Specifies if LDAP referrals should be followed or ignored. Please note that enabling referrals can slow down authentication + as it allows the LDAP server to decide which other LDAP servers to use. This could potentially include untrusted + servers. + type: str + choices: + - ignore + - follow + + mappers: + description: + - A list of dicts defining mappers associated with this Identity Provider. + type: list + elements: dict + suboptions: + id: + description: + - Unique ID of this mapper. + type: str + + name: + description: + - Name of the mapper. If no ID is given, the mapper is searched by name. + type: str + + parentId: + description: + - Unique ID for the parent of this mapper. ID of the user federation is automatically used if left blank. + type: str + + providerId: + description: + - The mapper type for this mapper (for instance V(user-attribute-ldap-mapper)). + type: str + + providerType: + description: + - Component type for this mapper. + type: str + default: org.keycloak.storage.ldap.mappers.LDAPStorageMapper + + config: + description: + - Dict specifying the configuration options for the mapper; the contents differ depending on the value of I(identityProviderMapper). type: dict - suboptions: - enabled: - description: - - Enable/disable this user federation. - default: true - type: bool - - priority: - description: - - Priority of provider when doing a user lookup. Lowest first. - default: 0 - type: int - - importEnabled: - description: - - If V(true), LDAP users will be imported into Keycloak DB and synced by the configured - sync policies. - default: true - type: bool - - editMode: - description: - - V(READ_ONLY) is a read-only LDAP store. V(WRITABLE) means data will be synced back to LDAP - on demand. V(UNSYNCED) means user data will be imported, but not synced back to LDAP. - type: str - choices: - - READ_ONLY - - WRITABLE - - UNSYNCED - - syncRegistrations: - description: - - Should newly created users be created within LDAP store? Priority effects which - provider is chosen to sync the new user. - default: false - type: bool - - vendor: - description: - - LDAP vendor (provider). - - Use short name. For instance, write V(rhds) for "Red Hat Directory Server". - type: str - - usernameLDAPAttribute: - description: - - Name of LDAP attribute, which is mapped as Keycloak username. For many LDAP server - vendors it can be V(uid). For Active directory it can be V(sAMAccountName) or V(cn). - The attribute should be filled for all LDAP user records you want to import from - LDAP to Keycloak. - type: str - - rdnLDAPAttribute: - description: - - Name of LDAP attribute, which is used as RDN (top attribute) of typical user DN. - Usually it's the same as Username LDAP attribute, however it is not required. For - example for Active directory, it is common to use V(cn) as RDN attribute when - username attribute might be V(sAMAccountName). - type: str - - uuidLDAPAttribute: - description: - - Name of LDAP attribute, which is used as unique object identifier (UUID) for objects - in LDAP. For many LDAP server vendors, it is V(entryUUID); however some are different. - For example for Active directory it should be V(objectGUID). If your LDAP server does - not support the notion of UUID, you can use any other attribute that is supposed to - be unique among LDAP users in tree. - type: str - - userObjectClasses: - description: - - All values of LDAP objectClass attribute for users in LDAP divided by comma. - For example V(inetOrgPerson, organizationalPerson). Newly created Keycloak users - will be written to LDAP with all those object classes and existing LDAP user records - are found just if they contain all those object classes. - type: str - - connectionUrl: - description: - - Connection URL to your LDAP server. - type: str - - usersDn: - description: - - Full DN of LDAP tree where your users are. This DN is the parent of LDAP users. - type: str - - customUserSearchFilter: - description: - - Additional LDAP Filter for filtering searched users. Leave this empty if you don't - need additional filter. - type: str - - searchScope: - description: - - For one level, the search applies only for users in the DNs specified by User DNs. - For subtree, the search applies to the whole subtree. See LDAP documentation for - more details. - default: '1' - type: str - choices: - - '1' - - '2' - - authType: - description: - - Type of the Authentication method used during LDAP Bind operation. It is used in - most of the requests sent to the LDAP server. - default: 'none' - type: str - choices: - - none - - simple - - bindDn: - description: - - DN of LDAP user which will be used by Keycloak to access LDAP server. - type: str - - bindCredential: - description: - - Password of LDAP admin. - type: str - - startTls: - description: - - Encrypts the connection to LDAP using STARTTLS, which will disable connection pooling. - default: false - type: bool - - usePasswordModifyExtendedOp: - description: - - Use the LDAPv3 Password Modify Extended Operation (RFC-3062). The password modify - extended operation usually requires that LDAP user already has password in the LDAP - server. So when this is used with 'Sync Registrations', it can be good to add also - 'Hardcoded LDAP attribute mapper' with randomly generated initial password. - default: false - type: bool - - validatePasswordPolicy: - description: - - Determines if Keycloak should validate the password with the realm password policy - before updating it. - default: false - type: bool - - trustEmail: - description: - - If enabled, email provided by this provider is not verified even if verification is - enabled for the realm. - default: false - type: bool - - useTruststoreSpi: - description: - - Specifies whether LDAP connection will use the truststore SPI with the truststore - configured in standalone.xml/domain.xml. V(always) means that it will always use it. - V(never) means that it will not use it. V(ldapsOnly) means that it will use if - your connection URL use ldaps. Note even if standalone.xml/domain.xml is not - configured, the default Java cacerts or certificate specified by - C(javax.net.ssl.trustStore) property will be used. - default: ldapsOnly - type: str - choices: - - always - - ldapsOnly - - never - - connectionTimeout: - description: - - LDAP Connection Timeout in milliseconds. - type: int - - readTimeout: - description: - - LDAP Read Timeout in milliseconds. This timeout applies for LDAP read operations. - type: int - - pagination: - description: - - Does the LDAP server support pagination. - default: true - type: bool - - connectionPooling: - description: - - Determines if Keycloak should use connection pooling for accessing LDAP server. - default: true - type: bool - - connectionPoolingAuthentication: - description: - - A list of space-separated authentication types of connections that may be pooled. - type: str - choices: - - none - - simple - - DIGEST-MD5 - - connectionPoolingDebug: - description: - - A string that indicates the level of debug output to produce. Example valid values are - V(fine) (trace connection creation and removal) and V(all) (all debugging information). - type: str - - connectionPoolingInitSize: - description: - - The number of connections per connection identity to create when initially creating a - connection for the identity. - type: int - - connectionPoolingMaxSize: - description: - - The maximum number of connections per connection identity that can be maintained - concurrently. - type: int - - connectionPoolingPrefSize: - description: - - The preferred number of connections per connection identity that should be maintained - concurrently. - type: int - - connectionPoolingProtocol: - description: - - A list of space-separated protocol types of connections that may be pooled. - Valid types are V(plain) and V(ssl). - type: str - - connectionPoolingTimeout: - description: - - The number of milliseconds that an idle connection may remain in the pool without - being closed and removed from the pool. - type: int - - allowKerberosAuthentication: - description: - - Enable/disable HTTP authentication of users with SPNEGO/Kerberos tokens. The data - about authenticated users will be provisioned from this LDAP server. - default: false - type: bool - - kerberosRealm: - description: - - Name of kerberos realm. - type: str - - krbPrincipalAttribute: - description: - - Name of the LDAP attribute, which refers to Kerberos principal. - This is used to lookup appropriate LDAP user after successful Kerberos/SPNEGO authentication in Keycloak. - When this is empty, the LDAP user will be looked based on LDAP username corresponding - to the first part of his Kerberos principal. For instance, for principal C(john@KEYCLOAK.ORG), - it will assume that LDAP username is V(john). - type: str - - serverPrincipal: - description: - - Full name of server principal for HTTP service including server and domain name. For - example V(HTTP/host.foo.org@FOO.ORG). Use V(*) to accept any service principal in the - KeyTab file. - type: str - - keyTab: - description: - - Location of Kerberos KeyTab file containing the credentials of server principal. For - example V(/etc/krb5.keytab). - type: str - - debug: - description: - - Enable/disable debug logging to standard output for Krb5LoginModule. - type: bool - - useKerberosForPasswordAuthentication: - description: - - Use Kerberos login module for authenticate username/password against Kerberos server - instead of authenticating against LDAP server with Directory Service API. - default: false - type: bool - - allowPasswordAuthentication: - description: - - Enable/disable possibility of username/password authentication against Kerberos database. - type: bool - - batchSizeForSync: - description: - - Count of LDAP users to be imported from LDAP to Keycloak within a single transaction. - default: 1000 - type: int - - fullSyncPeriod: - description: - - Period for full synchronization in seconds. - default: -1 - type: int - - changedSyncPeriod: - description: - - Period for synchronization of changed or newly created LDAP users in seconds. - default: -1 - type: int - - updateProfileFirstLogin: - description: - - Update profile on first login. - type: bool - - cachePolicy: - description: - - Cache Policy for this storage provider. - type: str - default: 'DEFAULT' - choices: - - DEFAULT - - EVICT_DAILY - - EVICT_WEEKLY - - MAX_LIFESPAN - - NO_CACHE - - evictionDay: - description: - - Day of the week the entry will become invalid on. - type: str - - evictionHour: - description: - - Hour of day the entry will become invalid on. - type: str - - evictionMinute: - description: - - Minute of day the entry will become invalid on. - type: str - - maxLifespan: - description: - - Max lifespan of cache entry in milliseconds. - type: int - - referral: - description: - - Specifies if LDAP referrals should be followed or ignored. Please note that enabling - referrals can slow down authentication as it allows the LDAP server to decide which other - LDAP servers to use. This could potentially include untrusted servers. - type: str - choices: - - ignore - - follow - - mappers: - description: - - A list of dicts defining mappers associated with this Identity Provider. - type: list - elements: dict - suboptions: - id: - description: - - Unique ID of this mapper. - type: str - - name: - description: - - Name of the mapper. If no ID is given, the mapper will be searched by name. - type: str - - parentId: - description: - - Unique ID for the parent of this mapper. ID of the user federation will automatically - be used if left blank. - type: str - - providerId: - description: - - The mapper type for this mapper (for instance V(user-attribute-ldap-mapper)). - type: str - - providerType: - description: - - Component type for this mapper. - type: str - default: org.keycloak.storage.ldap.mappers.LDAPStorageMapper - - config: - description: - - Dict specifying the configuration options for the mapper; the contents differ - depending on the value of I(identityProviderMapper). - type: dict extends_documentation_fragment: - - middleware_automation.keycloak.keycloak - - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes author: - - Laurent Paumier (@laurpaum) -''' + - Laurent Paumier (@laurpaum) +""" -EXAMPLES = ''' +EXAMPLES = r""" - name: Create LDAP user federation middleware_automation.keycloak.keycloak_user_federation: - auth_keycloak_url: https://keycloak.example.com/auth + auth_keycloak_url: https://keycloak.example.com auth_realm: master auth_username: admin auth_password: password @@ -527,32 +500,32 @@ EXAMPLES = ''' provider_id: ldap provider_type: org.keycloak.storage.UserStorageProvider config: - priority: 0 - enabled: true - cachePolicy: DEFAULT - batchSizeForSync: 1000 - editMode: READ_ONLY - importEnabled: true - syncRegistrations: false - vendor: other - usernameLDAPAttribute: uid - rdnLDAPAttribute: uid - uuidLDAPAttribute: entryUUID - userObjectClasses: inetOrgPerson, organizationalPerson - connectionUrl: ldaps://ldap.example.com:636 - usersDn: ou=Users,dc=example,dc=com - authType: simple - bindDn: cn=directory reader - bindCredential: password - searchScope: 1 - validatePasswordPolicy: false - trustEmail: false - useTruststoreSpi: ldapsOnly - connectionPooling: true - pagination: true - allowKerberosAuthentication: false - debug: false - useKerberosForPasswordAuthentication: false + priority: 0 + enabled: true + cachePolicy: DEFAULT + batchSizeForSync: 1000 + editMode: READ_ONLY + importEnabled: true + syncRegistrations: false + vendor: other + usernameLDAPAttribute: uid + rdnLDAPAttribute: uid + uuidLDAPAttribute: entryUUID + userObjectClasses: inetOrgPerson, organizationalPerson + connectionUrl: ldaps://ldap.example.com:636 + usersDn: ou=Users,dc=example,dc=com + authType: simple + bindDn: cn=directory reader + bindCredential: password + searchScope: 1 + validatePasswordPolicy: false + trustEmail: false + useTruststoreSpi: ldapsOnly + connectionPooling: true + pagination: true + allowKerberosAuthentication: false + debug: false + useKerberosForPasswordAuthentication: false mappers: - name: "full name" providerId: "full-name-ldap-mapper" @@ -564,7 +537,7 @@ EXAMPLES = ''' - name: Create Kerberos user federation middleware_automation.keycloak.keycloak_user_federation: - auth_keycloak_url: https://keycloak.example.com/auth + auth_keycloak_url: https://keycloak.example.com auth_realm: master auth_username: admin auth_password: password @@ -585,7 +558,7 @@ EXAMPLES = ''' - name: Create sssd user federation middleware_automation.keycloak.keycloak_user_federation: - auth_keycloak_url: https://keycloak.example.com/auth + auth_keycloak_url: https://keycloak.example.com auth_realm: master auth_username: admin auth_password: password @@ -595,176 +568,201 @@ EXAMPLES = ''' provider_id: sssd provider_type: org.keycloak.storage.UserStorageProvider config: - priority: 0 - enabled: true - cachePolicy: DEFAULT + priority: 0 + enabled: true + cachePolicy: DEFAULT - name: Delete user federation middleware_automation.keycloak.keycloak_user_federation: - auth_keycloak_url: https://keycloak.example.com/auth + auth_keycloak_url: https://keycloak.example.com auth_realm: master auth_username: admin auth_password: password realm: my-realm name: my-federation state: absent -''' +""" -RETURN = ''' +RETURN = r""" msg: - description: Message as to what action was taken. - returned: always - type: str - sample: "No changes required to user federation 164bb483-c613-482e-80fe-7f1431308799." + description: Message as to what action was taken. + returned: always + type: str + sample: "No changes required to user federation 164bb483-c613-482e-80fe-7f1431308799." proposed: - description: Representation of proposed user federation. - returned: always - type: dict - sample: { - "config": { - "allowKerberosAuthentication": "false", - "authType": "simple", - "batchSizeForSync": "1000", - "bindCredential": "**********", - "bindDn": "cn=directory reader", - "cachePolicy": "DEFAULT", - "connectionPooling": "true", - "connectionUrl": "ldaps://ldap.example.com:636", - "debug": "false", - "editMode": "READ_ONLY", - "enabled": "true", - "importEnabled": "true", - "pagination": "true", - "priority": "0", - "rdnLDAPAttribute": "uid", - "searchScope": "1", - "syncRegistrations": "false", - "trustEmail": "false", - "useKerberosForPasswordAuthentication": "false", - "useTruststoreSpi": "ldapsOnly", - "userObjectClasses": "inetOrgPerson, organizationalPerson", - "usernameLDAPAttribute": "uid", - "usersDn": "ou=Users,dc=example,dc=com", - "uuidLDAPAttribute": "entryUUID", - "validatePasswordPolicy": "false", - "vendor": "other" - }, - "name": "ldap", - "providerId": "ldap", - "providerType": "org.keycloak.storage.UserStorageProvider" + description: Representation of proposed user federation. + returned: always + type: dict + sample: + { + "config": { + "allowKerberosAuthentication": "false", + "authType": "simple", + "batchSizeForSync": "1000", + "bindCredential": "**********", + "bindDn": "cn=directory reader", + "cachePolicy": "DEFAULT", + "connectionPooling": "true", + "connectionUrl": "ldaps://ldap.example.com:636", + "debug": "false", + "editMode": "READ_ONLY", + "enabled": "true", + "importEnabled": "true", + "pagination": "true", + "priority": "0", + "rdnLDAPAttribute": "uid", + "searchScope": "1", + "syncRegistrations": "false", + "trustEmail": "false", + "useKerberosForPasswordAuthentication": "false", + "useTruststoreSpi": "ldapsOnly", + "userObjectClasses": "inetOrgPerson, organizationalPerson", + "usernameLDAPAttribute": "uid", + "usersDn": "ou=Users,dc=example,dc=com", + "uuidLDAPAttribute": "entryUUID", + "validatePasswordPolicy": "false", + "vendor": "other" + }, + "name": "ldap", + "providerId": "ldap", + "providerType": "org.keycloak.storage.UserStorageProvider" } existing: - description: Representation of existing user federation. - returned: always - type: dict - sample: { - "config": { - "allowKerberosAuthentication": "false", - "authType": "simple", - "batchSizeForSync": "1000", - "bindCredential": "**********", - "bindDn": "cn=directory reader", - "cachePolicy": "DEFAULT", - "changedSyncPeriod": "-1", - "connectionPooling": "true", - "connectionUrl": "ldaps://ldap.example.com:636", - "debug": "false", - "editMode": "READ_ONLY", - "enabled": "true", - "fullSyncPeriod": "-1", - "importEnabled": "true", - "pagination": "true", - "priority": "0", - "rdnLDAPAttribute": "uid", - "searchScope": "1", - "syncRegistrations": "false", - "trustEmail": "false", - "useKerberosForPasswordAuthentication": "false", - "useTruststoreSpi": "ldapsOnly", - "userObjectClasses": "inetOrgPerson, organizationalPerson", - "usernameLDAPAttribute": "uid", - "usersDn": "ou=Users,dc=example,dc=com", - "uuidLDAPAttribute": "entryUUID", - "validatePasswordPolicy": "false", - "vendor": "other" - }, - "id": "01122837-9047-4ae4-8ca0-6e2e891a765f", - "mappers": [ - { - "config": { - "always.read.value.from.ldap": "false", - "is.mandatory.in.ldap": "false", - "ldap.attribute": "mail", - "read.only": "true", - "user.model.attribute": "email" - }, - "id": "17d60ce2-2d44-4c2c-8b1f-1fba601b9a9f", - "name": "email", - "parentId": "01122837-9047-4ae4-8ca0-6e2e891a765f", - "providerId": "user-attribute-ldap-mapper", - "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" - } - ], - "name": "myfed", - "parentId": "myrealm", - "providerId": "ldap", - "providerType": "org.keycloak.storage.UserStorageProvider" + description: Representation of existing user federation. + returned: always + type: dict + sample: + { + "config": { + "allowKerberosAuthentication": "false", + "authType": "simple", + "batchSizeForSync": "1000", + "bindCredential": "**********", + "bindDn": "cn=directory reader", + "cachePolicy": "DEFAULT", + "changedSyncPeriod": "-1", + "connectionPooling": "true", + "connectionUrl": "ldaps://ldap.example.com:636", + "debug": "false", + "editMode": "READ_ONLY", + "enabled": "true", + "fullSyncPeriod": "-1", + "importEnabled": "true", + "pagination": "true", + "priority": "0", + "rdnLDAPAttribute": "uid", + "searchScope": "1", + "syncRegistrations": "false", + "trustEmail": "false", + "useKerberosForPasswordAuthentication": "false", + "useTruststoreSpi": "ldapsOnly", + "userObjectClasses": "inetOrgPerson, organizationalPerson", + "usernameLDAPAttribute": "uid", + "usersDn": "ou=Users,dc=example,dc=com", + "uuidLDAPAttribute": "entryUUID", + "validatePasswordPolicy": "false", + "vendor": "other" + }, + "id": "01122837-9047-4ae4-8ca0-6e2e891a765f", + "mappers": [ + { + "config": { + "always.read.value.from.ldap": "false", + "is.mandatory.in.ldap": "false", + "ldap.attribute": "mail", + "read.only": "true", + "user.model.attribute": "email" + }, + "id": "17d60ce2-2d44-4c2c-8b1f-1fba601b9a9f", + "name": "email", + "parentId": "01122837-9047-4ae4-8ca0-6e2e891a765f", + "providerId": "user-attribute-ldap-mapper", + "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" + } + ], + "name": "myfed", + "parentId": "myrealm", + "providerId": "ldap", + "providerType": "org.keycloak.storage.UserStorageProvider" } end_state: - description: Representation of user federation after module execution. - returned: on success - type: dict - sample: { - "config": { - "allowPasswordAuthentication": "false", - "cachePolicy": "DEFAULT", - "enabled": "true", - "kerberosRealm": "EXAMPLE.COM", - "keyTab": "/etc/krb5.keytab", - "priority": "0", - "serverPrincipal": "HTTP/host.example.com@EXAMPLE.COM", - "updateProfileFirstLogin": "false" - }, - "id": "cf52ae4f-4471-4435-a0cf-bb620cadc122", - "mappers": [], - "name": "kerberos", - "parentId": "myrealm", - "providerId": "kerberos", - "providerType": "org.keycloak.storage.UserStorageProvider" + description: Representation of user federation after module execution. + returned: on success + type: dict + sample: + { + "config": { + "allowPasswordAuthentication": "false", + "cachePolicy": "DEFAULT", + "enabled": "true", + "kerberosRealm": "EXAMPLE.COM", + "keyTab": "/etc/krb5.keytab", + "priority": "0", + "serverPrincipal": "HTTP/host.example.com@EXAMPLE.COM", + "updateProfileFirstLogin": "false" + }, + "id": "cf52ae4f-4471-4435-a0cf-bb620cadc122", + "mappers": [], + "name": "kerberos", + "parentId": "myrealm", + "providerId": "kerberos", + "providerType": "org.keycloak.storage.UserStorageProvider" } -''' +""" -from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, get_token, KeycloakError -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.six.moves.urllib.parse import urlencode from copy import deepcopy +from urllib.parse import urlencode + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + keycloak_argument_spec, +) def normalize_kc_comp(comp): - if 'config' in comp: + if "config" in comp: # kc completely removes the parameter `krbPrincipalAttribute` if it is set to `''`; the unset kc parameter is equivalent to `''`; # to make change detection and diff more accurate we set it again in the kc responses - if 'krbPrincipalAttribute' not in comp['config']: - comp['config']['krbPrincipalAttribute'] = [''] + if "krbPrincipalAttribute" not in comp["config"]: + comp["config"]["krbPrincipalAttribute"] = [""] # kc stores a timestamp of the last sync in `lastSync` to time the periodic sync, it is removed to minimize diff/changes - comp['config'].pop('lastSync', None) + comp["config"].pop("lastSync", None) def sanitize(comp): + def sanitize_value(v): + """Convert list values: single-element lists to strings, multi-element lists sorted alphabetically, others as-is.""" + if isinstance(v, list): + if len(v) == 0: + return None + elif len(v) == 1: + return v[0] + else: + return sorted(v) + else: + return v + compcopy = deepcopy(comp) - if 'config' in compcopy: - compcopy['config'] = {k: v[0] for k, v in compcopy['config'].items()} - if 'bindCredential' in compcopy['config']: - compcopy['config']['bindCredential'] = '**********' - if 'mappers' in compcopy: - for mapper in compcopy['mappers']: - if 'config' in mapper: - mapper['config'] = {k: v[0] for k, v in mapper['config'].items()} + if "config" in compcopy: + compcopy["config"] = {k: sanitize_value(v) for k, v in compcopy["config"].items()} + # Remove None values (empty lists converted) + compcopy["config"] = {k: v for k, v in compcopy["config"].items() if v is not None} + if "bindCredential" in compcopy["config"]: + compcopy["config"]["bindCredential"] = "**********" + if "mappers" in compcopy: + for mapper in compcopy["mappers"]: + if "config" in mapper: + mapper["config"] = {k: sanitize_value(v) for k, v in mapper["config"].items()} + mapper["config"] = {k: v for k, v in mapper["config"].items() if v is not None} return compcopy @@ -777,91 +775,102 @@ def main(): argument_spec = keycloak_argument_spec() config_spec = dict( - allowKerberosAuthentication=dict(type='bool', default=False), - allowPasswordAuthentication=dict(type='bool'), - authType=dict(type='str', choices=['none', 'simple'], default='none'), - batchSizeForSync=dict(type='int', default=1000), - bindCredential=dict(type='str', no_log=True), - bindDn=dict(type='str'), - cachePolicy=dict(type='str', choices=['DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN', 'NO_CACHE'], default='DEFAULT'), - changedSyncPeriod=dict(type='int', default=-1), - connectionPooling=dict(type='bool', default=True), - connectionPoolingAuthentication=dict(type='str', choices=['none', 'simple', 'DIGEST-MD5']), - connectionPoolingDebug=dict(type='str'), - connectionPoolingInitSize=dict(type='int'), - connectionPoolingMaxSize=dict(type='int'), - connectionPoolingPrefSize=dict(type='int'), - connectionPoolingProtocol=dict(type='str'), - connectionPoolingTimeout=dict(type='int'), - connectionTimeout=dict(type='int'), - connectionUrl=dict(type='str'), - customUserSearchFilter=dict(type='str'), - debug=dict(type='bool'), - editMode=dict(type='str', choices=['READ_ONLY', 'WRITABLE', 'UNSYNCED']), - enabled=dict(type='bool', default=True), - evictionDay=dict(type='str'), - evictionHour=dict(type='str'), - evictionMinute=dict(type='str'), - fullSyncPeriod=dict(type='int', default=-1), - importEnabled=dict(type='bool', default=True), - kerberosRealm=dict(type='str'), - keyTab=dict(type='str', no_log=False), - maxLifespan=dict(type='int'), - pagination=dict(type='bool', default=True), - priority=dict(type='int', default=0), - rdnLDAPAttribute=dict(type='str'), - readTimeout=dict(type='int'), - referral=dict(type='str', choices=['ignore', 'follow']), - searchScope=dict(type='str', choices=['1', '2'], default='1'), - serverPrincipal=dict(type='str'), - krbPrincipalAttribute=dict(type='str'), - startTls=dict(type='bool', default=False), - syncRegistrations=dict(type='bool', default=False), - trustEmail=dict(type='bool', default=False), - updateProfileFirstLogin=dict(type='bool'), - useKerberosForPasswordAuthentication=dict(type='bool', default=False), - usePasswordModifyExtendedOp=dict(type='bool', default=False, no_log=False), - useTruststoreSpi=dict(type='str', choices=['always', 'ldapsOnly', 'never'], default='ldapsOnly'), - userObjectClasses=dict(type='str'), - usernameLDAPAttribute=dict(type='str'), - usersDn=dict(type='str'), - uuidLDAPAttribute=dict(type='str'), - validatePasswordPolicy=dict(type='bool', default=False), - vendor=dict(type='str'), + allowKerberosAuthentication=dict(type="bool", default=False), + allowPasswordAuthentication=dict(type="bool"), + authType=dict(type="str", choices=["none", "simple"], default="none"), + batchSizeForSync=dict(type="int", default=1000), + bindCredential=dict(type="str", no_log=True), + bindDn=dict(type="str"), + cachePolicy=dict( + type="str", + choices=["DEFAULT", "EVICT_DAILY", "EVICT_WEEKLY", "MAX_LIFESPAN", "NO_CACHE"], + default="DEFAULT", + ), + changedSyncPeriod=dict(type="int", default=-1), + connectionPooling=dict(type="bool", default=True), + connectionPoolingAuthentication=dict(type="str", choices=["none", "simple", "DIGEST-MD5"]), + connectionPoolingDebug=dict(type="str"), + connectionPoolingInitSize=dict(type="int"), + connectionPoolingMaxSize=dict(type="int"), + connectionPoolingPrefSize=dict(type="int"), + connectionPoolingProtocol=dict(type="str"), + connectionPoolingTimeout=dict(type="int"), + connectionTimeout=dict(type="int"), + connectionUrl=dict(type="str"), + customUserSearchFilter=dict(type="str"), + debug=dict(type="bool"), + editMode=dict(type="str", choices=["READ_ONLY", "WRITABLE", "UNSYNCED"]), + enabled=dict(type="bool", default=True), + evictionDay=dict(type="str"), + evictionHour=dict(type="str"), + evictionMinute=dict(type="str"), + fullSyncPeriod=dict(type="int", default=-1), + importEnabled=dict(type="bool", default=True), + kerberosRealm=dict(type="str"), + keyTab=dict(type="str", no_log=False), + maxLifespan=dict(type="int"), + pagination=dict(type="bool", default=True), + priority=dict(type="int", default=0), + rdnLDAPAttribute=dict(type="str"), + readTimeout=dict(type="int"), + referral=dict(type="str", choices=["ignore", "follow"]), + searchScope=dict(type="str", choices=["1", "2"], default="1"), + serverPrincipal=dict(type="str"), + krbPrincipalAttribute=dict(type="str"), + startTls=dict(type="bool", default=False), + syncRegistrations=dict(type="bool", default=False), + trustEmail=dict(type="bool", default=False), + updateProfileFirstLogin=dict(type="bool"), + useKerberosForPasswordAuthentication=dict(type="bool", default=False), + usePasswordModifyExtendedOp=dict(type="bool", default=False, no_log=False), + useTruststoreSpi=dict(type="str", choices=["always", "ldapsOnly", "never"], default="ldapsOnly"), + userObjectClasses=dict(type="str"), + usernameLDAPAttribute=dict(type="str"), + usersDn=dict(type="str"), + uuidLDAPAttribute=dict(type="str"), + validatePasswordPolicy=dict(type="bool", default=False), + vendor=dict(type="str"), ) mapper_spec = dict( - id=dict(type='str'), - name=dict(type='str'), - parentId=dict(type='str'), - providerId=dict(type='str'), - providerType=dict(type='str', default='org.keycloak.storage.ldap.mappers.LDAPStorageMapper'), - config=dict(type='dict'), + id=dict(type="str"), + name=dict(type="str"), + parentId=dict(type="str"), + providerId=dict(type="str"), + providerType=dict(type="str", default="org.keycloak.storage.ldap.mappers.LDAPStorageMapper"), + config=dict(type="dict"), ) meta_args = dict( - config=dict(type='dict', options=config_spec), - state=dict(type='str', default='present', choices=['present', 'absent']), - realm=dict(type='str', default='master'), - id=dict(type='str'), - name=dict(type='str'), - provider_id=dict(type='str', aliases=['providerId']), - provider_type=dict(type='str', aliases=['providerType'], default='org.keycloak.storage.UserStorageProvider'), - parent_id=dict(type='str', aliases=['parentId']), - remove_unspecified_mappers=dict(type='bool', default=True), - bind_credential_update_mode=dict(type='str', default='always', choices=['always', 'only_indirect']), - mappers=dict(type='list', elements='dict', options=mapper_spec), + config=dict(type="dict", options=config_spec), + state=dict(type="str", default="present", choices=["present", "absent"]), + realm=dict(type="str", default="master"), + id=dict(type="str"), + name=dict(type="str"), + provider_id=dict(type="str", aliases=["providerId"]), + provider_type=dict(type="str", aliases=["providerType"], default="org.keycloak.storage.UserStorageProvider"), + parent_id=dict(type="str", aliases=["parentId"]), + remove_unspecified_mappers=dict(type="bool", default=True), + bind_credential_update_mode=dict(type="str", default="always", choices=["always", "only_indirect"]), + mappers=dict(type="list", elements="dict", options=mapper_spec), ) argument_spec.update(meta_args) - module = AnsibleModule(argument_spec=argument_spec, - supports_check_mode=True, - required_one_of=([['id', 'name'], - ['token', 'auth_realm', 'auth_username', 'auth_password']]), - required_together=([['auth_realm', 'auth_username', 'auth_password']])) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [ + ["id", "name"], + ["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"], + ] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) - result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API try: @@ -871,44 +880,52 @@ def main(): kc = KeycloakAPI(module, connection_header) - realm = module.params.get('realm') - state = module.params.get('state') - config = module.params.get('config') - mappers = module.params.get('mappers') - cid = module.params.get('id') - name = module.params.get('name') + realm = module.params.get("realm") + state = module.params.get("state") + config = module.params.get("config") + mappers = module.params.get("mappers") + cid = module.params.get("id") + name = module.params.get("name") # Keycloak API expects config parameters to be arrays containing a single string element if config is not None: - module.params['config'] = { - k: [str(v).lower() if not isinstance(v, str) else v] - for k, v in config.items() - if config[k] is not None + module.params["config"] = { + k: [str(v).lower() if not isinstance(v, str) else v] for k, v in config.items() if config[k] is not None } if mappers is not None: for mapper in mappers: - if mapper.get('config') is not None: - mapper['config'] = { - k: [str(v).lower() if not isinstance(v, str) else v] - for k, v in mapper['config'].items() - if mapper['config'][k] is not None - } + if mapper.get("config") is not None: + new_config = {} + for k, v in mapper["config"].items(): + if v is None: + continue + if isinstance(v, list): + new_config[k] = [str(item).lower() if not isinstance(item, str) else item for item in v] + else: + new_config[k] = [str(v).lower() if not isinstance(v, str) else v] + mapper["config"] = new_config # Filter and map the parameters names that apply - comp_params = [x for x in module.params - if x not in list(keycloak_argument_spec().keys()) - + ['state', 'realm', 'mappers', 'remove_unspecified_mappers', 'bind_credential_update_mode'] - and module.params.get(x) is not None] + comp_params = [ + x + for x in module.params + if x + not in list(keycloak_argument_spec().keys()) + + ["state", "realm", "mappers", "remove_unspecified_mappers", "bind_credential_update_mode"] + and module.params.get(x) is not None + ] # See if it already exists in Keycloak if cid is None: - found = kc.get_components(urlencode(dict(type='org.keycloak.storage.UserStorageProvider', name=name)), realm) + found = kc.get_components(urlencode(dict(type="org.keycloak.storage.UserStorageProvider", name=name)), realm) if len(found) > 1: - module.fail_json(msg='No ID given and found multiple user federations with name `{name}`. Cannot continue.'.format(name=name)) + module.fail_json( + msg=f"No ID given and found multiple user federations with name `{name}`. Cannot continue." + ) before_comp = next(iter(found), None) if before_comp is not None: - cid = before_comp['id'] + cid = before_comp["id"] else: before_comp = kc.get_component(cid, realm) @@ -917,7 +934,9 @@ def main(): # if user federation exists, get associated mappers if cid is not None and before_comp: - before_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name') or '') + before_comp["mappers"] = sorted( + kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get("name") or "" + ) normalize_kc_comp(before_comp) @@ -927,29 +946,40 @@ def main(): for param in comp_params: new_param_value = module.params.get(param) old_value = before_comp[camel(param)] if camel(param) in before_comp else None - if param == 'mappers': + if param == "mappers": new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value] if new_param_value != old_value: changeset[camel(param)] = new_param_value # special handling of mappers list to allow change detection - if module.params.get('mappers') is not None: - if module.params['provider_id'] in ['kerberos', 'sssd']: - module.fail_json(msg='Cannot configure mappers for {type} provider.'.format(type=module.params['provider_id'])) - for change in module.params['mappers']: + if module.params.get("mappers") is not None: + if module.params["provider_id"] in ["kerberos", "sssd"]: + module.fail_json(msg=f"Cannot configure mappers for {module.params['provider_id']} provider.") + for change in module.params["mappers"]: change = {k: v for k, v in change.items() if v is not None} - if change.get('id') is None and change.get('name') is None: - module.fail_json(msg='Either `name` or `id` has to be specified on each mapper.') + if change.get("id") is None and change.get("name") is None: + module.fail_json(msg="Either `name` or `id` has to be specified on each mapper.") if cid is None: old_mapper = {} - elif change.get('id') is not None: - old_mapper = next((before_mapper for before_mapper in before_comp.get('mappers', []) if before_mapper["id"] == change['id']), None) + elif change.get("id") is not None: + old_mapper = next( + ( + before_mapper + for before_mapper in before_comp.get("mappers", []) + if before_mapper["id"] == change["id"] + ), + None, + ) if old_mapper is None: old_mapper = {} else: - found = [before_mapper for before_mapper in before_comp.get('mappers', []) if before_mapper['name'] == change['name']] + found = [ + before_mapper + for before_mapper in before_comp.get("mappers", []) + if before_mapper["name"] == change["name"] + ] if len(found) > 1: - module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=change['name'])) + module.fail_json(msg=f"Found multiple mappers with name `{change['name']}`. Cannot continue.") if len(found) == 1: old_mapper = found[0] else: @@ -957,55 +987,59 @@ def main(): new_mapper = old_mapper.copy() new_mapper.update(change) # changeset contains all desired mappers: those existing, to update or to create - if changeset.get('mappers') is None: - changeset['mappers'] = list() - changeset['mappers'].append(new_mapper) - changeset['mappers'] = sorted(changeset['mappers'], key=lambda x: x.get('name') or '') + if changeset.get("mappers") is None: + changeset["mappers"] = list() + changeset["mappers"].append(new_mapper) + changeset["mappers"] = sorted(changeset["mappers"], key=lambda x: x.get("name") or "") # to keep unspecified existing mappers we add them to the desired mappers list, unless they're already present - if not module.params['remove_unspecified_mappers'] and 'mappers' in before_comp: - changeset_mapper_ids = [mapper['id'] for mapper in changeset['mappers'] if 'id' in mapper] - changeset['mappers'].extend([mapper for mapper in before_comp['mappers'] if mapper['id'] not in changeset_mapper_ids]) + if not module.params["remove_unspecified_mappers"] and "mappers" in before_comp: + changeset_mapper_ids = [mapper["id"] for mapper in changeset["mappers"] if "id" in mapper] + changeset["mappers"].extend( + [mapper for mapper in before_comp["mappers"] if mapper["id"] not in changeset_mapper_ids] + ) # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) desired_comp = before_comp.copy() desired_comp.update(changeset) - result['proposed'] = sanitize(changeset) - result['existing'] = sanitize(before_comp) + result["proposed"] = sanitize(changeset) + result["existing"] = sanitize(before_comp) # Cater for when it doesn't exist (an empty dict) if not before_comp: - if state == 'absent': + if state == "absent": # Do nothing and exit if module._diff: - result['diff'] = dict(before='', after='') - result['changed'] = False - result['end_state'] = {} - result['msg'] = 'User federation does not exist; doing nothing.' + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = "User federation does not exist; doing nothing." module.exit_json(**result) # Process a creation - result['changed'] = True + result["changed"] = True if module.check_mode: if module._diff: - result['diff'] = dict(before='', after=sanitize(desired_comp)) + result["diff"] = dict(before="", after=sanitize(desired_comp)) module.exit_json(**result) # create it - desired_mappers = desired_comp.pop('mappers', []) + desired_mappers = desired_comp.pop("mappers", []) after_comp = kc.create_component(desired_comp, realm) - cid = after_comp['id'] + cid = after_comp["id"] updated_mappers = [] # when creating a user federation, keycloak automatically creates default mappers default_mappers = kc.get_components(urlencode(dict(parent=cid)), realm) # create new mappers or update existing default mappers for desired_mapper in desired_mappers: - found = [default_mapper for default_mapper in default_mappers if default_mapper['name'] == desired_mapper['name']] + found = [ + default_mapper for default_mapper in default_mappers if default_mapper["name"] == desired_mapper["name"] + ] if len(found) > 1: - module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=desired_mapper['name'])) + module.fail_json(msg=f"Found multiple mappers with name `{desired_mapper['name']}`. Cannot continue.") if len(found) == 1: old_mapper = found[0] else: @@ -1014,93 +1048,95 @@ def main(): new_mapper = old_mapper.copy() new_mapper.update(desired_mapper) - if new_mapper.get('id') is not None: + if new_mapper.get("id") is not None: kc.update_component(new_mapper, realm) updated_mappers.append(new_mapper) else: - if new_mapper.get('parentId') is None: - new_mapper['parentId'] = cid + if new_mapper.get("parentId") is None: + new_mapper["parentId"] = cid updated_mappers.append(kc.create_component(new_mapper, realm)) - if module.params['remove_unspecified_mappers']: + if module.params["remove_unspecified_mappers"]: # we remove all unwanted default mappers # we use ids so we dont accidently remove one of the previously updated default mapper for default_mapper in default_mappers: - if not default_mapper['id'] in [x['id'] for x in updated_mappers]: - kc.delete_component(default_mapper['id'], realm) + if default_mapper["id"] not in [x["id"] for x in updated_mappers]: + kc.delete_component(default_mapper["id"], realm) - after_comp['mappers'] = kc.get_components(urlencode(dict(parent=cid)), realm) + after_comp["mappers"] = kc.get_components(urlencode(dict(parent=cid)), realm) normalize_kc_comp(after_comp) if module._diff: - result['diff'] = dict(before='', after=sanitize(after_comp)) - result['end_state'] = sanitize(after_comp) - result['msg'] = "User federation {id} has been created".format(id=cid) + result["diff"] = dict(before="", after=sanitize(after_comp)) + result["end_state"] = sanitize(after_comp) + result["msg"] = f"User federation {cid} has been created" module.exit_json(**result) else: - if state == 'present': + if state == "present": # Process an update desired_copy = deepcopy(desired_comp) before_copy = deepcopy(before_comp) # exclude bindCredential when checking wether an update is required, therefore # updating it only if there are other changes - if module.params['bind_credential_update_mode'] == 'only_indirect': - desired_copy.get('config', []).pop('bindCredential', None) - before_copy.get('config', []).pop('bindCredential', None) + if module.params["bind_credential_update_mode"] == "only_indirect": + desired_copy.get("config", []).pop("bindCredential", None) + before_copy.get("config", []).pop("bindCredential", None) # no changes if desired_copy == before_copy: - result['changed'] = False - result['end_state'] = sanitize(desired_comp) - result['msg'] = "No changes required to user federation {id}.".format(id=cid) + result["changed"] = False + result["end_state"] = sanitize(desired_comp) + result["msg"] = f"No changes required to user federation {cid}." module.exit_json(**result) # doing an update - result['changed'] = True + result["changed"] = True if module._diff: - result['diff'] = dict(before=sanitize(before_comp), after=sanitize(desired_comp)) + result["diff"] = dict(before=sanitize(before_comp), after=sanitize(desired_comp)) if module.check_mode: module.exit_json(**result) # do the update - desired_mappers = desired_comp.pop('mappers', []) + desired_mappers = desired_comp.pop("mappers", []) kc.update_component(desired_comp, realm) - for before_mapper in before_comp.get('mappers', []): + for before_mapper in before_comp.get("mappers", []): # remove unwanted existing mappers that will not be updated - if not before_mapper['id'] in [x['id'] for x in desired_mappers if 'id' in x]: - kc.delete_component(before_mapper['id'], realm) + if before_mapper["id"] not in [x["id"] for x in desired_mappers if "id" in x]: + kc.delete_component(before_mapper["id"], realm) for mapper in desired_mappers: - if mapper in before_comp.get('mappers', []): + if mapper in before_comp.get("mappers", []): continue - if mapper.get('id') is not None: + if mapper.get("id") is not None: kc.update_component(mapper, realm) else: - if mapper.get('parentId') is None: - mapper['parentId'] = desired_comp['id'] + if mapper.get("parentId") is None: + mapper["parentId"] = desired_comp["id"] kc.create_component(mapper, realm) after_comp = kc.get_component(cid, realm) - after_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name') or '') + after_comp["mappers"] = sorted( + kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get("name") or "" + ) normalize_kc_comp(after_comp) after_comp_sanitized = sanitize(after_comp) before_comp_sanitized = sanitize(before_comp) - result['end_state'] = after_comp_sanitized + result["end_state"] = after_comp_sanitized if module._diff: - result['diff'] = dict(before=before_comp_sanitized, after=after_comp_sanitized) - result['changed'] = before_comp_sanitized != after_comp_sanitized - result['msg'] = "User federation {id} has been updated".format(id=cid) + result["diff"] = dict(before=before_comp_sanitized, after=after_comp_sanitized) + result["changed"] = before_comp_sanitized != after_comp_sanitized + result["msg"] = f"User federation {cid} has been updated" module.exit_json(**result) - elif state == 'absent': + elif state == "absent": # Process a deletion - result['changed'] = True + result["changed"] = True if module._diff: - result['diff'] = dict(before=sanitize(before_comp), after='') + result["diff"] = dict(before=sanitize(before_comp), after="") if module.check_mode: module.exit_json(**result) @@ -1108,12 +1144,12 @@ def main(): # delete it kc.delete_component(cid, realm) - result['end_state'] = {} + result["end_state"] = {} - result['msg'] = "User federation {id} has been deleted".format(id=cid) + result["msg"] = f"User federation {cid} has been deleted" module.exit_json(**result) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/plugins/modules/keycloak_user_rolemapping.py b/plugins/modules/keycloak_user_rolemapping.py new file mode 100644 index 0000000..b4ee83c --- /dev/null +++ b/plugins/modules/keycloak_user_rolemapping.py @@ -0,0 +1,440 @@ + +# Copyright (c) 2022, Dušan Marković (@bratwurzt) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_user_rolemapping + +short_description: Allows administration of Keycloak user_rolemapping with the Keycloak API + +version_added: "3.0.0" + +description: + - This module allows you to add, remove or modify Keycloak user_rolemapping with the Keycloak REST API. It requires access + to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. + In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with + the scope tailored to your needs and a user having the expected roles. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way + by this module. You may pass single values for attributes when calling the module, and this is translated into a list + suitable for the API. + - When updating a user_rolemapping, where possible provide the role ID to the module. This removes a lookup to the API to + translate the name into the role ID. +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the user_rolemapping. + - On V(present), the user_rolemapping is created if it does not yet exist, or updated with the parameters you provide. + - On V(absent), the user_rolemapping is removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this role_representation resides. + default: 'master' + + target_username: + type: str + description: + - Username of the user roles are mapped to. + - This parameter is not required (can be replaced by uid for less API call). + uid: + type: str + description: + - ID of the user to be mapped. + - This parameter is not required for updating or deleting the rolemapping but providing it reduces the number of API + calls required. + service_account_user_client_id: + type: str + description: + - Client ID of the service-account-user to be mapped. + - This parameter is not required for updating or deleting the rolemapping but providing it reduces the number of API + calls required. + client_id: + type: str + description: + - Name of the client (different than O(cid)) whose role is to be mapped. + - This parameter is required if O(cid) is not provided (can be replaced by O(cid) to reduce the number of API calls + that must be made). + - If neither O(cid) nor O(client_id) is specified, a B(realm) role is mapped instead. + cid: + type: str + description: + - ID of the client whose role is to be mapped. + - This parameter is not required for updating or deleting the rolemapping but providing it reduces the number of API + calls required. + - If neither O(cid) nor O(client_id) is specified, a B(realm) role is mapped instead. + roles: + description: + - Roles to be mapped to the user. + type: list + elements: dict + suboptions: + name: + type: str + description: + - Name of the role representation. + - This parameter is required only when creating or updating the role_representation. + id: + type: str + description: + - The unique identifier for this role_representation. + - This parameter is not required for updating or deleting a role_representation but providing it reduces the number + of API calls required. +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Dušan Marković (@bratwurzt) + - Ivan Kokalović (@koke1997) +""" + +EXAMPLES = r""" +- name: Map a realm role to a user, authentication with credentials + middleware_automation.keycloak.keycloak_user_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + uid: user_uid + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a user, authentication with credentials + middleware_automation.keycloak.keycloak_user_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + client_id: client1 + uid: user_uid + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a service account user for a client, authentication with credentials + middleware_automation.keycloak.keycloak_user_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + client_id: client1 + service_account_user_client_id: clientIdOfServiceAccount + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a user, authentication with token + middleware_automation.keycloak.keycloak_user_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + state: present + client_id: client1 + target_username: user1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Unmap client role from a user + middleware_automation.keycloak.keycloak_user_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: absent + client_id: client1 + uid: 70e3ae72-96b6-11e6-9056-9737fd4d0764 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Role role1 assigned to user user1." + +proposed: + description: Representation of proposed client role mapping. + returned: always + type: dict + sample: {"clientId": "test"} + +existing: + description: + - Representation of existing client role mapping. + - The sample is truncated. + returned: always + type: dict + sample: + { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256" + } + } + +end_state: + description: + - Representation of client role mapping after module execution. + - The sample is truncated. + returned: on success + type: dict + sample: + { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256" + } + } +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + roles_spec = dict( + name=dict(type="str"), + id=dict(type="str"), + ) + + meta_args = dict( + state=dict(default="present", choices=["present", "absent"]), + realm=dict(default="master"), + uid=dict(type="str"), + target_username=dict(type="str"), + service_account_user_client_id=dict(type="str"), + cid=dict(type="str"), + client_id=dict(type="str"), + roles=dict(type="list", elements="dict", options=roles_spec), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [ + ["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"], + ["uid", "target_username", "service_account_user_client_id"], + ] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + state = module.params.get("state") + cid = module.params.get("cid") + client_id = module.params.get("client_id") + uid = module.params.get("uid") + target_username = module.params.get("target_username") + service_account_user_client_id = module.params.get("service_account_user_client_id") + roles = module.params.get("roles") + + # Check the parameters + if uid is None and target_username is None and service_account_user_client_id is None: + module.fail_json( + msg="Either the `target_username`, `uid` or `service_account_user_client_id` has to be specified." + ) + + # Get the potential missing parameters + if uid is None and service_account_user_client_id is None: + user_rep = kc.get_user_by_username(username=target_username, realm=realm) + if user_rep is not None: + uid = user_rep.get("id") + else: + module.fail_json(msg=f"Could not fetch user for username {target_username}:") + else: + if uid is None and target_username is None: + user_rep = kc.get_service_account_user_by_client_id(client_id=service_account_user_client_id, realm=realm) + if user_rep is not None: + uid = user_rep["id"] + else: + module.fail_json(msg=f"Could not fetch service-account-user for client_id {target_username}:") + + if cid is None and client_id is not None: + cid = kc.get_client_id(client_id=client_id, realm=realm) + if cid is None: + module.fail_json(msg=f"Could not fetch client {client_id}:") + if roles is None: + module.exit_json(msg="Nothing to do (no roles specified).") + else: + for role in roles: + if role.get("name") is None and role.get("id") is None: + module.fail_json(msg="Either the `name` or `id` has to be specified on each role.") + # Fetch missing role_id + if role.get("id") is None: + if cid is None: + role_id = kc.get_realm_role(name=role.get("name"), realm=realm)["id"] + else: + role_id = kc.get_client_role_id_by_name(cid=cid, name=role.get("name"), realm=realm) + if role_id is not None: + role["id"] = role_id + else: + module.fail_json( + msg=f"Could not fetch role {role.get('name')} for client_id {client_id} or realm {realm}" + ) + # Fetch missing role_name + else: + if cid is None: + role_rep = kc.get_realm_user_rolemapping_by_id(uid=uid, rid=role.get("id"), realm=realm) + if role_rep is not None: + role["name"] = role_rep["name"] + else: + role_rep = kc.get_client_user_rolemapping_by_id(uid=uid, cid=cid, rid=role.get("id"), realm=realm) + if role_rep is not None: + role["name"] = role_rep["name"] + if role.get("name") is None: + module.fail_json( + msg=f"Could not fetch role {role.get('id')} for client_id {client_id} or realm {realm}" + ) + + # Get effective role mappings + if cid is None: + available_roles_before = kc.get_realm_user_available_rolemappings(uid=uid, realm=realm) + assigned_roles_before = kc.get_realm_user_composite_rolemappings(uid=uid, realm=realm) + else: + available_roles_before = kc.get_client_user_available_rolemappings(uid=uid, cid=cid, realm=realm) + assigned_roles_before = kc.get_client_user_composite_rolemappings(uid=uid, cid=cid, realm=realm) + + result["existing"] = assigned_roles_before + result["proposed"] = roles + + update_roles = [] + for role in roles: + # Fetch roles to assign if state present + if state == "present": + for available_role in available_roles_before: + if role.get("name") == available_role.get("name"): + update_roles.append( + { + "id": role.get("id"), + "name": role.get("name"), + } + ) + # Fetch roles to remove if state absent + else: + for assigned_role in assigned_roles_before: + if role.get("name") == assigned_role.get("name"): + update_roles.append( + { + "id": role.get("id"), + "name": role.get("name"), + } + ) + + if len(update_roles): + if state == "present": + # Assign roles + result["changed"] = True + if module._diff: + result["diff"] = dict(before={"roles": assigned_roles_before}, after={"roles": update_roles}) + if module.check_mode: + module.exit_json(**result) + kc.add_user_rolemapping(uid=uid, cid=cid, role_rep=update_roles, realm=realm) + result["msg"] = f"Roles {update_roles} assigned to userId {uid}." + if cid is None: + assigned_roles_after = kc.get_realm_user_composite_rolemappings(uid=uid, realm=realm) + else: + assigned_roles_after = kc.get_client_user_composite_rolemappings(uid=uid, cid=cid, realm=realm) + result["end_state"] = assigned_roles_after + module.exit_json(**result) + else: + # Remove mapping of role + result["changed"] = True + if module._diff: + result["diff"] = dict(before={"roles": assigned_roles_before}, after={"roles": update_roles}) + if module.check_mode: + module.exit_json(**result) + kc.delete_user_rolemapping(uid=uid, cid=cid, role_rep=update_roles, realm=realm) + result["msg"] = f"Roles {update_roles} removed from userId {uid}." + if cid is None: + assigned_roles_after = kc.get_realm_user_composite_rolemappings(uid=uid, realm=realm) + else: + assigned_roles_after = kc.get_client_user_composite_rolemappings(uid=uid, cid=cid, realm=realm) + result["end_state"] = assigned_roles_after + module.exit_json(**result) + # Do nothing + else: + result["changed"] = False + result["msg"] = f"Nothing to do, roles {roles} are correctly mapped to user for username {target_username}." + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_userprofile.py b/plugins/modules/keycloak_userprofile.py new file mode 100644 index 0000000..d427db9 --- /dev/null +++ b/plugins/modules/keycloak_userprofile.py @@ -0,0 +1,822 @@ + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_userprofile + +short_description: Allows managing Keycloak User Profiles + +description: + - This module allows you to create, update, or delete Keycloak User Profiles using the Keycloak API. You can also customize + the "Unmanaged Attributes" with it. + - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation + at U(https://www.keycloak.org/docs-api/24.0.5/rest-api/index.html). For compatibility reasons, the module also accepts + the camelCase versions of the options. +version_added: "3.0.0" + +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.0.0" + +options: + state: + description: + - State of the User Profile provider. + - On V(present), the User Profile provider is created if it does not yet exist, or updated with the parameters you provide. + - On V(absent), the User Profile provider is removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + parent_id: + description: + - The parent ID of the realm key. In practice the ID (name) of the realm. + aliases: + - parentId + - realm + type: str + required: true + + provider_id: + description: + - The name of the provider ID for the key (supported value is V(declarative-user-profile)). + aliases: + - providerId + choices: ['declarative-user-profile'] + default: 'declarative-user-profile' + type: str + + provider_type: + description: + - Component type for User Profile (only supported value is V(org.keycloak.userprofile.UserProfileProvider)). + aliases: + - providerType + choices: ['org.keycloak.userprofile.UserProfileProvider'] + default: org.keycloak.userprofile.UserProfileProvider + type: str + + config: + description: + - The configuration of the User Profile Provider. + type: dict + suboptions: + kc_user_profile_config: + description: + - Define a declarative User Profile. See EXAMPLES for more context. + aliases: + - kcUserProfileConfig + type: list + elements: dict + suboptions: + attributes: + description: + - A list of attributes to be included in the User Profile. + type: list + elements: dict + suboptions: + name: + description: + - The name of the attribute. + type: str + required: true + + display_name: + description: + - The display name of the attribute. + aliases: + - displayName + type: str + required: true + + validations: + description: + - The validations to be applied to the attribute. + type: dict + suboptions: + length: + description: + - The length validation for the attribute. + type: dict + suboptions: + min: + description: + - The minimum length of the attribute. + type: int + max: + description: + - The maximum length of the attribute. + type: int + required: true + + email: + description: + - The email validation for the attribute. + type: dict + + username_prohibited_characters: + description: + - The prohibited characters validation for the username attribute. + type: dict + aliases: + - usernameProhibitedCharacters + + up_username_not_idn_homograph: + description: + - The validation to prevent IDN homograph attacks in usernames. + type: dict + aliases: + - upUsernameNotIdnHomograph + + person_name_prohibited_characters: + description: + - The prohibited characters validation for person name attributes. + type: dict + aliases: + - personNameProhibitedCharacters + + uri: + description: + - The URI validation for the attribute. + type: dict + + pattern: + description: + - The pattern validation for the attribute using regular expressions. + type: dict + + options: + description: + - Validation to ensure the attribute matches one of the provided options. + type: dict + + integer: + description: + - The integer validation for the attribute. + type: dict + + double: + description: + - The double validation for the attribute. + type: dict + + iso_date: + description: + - The iso-date validation for the attribute. + type: dict + aliases: + - isoDate + + local_date: + description: + - The local-date validation for the attribute. + type: dict + aliases: + - localDate + + multivalued: + description: + - The multivalued validation for the attribute. + type: dict + suboptions: + min: + description: + - The minimum amount of values of the attribute. + type: int + max: + description: + - The maximum amount of values of the attribute. + type: int + required: true + + annotations: + description: + - Annotations for the attribute. + type: dict + + group: + description: + - Specifies the User Profile group where this attribute is added. + type: str + + permissions: + description: + - The permissions for viewing and editing the attribute. + type: dict + suboptions: + view: + description: + - The roles that can view the attribute. + - Supported values are V(admin) and V(user). + type: list + elements: str + default: + - admin + - user + + edit: + description: + - The roles that can edit the attribute. + - Supported values are V(admin) and V(user). + type: list + elements: str + default: + - admin + - user + + multivalued: + description: + - Whether the attribute can have multiple values. + type: bool + default: false + + required: + description: + - The roles that require this attribute. + type: dict + suboptions: + roles: + description: + - The roles for which this attribute is required. + - Supported values are V(admin) and V(user). + type: list + elements: str + default: + - user + + selector: + description: + - Selector when the attribute should be added. + type: dict + suboptions: + scopes: + description: + - Scopes to which the attribute should be added. + type: list + elements: str + + groups: + description: + - A list of attribute groups to be included in the User Profile. + type: list + elements: dict + suboptions: + name: + description: + - The name of the group. + type: str + required: true + + display_header: + description: + - The display header for the group. + aliases: + - displayHeader + type: str + required: true + + display_description: + description: + - The display description for the group. + aliases: + - displayDescription + type: str + + annotations: + description: + - The annotations included in the group. + type: dict + + unmanaged_attribute_policy: + description: + - Policy for unmanaged attributes. + aliases: + - unmanagedAttributePolicy + type: str + choices: + - ENABLED + - ADMIN_EDIT + - ADMIN_VIEW + +notes: + - Currently, only a single V(declarative-user-profile) entry is supported for O(provider_id) (design of the Keyckoak API). + However, there can be multiple O(config.kc_user_profile_config[].attributes[]) entries. +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Eike Waldt (@yeoldegrove) +""" + +EXAMPLES = r""" +- name: Create a Declarative User Profile with default settings + middleware_automation.keycloak.keycloak_userprofile: + state: present + parent_id: master + config: + kc_user_profile_config: + - attributes: + - name: username + displayName: ${username} + validations: + length: + min: 3 + max: 255 + username_prohibited_characters: {} + up_username_not_idn_homograph: {} + annotations: {} + permissions: + view: + - admin + - user + edit: [] + multivalued: false + - name: email + displayName: ${email} + validations: + email: {} + length: + max: 255 + annotations: {} + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false + - name: firstName + displayName: ${firstName} + validations: + length: + max: 255 + person_name_prohibited_characters: {} + annotations: {} + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false + - name: lastName + displayName: ${lastName} + validations: + length: + max: 255 + person_name_prohibited_characters: {} + annotations: {} + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false + - name: testAttribute + displayName: ${testAttribute} + validations: + integer: + min: 0 + max: 255 + annotations: {} + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false + groups: + - name: user-metadata + displayHeader: User metadata + displayDescription: Attributes, which refer to user metadata + annotations: {} + +- name: Delete a Keycloak User Profile Provider + keycloak_userprofile: + state: absent + parent_id: master + +# Unmanaged attributes are user attributes not explicitly defined in the User Profile +# configuration. By default, unmanaged attributes are "Disabled" and are not +# available from any context such as registration, account, and the +# administration console. By setting "Enabled", unmanaged attributes are fully +# recognized by the server and accessible through all contexts, useful if you are +# starting migrating an existing realm to the declarative User Profile +# and you don't have yet all user attributes defined in the User Profile configuration. +- name: Enable Unmanaged Attributes + middleware_automation.keycloak.keycloak_userprofile: + state: present + parent_id: master + config: + kc_user_profile_config: + - unmanagedAttributePolicy: ENABLED + +# By setting "Only administrators can write", unmanaged attributes can be managed +# only through the administration console and API, useful if you have already +# defined any custom attribute that can be managed by users but you are unsure +# about adding other attributes that should only be managed by administrators. +- name: Enable ADMIN_EDIT on Unmanaged Attributes + middleware_automation.keycloak.keycloak_userprofile: + state: present + parent_id: master + config: + kc_user_profile_config: + - unmanagedAttributePolicy: ADMIN_EDIT + +# By setting `Only administrators can view`, unmanaged attributes are read-only +# and only available through the administration console and API. +- name: Enable ADMIN_VIEW on Unmanaged Attributes + middleware_automation.keycloak.keycloak_userprofile: + state: present + parent_id: master + config: + kc_user_profile_config: + - unmanagedAttributePolicy: ADMIN_VIEW +""" + +RETURN = r""" +msg: + description: The output message generated by the module. + returned: always + type: str + sample: UserProfileProvider created successfully +data: + description: The data returned by the Keycloak API. + returned: when state is present + type: dict +""" + +import json +from copy import deepcopy +from urllib.parse import urlencode + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + KeycloakError, + camel, + get_token, + keycloak_argument_spec, +) + + +def remove_null_values(data): + if isinstance(data, dict): + # Recursively remove null values from dictionaries + return {k: remove_null_values(v) for k, v in data.items() if v is not None} + elif isinstance(data, list): + # Recursively remove null values from lists + return [remove_null_values(item) for item in data if item is not None] + else: + # Return the data if it is neither a dictionary nor a list + return data + + +def camel_recursive(data): + if isinstance(data, dict): + # Convert keys to camelCase and apply recursively + return {camel(k): camel_recursive(v) for k, v in data.items()} + elif isinstance(data, list): + # Apply camelCase conversion to each item in the list + return [camel_recursive(item) for item in data] + else: + # Return the data as-is if it is not a dict or list + return data + + +def main(): + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(type="str", choices=["present", "absent"], default="present"), + parent_id=dict(type="str", aliases=["parentId", "realm"], required=True), + provider_id=dict( + type="str", aliases=["providerId"], default="declarative-user-profile", choices=["declarative-user-profile"] + ), + provider_type=dict( + type="str", + aliases=["providerType"], + default="org.keycloak.userprofile.UserProfileProvider", + choices=["org.keycloak.userprofile.UserProfileProvider"], + ), + config=dict( + type="dict", + options={ + "kc_user_profile_config": dict( + type="list", + aliases=["kcUserProfileConfig"], + elements="dict", + options={ + "attributes": dict( + type="list", + elements="dict", + options={ + "name": dict(type="str", required=True), + "display_name": dict(type="str", aliases=["displayName"], required=True), + "validations": dict( + type="dict", + options={ + "length": dict( + type="dict", + options={"min": dict(type="int"), "max": dict(type="int", required=True)}, + ), + "email": dict(type="dict"), + "username_prohibited_characters": dict( + type="dict", aliases=["usernameProhibitedCharacters"] + ), + "up_username_not_idn_homograph": dict( + type="dict", aliases=["upUsernameNotIdnHomograph"] + ), + "person_name_prohibited_characters": dict( + type="dict", aliases=["personNameProhibitedCharacters"] + ), + "uri": dict(type="dict"), + "pattern": dict(type="dict"), + "options": dict(type="dict"), + "integer": dict(type="dict"), + "double": dict(type="dict"), + "iso_date": dict(type="dict", aliases=["isoDate"]), + "local_date": dict(type="dict", aliases=["localDate"]), + "multivalued": dict( + type="dict", + options={ + "min": dict(type="int", required=False), + "max": dict(type="int", required=True), + }, + ), + }, + ), + "annotations": dict(type="dict"), + "group": dict(type="str"), + "permissions": dict( + type="dict", + options={ + "view": dict(type="list", elements="str", default=["admin", "user"]), + "edit": dict(type="list", elements="str", default=["admin", "user"]), + }, + ), + "multivalued": dict(type="bool", default=False), + "required": dict( + type="dict", options={"roles": dict(type="list", elements="str", default=["user"])} + ), + "selector": dict(type="dict", options={"scopes": dict(type="list", elements="str")}), + }, + ), + "groups": dict( + type="list", + elements="dict", + options={ + "name": dict(type="str", required=True), + "display_header": dict(type="str", aliases=["displayHeader"], required=True), + "display_description": dict(type="str", aliases=["displayDescription"]), + "annotations": dict(type="dict"), + }, + ), + "unmanaged_attribute_policy": dict( + type="str", + aliases=["unmanagedAttributePolicy"], + choices=["ENABLED", "ADMIN_EDIT", "ADMIN_VIEW"], + ), + }, + ) + }, + ), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + # Initialize the result object. Only "changed" seems to have special + # meaning for Ansible. + result = dict(changed=False, msg="", end_state={}, diff=dict(before={}, after={})) + + # This will include the current state of the realm userprofile if it is already + # present. This is only used for diff-mode. + before_realm_userprofile = {} + before_realm_userprofile["config"] = {} + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + params_to_ignore = list(keycloak_argument_spec().keys()) + ["state"] + + # Filter and map the parameters names that apply to the role + component_params = [x for x in module.params if x not in params_to_ignore and module.params.get(x) is not None] + + # Build a proposed changeset from parameters given to this module + changeset = {} + + # Build the changeset with proper JSON serialization for kc_user_profile_config + config = module.params.get("config") + changeset["config"] = {} + + # Generate a JSON payload for Keycloak Admin API from the module + # parameters. Parameters that do not belong to the JSON payload (e.g. + # "state" or "auth_keycloal_url") have been filtered away earlier (see + # above). + # + # This loop converts Ansible module parameters (snake-case) into + # Keycloak-compatible format (camel-case). For example proider_id + # becomes providerId. It also handles some special cases, e.g. aliases. + for component_param in component_params: + # realm/parent_id parameter + if component_param == "realm" or component_param == "parent_id": + changeset["parent_id"] = module.params.get(component_param) + changeset.pop(component_param, None) + # complex parameters in config suboptions + elif component_param == "config": + for config_param in config: + # special parameter kc_user_profile_config + if config_param in ("kcUserProfileConfig", "kc_user_profile_config"): + config_param_org = config_param + # rename parameter to be accepted by Keycloak API + config_param = "kc.user.profile.config" + # make sure no null values are passed to Keycloak API + kc_user_profile_config = remove_null_values(config[config_param_org]) + changeset[camel(component_param)][config_param] = [] + if len(kc_user_profile_config) > 0: + # convert aliases to camelCase + kc_user_profile_config = camel_recursive(kc_user_profile_config) + # rename validations to be accepted by Keycloak API + if "attributes" in kc_user_profile_config[0]: + for attribute in kc_user_profile_config[0]["attributes"]: + if "validations" in attribute: + if "usernameProhibitedCharacters" in attribute["validations"]: + attribute["validations"]["username-prohibited-characters"] = attribute[ + "validations" + ].pop("usernameProhibitedCharacters") + if "upUsernameNotIdnHomograph" in attribute["validations"]: + attribute["validations"]["up-username-not-idn-homograph"] = attribute[ + "validations" + ].pop("upUsernameNotIdnHomograph") + if "personNameProhibitedCharacters" in attribute["validations"]: + attribute["validations"]["person-name-prohibited-characters"] = attribute[ + "validations" + ].pop("personNameProhibitedCharacters") + if "isoDate" in attribute["validations"]: + attribute["validations"]["iso-date"] = attribute["validations"].pop("isoDate") + if "localDate" in attribute["validations"]: + attribute["validations"]["local-date"] = attribute["validations"].pop( + "localDate" + ) + changeset[camel(component_param)][config_param].append(kc_user_profile_config[0]) + # usual camelCase parameters + else: + changeset[camel(component_param)][camel(config_param)] = [] + raw_value = module.params.get(component_param)[config_param] + if isinstance(raw_value, bool): + value = str(raw_value).lower() + else: + value = raw_value # Directly use the raw value + changeset[camel(component_param)][camel(config_param)].append(value) + # usual parameters + else: + new_param_value = module.params.get(component_param) + changeset[camel(component_param)] = new_param_value + + # Make it easier to refer to current module parameters + state = module.params.get("state") + parent_id = module.params.get("parent_id") + provider_type = module.params.get("provider_type") + provider_id = module.params.get("provider_id") + + # Make a deep copy of the changeset. This is use when determining + # changes to the current state. + changeset_copy = deepcopy(changeset) + + # Get a list of all Keycloak components that are of userprofile provider type. + realm_userprofiles = kc.get_components(urlencode(dict(type=provider_type)), parent_id) + + # If this component is present get its userprofile ID. Confusingly the userprofile ID is + # also known as the Provider ID. + userprofile_id = None + + # Track individual parameter changes + changes = "" + + # This tells Ansible whether the userprofile was changed (added, removed, modified) + result["changed"] = False + + # Loop through the list of components. If we encounter a component whose + # name matches the value of the name parameter then assume the userprofile is + # already present. + for userprofile in realm_userprofiles: + if provider_id == "declarative-user-profile": + userprofile_id = userprofile["id"] + changeset["id"] = userprofile_id + changeset_copy["id"] = userprofile_id + + # keycloak returns kc.user.profile.config as a single JSON formatted string, so we have to deserialize it + if "config" in userprofile and "kc.user.profile.config" in userprofile["config"]: + userprofile["config"]["kc.user.profile.config"][0] = json.loads( + userprofile["config"]["kc.user.profile.config"][0] + ) + + # Compare top-level parameters + for param in changeset: + before_realm_userprofile[param] = userprofile[param] + + if changeset_copy[param] != userprofile[param] and param != "config": + changes += f"{param}: {userprofile[param]} -> {changeset_copy[param]}, " + result["changed"] = True + + # Compare parameters under the "config" userprofile + for p, v in changeset_copy["config"].items(): + before_realm_userprofile["config"][p] = userprofile["config"][p] + if v != userprofile["config"][p]: + changes += f"config.{p}: {userprofile['config'][p]} -> {v}, " + result["changed"] = True + + # Check all the possible states of the resource and do what is needed to + # converge current state with desired state (create, update or delete + # the userprofile). + + # keycloak expects kc.user.profile.config as a single JSON formatted string, so we have to serialize it + if "config" in changeset and "kc.user.profile.config" in changeset["config"]: + changeset["config"]["kc.user.profile.config"][0] = json.dumps(changeset["config"]["kc.user.profile.config"][0]) + if userprofile_id and state == "present": + if result["changed"]: + if module._diff: + result["diff"] = dict(before=before_realm_userprofile, after=changeset_copy) + + if module.check_mode: + result["msg"] = f"Userprofile {provider_id} would be changed: {changes.strip(', ')}" + else: + kc.update_component(changeset, parent_id) + result["msg"] = f"Userprofile {provider_id} changed: {changes.strip(', ')}" + else: + result["msg"] = f"Userprofile {provider_id} was in sync" + + result["end_state"] = changeset_copy + elif userprofile_id and state == "absent": + if module._diff: + result["diff"] = dict(before=before_realm_userprofile, after={}) + + if module.check_mode: + result["changed"] = True + result["msg"] = f"Userprofile {provider_id} would be deleted" + else: + kc.delete_component(userprofile_id, parent_id) + result["changed"] = True + result["msg"] = f"Userprofile {provider_id} deleted" + + result["end_state"] = {} + elif not userprofile_id and state == "present": + if module._diff: + result["diff"] = dict(before={}, after=changeset_copy) + + if module.check_mode: + result["changed"] = True + result["msg"] = f"Userprofile {provider_id} would be created" + else: + kc.create_component(changeset, parent_id) + result["changed"] = True + result["msg"] = f"Userprofile {provider_id} created" + + result["end_state"] = changeset_copy + elif not userprofile_id and state == "absent": + result["changed"] = False + result["msg"] = f"Userprofile {provider_id} not present" + result["end_state"] = {} + + module.exit_json(**result) + + +if __name__ == "__main__": + main()