From bdc090de64d96453921598aa7beaa6dbbcdc7722 Mon Sep 17 00:00:00 2001 From: Harsha Cherukuri Date: Wed, 27 May 2026 14:47:21 -0400 Subject: [PATCH 1/2] Move community.general keycloak modules into keycloak collection --- .github/workflows/ci.yml | 2 +- README.md | 105 +- meta/runtime.yml | 43 + molecule/keycloak_modules/converge.yml | 2 + molecule/keycloak_modules/molecule.yml | 51 + molecule/keycloak_modules/prepare.yml | 2 + molecule/keycloak_modules/verify.yml | 536 ++++ molecule/quarkus_ha_remote/converge.yml | 8 +- plugins/doc_fragments/actiongroup_keycloak.py | 21 + plugins/doc_fragments/keycloak.py | 8 +- .../identity/keycloak/_keycloak_utils.py | 32 + .../identity/keycloak/keycloak.py | 2819 +++++++++-------- .../keycloak/keycloak_clientsecret.py | 76 + plugins/modules/keycloak_authentication.py | 516 +++ .../modules/keycloak_authentication_flow.py | 3 + ...eycloak_authentication_required_actions.py | 461 +++ plugins/modules/keycloak_authentication_v2.py | 1038 ++++++ .../keycloak_authz_authorization_scope.py | 278 ++ .../modules/keycloak_authz_custom_policy.py | 211 ++ plugins/modules/keycloak_authz_permission.py | 441 +++ .../modules/keycloak_authz_permission_info.py | 176 + plugins/modules/keycloak_client.py | 1861 ++++++----- .../modules/keycloak_client_rolemapping.py | 412 +++ plugins/modules/keycloak_client_rolescope.py | 285 ++ plugins/modules/keycloak_client_scope.py | 3 + plugins/modules/keycloak_clientscope_type.py | 313 ++ plugins/modules/keycloak_clientsecret_info.py | 166 + .../keycloak_clientsecret_regenerate.py | 176 + plugins/modules/keycloak_clienttemplate.py | 471 +++ plugins/modules/keycloak_component.py | 326 ++ plugins/modules/keycloak_component_info.py | 169 + plugins/modules/keycloak_group.py | 492 +++ plugins/modules/keycloak_identity_provider.py | 775 +++++ plugins/modules/keycloak_realm.py | 1629 ++++++---- plugins/modules/keycloak_realm_info.py | 130 + plugins/modules/keycloak_realm_key.py | 1058 +++++++ .../keycloak_realm_keys_metadata_info.py | 135 + .../modules/keycloak_realm_localization.py | 398 +++ plugins/modules/keycloak_realm_rolemapping.py | 390 +++ plugins/modules/keycloak_role.py | 443 +-- plugins/modules/keycloak_user.py | 561 ++++ .../keycloak_user_execute_actions_email.py | 204 ++ plugins/modules/keycloak_user_federation.py | 1666 +++++----- plugins/modules/keycloak_user_rolemapping.py | 440 +++ plugins/modules/keycloak_userprofile.py | 822 +++++ 45 files changed, 16390 insertions(+), 3764 deletions(-) create mode 100644 molecule/keycloak_modules/converge.yml create mode 100644 molecule/keycloak_modules/molecule.yml create mode 100644 molecule/keycloak_modules/prepare.yml create mode 100644 molecule/keycloak_modules/verify.yml create mode 100644 plugins/doc_fragments/actiongroup_keycloak.py create mode 100644 plugins/module_utils/identity/keycloak/_keycloak_utils.py create mode 100644 plugins/module_utils/identity/keycloak/keycloak_clientsecret.py create mode 100644 plugins/modules/keycloak_authentication.py create mode 100644 plugins/modules/keycloak_authentication_required_actions.py create mode 100644 plugins/modules/keycloak_authentication_v2.py create mode 100644 plugins/modules/keycloak_authz_authorization_scope.py create mode 100644 plugins/modules/keycloak_authz_custom_policy.py create mode 100644 plugins/modules/keycloak_authz_permission.py create mode 100644 plugins/modules/keycloak_authz_permission_info.py create mode 100644 plugins/modules/keycloak_client_rolemapping.py create mode 100644 plugins/modules/keycloak_client_rolescope.py create mode 100644 plugins/modules/keycloak_clientscope_type.py create mode 100644 plugins/modules/keycloak_clientsecret_info.py create mode 100644 plugins/modules/keycloak_clientsecret_regenerate.py create mode 100644 plugins/modules/keycloak_clienttemplate.py create mode 100644 plugins/modules/keycloak_component.py create mode 100644 plugins/modules/keycloak_component_info.py create mode 100644 plugins/modules/keycloak_group.py create mode 100644 plugins/modules/keycloak_identity_provider.py create mode 100644 plugins/modules/keycloak_realm_info.py create mode 100644 plugins/modules/keycloak_realm_key.py create mode 100644 plugins/modules/keycloak_realm_keys_metadata_info.py create mode 100644 plugins/modules/keycloak_realm_localization.py create mode 100644 plugins/modules/keycloak_realm_rolemapping.py create mode 100644 plugins/modules/keycloak_user.py create mode 100644 plugins/modules/keycloak_user_execute_actions_email.py create mode 100644 plugins/modules/keycloak_user_rolemapping.py create mode 100644 plugins/modules/keycloak_userprofile.py 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() From 123906d73957d378731df3f16008370a3c8b0bed Mon Sep 17 00:00:00 2001 From: Harsha Cherukuri Date: Sat, 30 May 2026 09:16:56 -0400 Subject: [PATCH 2/2] Added PRs #11841 and #11749, and updated version references from community.general to Keycloak. --- galaxy.yml | 1 + meta/runtime.yml | 1 + molecule/keycloak_modules/verify.yml | 121 ++++++++ .../identity/keycloak/keycloak.py | 174 ++++++++++- plugins/modules/keycloak_authentication.py | 2 + ...eycloak_authentication_required_actions.py | 2 + plugins/modules/keycloak_authentication_v2.py | 1 + .../keycloak_authz_authorization_scope.py | 2 + .../modules/keycloak_authz_custom_policy.py | 2 + plugins/modules/keycloak_authz_permission.py | 2 + .../modules/keycloak_authz_permission_info.py | 2 + plugins/modules/keycloak_client.py | 1 + .../modules/keycloak_client_rolemapping.py | 2 + plugins/modules/keycloak_client_rolescope.py | 2 + plugins/modules/keycloak_client_scope.py | 1 + .../keycloak_clientscope_rolemappings.py | 282 ++++++++++++++++++ plugins/modules/keycloak_clientscope_type.py | 2 + plugins/modules/keycloak_clientsecret_info.py | 2 + .../keycloak_clientsecret_regenerate.py | 2 + plugins/modules/keycloak_clienttemplate.py | 1 + plugins/modules/keycloak_component.py | 2 + plugins/modules/keycloak_component_info.py | 2 + plugins/modules/keycloak_group.py | 1 + plugins/modules/keycloak_identity_provider.py | 2 + plugins/modules/keycloak_realm.py | 2 + plugins/modules/keycloak_realm_info.py | 1 + plugins/modules/keycloak_realm_key.py | 2 + .../keycloak_realm_keys_metadata_info.py | 2 + .../modules/keycloak_realm_localization.py | 1 + plugins/modules/keycloak_realm_rolemapping.py | 2 + plugins/modules/keycloak_role.py | 2 + plugins/modules/keycloak_user.py | 170 ++++++----- .../keycloak_user_execute_actions_email.py | 1 + plugins/modules/keycloak_user_federation.py | 2 + plugins/modules/keycloak_user_rolemapping.py | 2 + plugins/modules/keycloak_userprofile.py | 2 + 36 files changed, 721 insertions(+), 80 deletions(-) create mode 100644 plugins/modules/keycloak_clientscope_rolemappings.py diff --git a/galaxy.yml b/galaxy.yml index 3ae9809..0596571 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -8,6 +8,7 @@ authors: - Guido Grazioli - Pavan Kumar Motaparthi - Helmut Wolf + - Harsha Cherukuri description: Install and configure a keycloak, or Red Hat Single Sign-on, service. license_file: "LICENSE" tags: diff --git a/meta/runtime.yml b/meta/runtime.yml index 64490eb..e2907d9 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -15,6 +15,7 @@ action_groups: - keycloak_client_rolescope - keycloak_client_scope - keycloak_clientscope_type + - keycloak_clientscope_rolemappings - keycloak_clientsecret_info - keycloak_clientsecret_regenerate - keycloak_clienttemplate diff --git a/molecule/keycloak_modules/verify.yml b/molecule/keycloak_modules/verify.yml index 8194ade..867f687 100644 --- a/molecule/keycloak_modules/verify.yml +++ b/molecule/keycloak_modules/verify.yml @@ -38,6 +38,7 @@ - keycloak_client_rolescope - keycloak_client_scope - keycloak_clientscope_type + - keycloak_clientscope_rolemappings - keycloak_clientsecret_info - keycloak_clientsecret_regenerate - keycloak_clienttemplate @@ -303,6 +304,109 @@ - "{{ role }}" state: present + - name: keycloak_clientscope_rolemappings — map client roles to clientscope + middleware_automation.keycloak.keycloak_clientscope_rolemappings: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + clientscope_id: "{{ scope }}" + role_names: + - "{{ client_role }}" + register: clientscope_rolemappings_result + + - name: Assert clientscope role mappings were created + ansible.builtin.assert: + that: + - clientscope_rolemappings_result is changed + - clientscope_rolemappings_result.end_state | length == 1 + + - name: keycloak_clientscope_rolemappings — remap client role (idempotency) + middleware_automation.keycloak.keycloak_clientscope_rolemappings: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + clientscope_id: "{{ scope }}" + role_names: + - "{{ client_role }}" + register: clientscope_rolemappings_idempotent_result + + - name: Assert clientscope role mappings are idempotent + ansible.builtin.assert: + that: + - clientscope_rolemappings_idempotent_result is not changed + - clientscope_rolemappings_idempotent_result.end_state | length == 1 + + - name: keycloak_clientscope_rolemappings — map realm role to clientscope + middleware_automation.keycloak.keycloak_clientscope_rolemappings: + realm: "{{ target_realm }}" + clientscope_id: "{{ scope }}" + role_names: + - "{{ role }}" + register: clientscope_realm_rolemappings_result + + - name: Assert realm role was mapped to clientscope + ansible.builtin.assert: + that: + - clientscope_realm_rolemappings_result is changed + - clientscope_realm_rolemappings_result.end_state | length == 1 + + - name: keycloak_user — set email_verified explicitly + middleware_automation.keycloak.keycloak_user: + realm: "{{ target_realm }}" + username: "{{ user }}" + email_verified: true + state: present + register: user_email_verified_result + + - name: Assert email_verified was set + ansible.builtin.assert: + that: + - user_email_verified_result is changed + - user_email_verified_result.end_state.emailVerified == true + + - name: keycloak_user — leave email_verified unchanged with no_defaults + middleware_automation.keycloak.keycloak_user: + realm: "{{ target_realm }}" + username: "{{ user }}" + email_verified_behavior: no_defaults + state: present + register: user_email_verified_idempotent_result + + - name: Assert email_verified is unchanged + ansible.builtin.assert: + that: + - user_email_verified_idempotent_result is not changed + - user_email_verified_idempotent_result.end_state.emailVerified == true + + - name: keycloak_user — set required actions + middleware_automation.keycloak.keycloak_user: + realm: "{{ target_realm }}" + username: "{{ user }}" + required_actions: + - UPDATE_PASSWORD + - VERIFY_EMAIL + state: present + register: user_required_actions_result + + - name: Assert required actions were set + ansible.builtin.assert: + that: + - user_required_actions_result is changed + - "'UPDATE_PASSWORD' in user_required_actions_result.end_state.requiredActions" + - "'VERIFY_EMAIL' in user_required_actions_result.end_state.requiredActions" + + - name: keycloak_user — leave required actions unchanged when omitted + middleware_automation.keycloak.keycloak_user: + realm: "{{ target_realm }}" + username: "{{ user }}" + state: present + register: user_required_actions_idempotent_result + + - name: Assert required actions are unchanged + ansible.builtin.assert: + that: + - user_required_actions_idempotent_result is not changed + - "'UPDATE_PASSWORD' in user_required_actions_idempotent_result.end_state.requiredActions" + - "'VERIFY_EMAIL' in user_required_actions_idempotent_result.end_state.requiredActions" + - name: keycloak_clientsecret_info — read client secret middleware_automation.keycloak.keycloak_clientsecret_info: realm: "{{ target_realm }}" @@ -413,6 +517,23 @@ name: "{{ authz_scope }}" state: absent + - name: keycloak_clientscope_rolemappings — remove realm role from clientscope + middleware_automation.keycloak.keycloak_clientscope_rolemappings: + realm: "{{ target_realm }}" + clientscope_id: "{{ scope }}" + role_names: + - "{{ role }}" + state: absent + + - name: keycloak_clientscope_rolemappings — remove client role from clientscope + middleware_automation.keycloak.keycloak_clientscope_rolemappings: + realm: "{{ target_realm }}" + client_id: "{{ client }}" + clientscope_id: "{{ scope }}" + role_names: + - "{{ client_role }}" + state: absent + - name: keycloak_client_rolescope — remove role scope mapping middleware_automation.keycloak.keycloak_client_rolescope: realm: "{{ target_realm }}" diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 601fb58..8d19024 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -59,6 +59,9 @@ URL_GROUP_CHILDREN = "{url}/admin/realms/{realm}/groups/{groupid}/children" URL_CLIENTSCOPES = "{url}/admin/realms/{realm}/client-scopes" URL_CLIENTSCOPE = "{url}/admin/realms/{realm}/client-scopes/{id}" +URL_CLIENTSCOPE_SCOPE_MAPPINGS = "{url}/admin/realms/{realm}/client-scopes/{id}/scope-mappings" +URL_CLIENTSCOPE_SCOPE_MAPPINGS_REALM = "{url}/admin/realms/{realm}/client-scopes/{id}/scope-mappings/realm" +URL_CLIENTSCOPE_SCOPE_MAPPINGS_CLIENT = "{url}/admin/realms/{realm}/client-scopes/{id}/scope-mappings/clients/{client}" URL_CLIENTSCOPE_PROTOCOLMAPPERS = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models" URL_CLIENTSCOPE_PROTOCOLMAPPER = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models/{mapper_id}" @@ -331,41 +334,44 @@ def get_token(module_params: dict[str, t.Any]) -> dict[str, str]: return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} -def is_struct_included(struct1: object, struct2: object, exclude: Sequence[str] | None = None) -> bool: +def is_struct_included( + struct1: dict | list | bool | int | str, + struct2: dict | list | bool | int | str, + exclude: Sequence[str] | None = None, + empty_list_result: bool = True, +) -> 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. The two structure does not need to be equals for that function to return true. Each elements are compared recursively. :param struct1: - type: - dict for the initial call, can be dict, list, bool, int or str for recursive calls description: reference structure :param struct2: - type: - dict for the initial call, can be dict, list, bool, int or str for recursive calls description: structure to compare with first parameter. :param exclude: - type: - list description: Key to exclude from the comparison. - default: None + :param empty_list_result: + description: + Return this value, when struct1 is an empty list. :return: - type: - bool description: Return True if all element of dict 1 are present in dict 2, return false otherwise. """ if isinstance(struct1, list) and isinstance(struct2, list): if not struct1 and not struct2: return True + + if not struct1: + return empty_list_result + for item1 in struct1: if isinstance(item1, (list, dict)): for item2 in struct2: - if is_struct_included(item1, item2, exclude): + if is_struct_included(item1, item2, exclude, empty_list_result): break else: return False @@ -379,7 +385,7 @@ def is_struct_included(struct1: object, struct2: object, exclude: Sequence[str] try: for key in struct1: if not (exclude and key in exclude): - if not is_struct_included(struct1[key], struct2[key], exclude): + if not is_struct_included(struct1[key], struct2[key], exclude, empty_list_result): return False except KeyError: return False @@ -2937,7 +2943,7 @@ class KeycloakAPI: :return: Representation of the user. """ try: - user_url = URL_USER.format(url=self.baseurl, realm=realm, id=user_id) + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=user_id) + "?userProfileMetadata=True" userrep = json.load(self._request(user_url, method="GET")) return userrep except Exception as e: @@ -3108,11 +3114,19 @@ class KeycloakAPI: 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) + else: + self.module.fail_json( + msg=f"Could not update group membership for user {userrep['username']} in realm {realm}: group not found {group_to_add}" + ) 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) + else: + self.module.fail_json( + msg=f"Could not update group membership for user {userrep['username']} in realm {realm}: group not found {group_to_remove}" + ) return True except Exception as e: @@ -3257,6 +3271,52 @@ class KeycloakAPI: except Exception: return False + def get_all_clientscope_scope_mappings(self, clientscope_id, realm: str = "master"): + """Fetch all (realm and client) roles (scope-mappings) associated with the clientscope for a specific clientscope on the Keycloak server. + :param clientscope_id: ID of the clientscope from which to obtain the associated roles. + :param realm: Realm from which to obtain the scope. + :return: The clientscope scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS.format(url=self.baseurl, realm=realm, id=clientscope_id) + try: + return self._request_and_deserialize(client_role_scope_url, method="GET") + except Exception as e: + self.fail_request(e, msg=f"Could not fetch roles for client-scope {clientscope_id} in realm {realm}: {e}") + + def get_clientscope_scope_mappings_realm(self, clientscope_id, realm: str = "master"): + """Fetch the realm roles (scope-mappings) associated with the clientscope for a specific clientscope on the Keycloak server. + :param clientscope_id: ID of the clientscope from which to obtain the associated roles. + :param realm: Realm from which to obtain the scope. + :return: The clientscope realm scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_REALM.format( + url=self.baseurl, realm=realm, id=clientscope_id + ) + try: + return self._request_and_deserialize(client_role_scope_url, method="GET") + except Exception as e: + self.fail_request( + e, msg=f"Could not fetch realm roles for client-scope {clientscope_id} in realm {realm}: {e}" + ) + + def get_clientscope_scope_mappings_client(self, clientscope_id, client_id, realm: str = "master"): + """Fetch the client roles (scope-mappings) associated with the clientscope for a specific clientscope and client on the Keycloak server. + :param clientscope_id: ID of the clientscope from which to obtain the associated roles. + :param clientid: ID of the client from which to obtain the associated roles. + :param realm: Realm from which to obtain the scope. + :return: The clientscope client scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=clientscope_id, client=client_id + ) + try: + return self._request_and_deserialize(client_role_scope_url, method="GET") + except Exception as e: + self.fail_request( + e, + msg=f"Could not fetch client roles from client {client_id} for client-scope {clientscope_id} in realm {realm}: {e}", + ) + 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. @@ -3310,6 +3370,94 @@ class KeycloakAPI: return self.get_client_role_scope_from_client(clientid, clientscopeid, realm) + def update_clientscope_scope_mappings_client( + self, payload: list[dict], clientscope_id: str, client_id: str, realm: str = "master" + ): + """Update and fetch the client roles (scope-mappings) associated with the clientscope on the Keycloak server. + :param payload: List of client roles to be added to the scope. + :param clientscope_id: ID of the clientscope to update scope-mappings. + :param clientid: ID of the client from which to obtain the associated roles. + :param realm: Realm from which to obtain the client. + :return: The clientscope client scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=clientscope_id, client=client_id + ) + try: + self._request(client_role_scope_url, method="POST", data=json.dumps(payload)) + + except Exception as e: + self.fail_request( + e, + msg=f"Could not update scope mappings for client-scope {client_id}.{clientscope_id} in realm {realm}: {e}", + ) + + return self.get_clientscope_scope_mappings_client(clientscope_id, client_id, realm) + + def update_clientscope_scope_mappings_realm(self, payload: list[dict], clientscope_id: str, realm: str = "master"): + """Update and fetch the realm roles (scope-mappings) associated with the clientscope on the Keycloak server. + :param payload: List of realm roles to be added to the scope. + :param clientscope_id: ID of the clientscope to update scope-mappings. + :param realm: Realm from which to obtain the roles. + :return: The clientscope realm scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_REALM.format( + url=self.baseurl, realm=realm, id=clientscope_id + ) + try: + self._request(client_role_scope_url, method="POST", data=json.dumps(payload)) + + except Exception as e: + self.fail_request( + e, msg=f"Could not update scope mappings for client-scope {clientscope_id} in realm {realm}: {e}" + ) + + return self.get_clientscope_scope_mappings_realm(clientscope_id, realm) + + def delete_clientscope_scope_mappings_client( + self, payload: list[dict], clientscope_id: str, client_id: str, realm: str = "master" + ): + """Delete the client roles (scope_mappings) contained in the payload from the clientscope on the Keycloak server. + :param payload: List of roles to be deleted. + :param clientscope_id: ID of the clientscope to delete roles from scope-mappings. + :param clientid: ID of the client who owns the roles. + :param realm: Realm from which to obtain the client. + :return: The clientscope client scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=clientscope_id, client=client_id + ) + try: + self._request(client_role_scope_url, method="DELETE", data=json.dumps(payload)) + + except Exception as e: + self.fail_request( + e, + msg=f"Could not delete scope mappings for client-scope {client_id}.{clientscope_id} in realm {realm}: {e}", + ) + + return self.get_clientscope_scope_mappings_client(clientscope_id, client_id, realm) + + def delete_clientscope_scope_mappings_realm(self, payload: list[dict], clientscope_id: str, realm: str = "master"): + """Delete the realm roles (scope_mappings) contained in the payload from the clientscope on the Keycloak server. + :param payload: List of roles to be deleted. + :param clientscope_id: ID of the clientscope to delete roles from scope-mappings. + :param realm: Realm from which to obtain the roles. + :return: The clientscope realm scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_REALM.format( + url=self.baseurl, realm=realm, id=clientscope_id + ) + try: + self._request(client_role_scope_url, method="DELETE", data=json.dumps(payload)) + + except Exception as e: + self.fail_request( + e, msg=f"Could not delete scope mappings for client-scope {clientscope_id} in realm {realm}: {e}" + ) + + return self.get_clientscope_scope_mappings_realm(clientscope_id, realm) + 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. diff --git a/plugins/modules/keycloak_authentication.py b/plugins/modules/keycloak_authentication.py index 4a45ad5..d545261 100644 --- a/plugins/modules/keycloak_authentication.py +++ b/plugins/modules/keycloak_authentication.py @@ -12,6 +12,7 @@ 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. +# Originally added in community.general 3.3.0 version_added: "3.0.0" attributes: @@ -20,6 +21,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_authentication_required_actions.py b/plugins/modules/keycloak_authentication_required_actions.py index a333698..ad04583 100644 --- a/plugins/modules/keycloak_authentication_required_actions.py +++ b/plugins/modules/keycloak_authentication_required_actions.py @@ -14,6 +14,7 @@ short_description: Allows administration of Keycloak authentication required act 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. +# Originally added in community.general 7.1.0 version_added: "3.0.0" attributes: @@ -22,6 +23,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_authentication_v2.py b/plugins/modules/keycloak_authentication_v2.py index e6234a1..789c9ae 100644 --- a/plugins/modules/keycloak_authentication_v2.py +++ b/plugins/modules/keycloak_authentication_v2.py @@ -9,6 +9,7 @@ DOCUMENTATION = r""" module: keycloak_authentication_v2 short_description: Configure authentication flows in Keycloak in an idempotent and safe manner. +# Originally added in community.general 12.5.0 version_added: "3.0.0" description: - This module allows the creation, deletion, and modification of Keycloak authentication flows using the Keycloak REST API. diff --git a/plugins/modules/keycloak_authz_authorization_scope.py b/plugins/modules/keycloak_authz_authorization_scope.py index 4c80149..13f00dc 100644 --- a/plugins/modules/keycloak_authz_authorization_scope.py +++ b/plugins/modules/keycloak_authz_authorization_scope.py @@ -11,6 +11,7 @@ module: keycloak_authz_authorization_scope short_description: Allows administration of Keycloak client authorization scopes using Keycloak API +# Originally added in community.general 6.6.0 version_added: "3.0.0" description: @@ -28,6 +29,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_authz_custom_policy.py b/plugins/modules/keycloak_authz_custom_policy.py index 1ca179f..a59b14d 100644 --- a/plugins/modules/keycloak_authz_custom_policy.py +++ b/plugins/modules/keycloak_authz_custom_policy.py @@ -11,6 +11,7 @@ module: keycloak_authz_custom_policy short_description: Allows administration of Keycloak client custom Javascript policies using Keycloak API +# Originally added in community.general 7.5.0 version_added: "3.0.0" description: @@ -29,6 +30,7 @@ attributes: diff_mode: support: none action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_authz_permission.py b/plugins/modules/keycloak_authz_permission.py index 4e94d80..b6d8978 100644 --- a/plugins/modules/keycloak_authz_permission.py +++ b/plugins/modules/keycloak_authz_permission.py @@ -9,6 +9,7 @@ from __future__ import annotations DOCUMENTATION = r""" module: keycloak_authz_permission +# Originally added in community.general 7.2.0 version_added: "3.0.0" short_description: Allows administration of Keycloak client authorization permissions using Keycloak API @@ -34,6 +35,7 @@ attributes: diff_mode: support: none action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_authz_permission_info.py b/plugins/modules/keycloak_authz_permission_info.py index 43e509f..f2ece6d 100644 --- a/plugins/modules/keycloak_authz_permission_info.py +++ b/plugins/modules/keycloak_authz_permission_info.py @@ -9,6 +9,7 @@ from __future__ import annotations DOCUMENTATION = r""" module: keycloak_authz_permission_info +# Originally added in community.general 7.2.0 version_added: "3.0.0" short_description: Query Keycloak client authorization permissions information @@ -24,6 +25,7 @@ description: U(https://www.puppeteers.net/blog/keycloak-authorization-services-rest-api-paths-and-payload/). attributes: action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_client.py b/plugins/modules/keycloak_client.py index f475289..9a15881 100644 --- a/plugins/modules/keycloak_client.py +++ b/plugins/modules/keycloak_client.py @@ -30,6 +30,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_client_rolemapping.py b/plugins/modules/keycloak_client_rolemapping.py index 999739f..679684f 100644 --- a/plugins/modules/keycloak_client_rolemapping.py +++ b/plugins/modules/keycloak_client_rolemapping.py @@ -10,6 +10,7 @@ module: keycloak_client_rolemapping short_description: Allows administration of Keycloak client_rolemapping with the Keycloak API +# Originally added in community.general 3.5.0 version_added: "3.0.0" description: @@ -30,6 +31,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_client_rolescope.py b/plugins/modules/keycloak_client_rolescope.py index 0904730..2ea710a 100644 --- a/plugins/modules/keycloak_client_rolescope.py +++ b/plugins/modules/keycloak_client_rolescope.py @@ -11,6 +11,7 @@ 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 +# Originally added in community.general 8.6.0 version_added: "3.0.0" description: @@ -28,6 +29,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_client_scope.py b/plugins/modules/keycloak_client_scope.py index 5ec63fd..35723c9 100644 --- a/plugins/modules/keycloak_client_scope.py +++ b/plugins/modules/keycloak_client_scope.py @@ -14,6 +14,7 @@ module: keycloak_client_scope short_description: Allows administration of Keycloak client scopes via Keycloak API +# Originally added in community.general 3.4.0 as keycloak_clientscope version_added: "3.0.0" description: diff --git a/plugins/modules/keycloak_clientscope_rolemappings.py b/plugins/modules/keycloak_clientscope_rolemappings.py new file mode 100644 index 0000000..be89a07 --- /dev/null +++ b/plugins/modules/keycloak_clientscope_rolemappings.py @@ -0,0 +1,282 @@ + +# 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_rolemappings + +short_description: Allows administration of Keycloak clientscope scope mappings to restrict the usage of certain roles to + specific clientscopes + +# Originally added in community.general 13.1.0 +version_added: "3.0.0" + +description: + - This module allows you to add or remove Keycloak roles from 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, C(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 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: + # Originally added in community.general 13.1.0 + 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 they exist. + default: 'present' + type: str + choices: + - present + - absent + + realm: + type: str + description: + - The Keycloak realm under which clients resides. + default: 'master' + + clientscope_id: + required: true + type: str + description: + - Roles provided in O(role_names) will be added to this clientscope. + + client_id: + type: str + description: + - If the O(role_names) are client roles, the client ID under which it resides. + - If this parameter is absent, the roles are considered realm roles. + + role_names: + required: true + type: list + elements: str + description: + - Names of roles to add. + - If O(client_id) is present, all roles must be under this client. + - If O(client_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: + - Felix Grzelka (@felix-grzelka) + # This module was adapted from keycloak_client_rolescope, which was written by Andre Desrosiers (@desand01). +""" + +EXAMPLES = r""" +- name: Add roles to clientscope + middleware_automation.keycloak.keycloak_clientscope_rolemappings: + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_id: frontend-client-public + clientscope_id: frontend-clientscope + role_names: + - backend-role-admin + - backend-role-user + +- name: Remove roles from clientscope + middleware_automation.keycloak.keycloak_clientscope_rolemappings: + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_id: frontend-client-public + clientscope_id: frontend-clientscope + role_names: + - backend-role-admin + state: absent + +- name: Add realm roles to clientscope + middleware_automation.keycloak.keycloak_clientscope_rolemappings: + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + clientscope_id: frontend-clientscope + role_names: + - realm-role-admin + - realm-role-user +""" + +RETURN = r""" +end_state: + description: Representation of clientscope scope mappings after module execution. + returned: on success + type: list + elements: dict + sample: + [ + { + "clientRole": false, + "composite": false, + "containerId": "77f9bd4e-13a6-451e-9c72-ee6997299c1f", + "description": "User role", + "id": "9e155ef7-86f5-4def-b507-581ce7b87013", + "name": "realm-role-user" + }, + { + "clientRole": false, + "composite": false, + "containerId": "77f9bd4e-13a6-451e-9c72-ee6997299c1f", + "description": "Admin role", + "id": "9e155ef7-86f5-4def-b507-581ce7b87013", + "name": "realm-role-admin" + } + ] +""" + +import copy + +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( + client_id=dict(type="str"), + clientscope_id=dict(type="str", required=True), + 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["realm"] + client_id = module.params["client_id"] + clientscope_id = module.params["clientscope_id"] + role_names = module.params["role_names"] + state = module.params["state"] + + realm_object = kc.get_realm_by_id(realm) + if not realm_object: + module.fail_json(msg=f"Failed to retrieve realm '{realm}'") + + clientscope_object = kc.get_clientscope_by_name(clientscope_id, realm) + if not clientscope_object: + module.fail_json(msg=f"Failed to retrieve client-scope '{clientscope_id}'") + + if client_id: + # add client role + client_object = kc.get_client_by_clientid(client_id, realm) + if not client_object: + module.fail_json(msg=f"Failed to retrieve client '{realm}.{client_id}'") + if client_object["fullScopeAllowed"] and state == "present": + module.fail_json(msg=f"FullScopeAllowed is active for Client '{realm}.{client_id}'") + + before_roles = kc.get_clientscope_scope_mappings_client(clientscope_object["id"], client_object["id"], realm) + available_roles_by_name = kc.get_client_roles_by_id(client_object["id"], realm) + else: + # add realm role + before_roles = kc.get_clientscope_scope_mappings_realm(clientscope_object["id"], realm) + available_roles_by_name = kc.get_realm_roles(realm) + + # convert to indexed Dict by name + available_roles_by_name = {role["name"]: role for role in available_roles_by_name} + before_roles_by_name = {role["name"]: role for role in before_roles} + desired_roles = copy.deepcopy(before_roles) + changed_roles = [] + + if state == "present": + # update desired + for role_name in role_names: + if role_name not in available_roles_by_name: + if client_id: + module.fail_json(msg=f"Failed to retrieve role '{realm}.{client_id}.{role_name}'") + else: + module.fail_json(msg=f"Failed to retrieve role '{realm}.{role_name}'") + if role_name not in before_roles_by_name: + changed_roles.append(available_roles_by_name[role_name]) + desired_roles.append(available_roles_by_name[role_name]) + else: + # remove role if present + for role_name in role_names: + if role_name in before_roles_by_name: + changed_roles.append(before_roles_by_name[role_name]) + desired_roles.remove(available_roles_by_name[role_name]) + + before_roles = sorted(before_roles, key=lambda d: d["name"]) + desired_role_mapping = sorted(desired_roles, key=lambda d: d["name"]) + + result["changed"] = bool(changed_roles) + + if module._diff: + result["diff"] = dict(before={"roles": before_roles}, after={"roles": desired_role_mapping}) + + if not result["changed"]: + # no changes + result["end_state"] = before_roles + result["msg"] = f"No changes required for clientscope {clientscope_id}." + elif state == "present": + # doing update + if module.check_mode: + result["end_state"] = desired_role_mapping + elif client_id: + result["end_state"] = kc.update_clientscope_scope_mappings_client( + changed_roles, clientscope_object["id"], client_object["id"], realm + ) + else: + result["end_state"] = kc.update_clientscope_scope_mappings_realm( + changed_roles, clientscope_object["id"], realm + ) + result["msg"] = f"Clientscope scope mappings for {clientscope_id} have been updated" + else: + # doing delete + if module.check_mode: + result["end_state"] = desired_role_mapping + elif client_id: + result["end_state"] = kc.delete_clientscope_scope_mappings_client( + changed_roles, clientscope_object["id"], client_object["id"], realm + ) + else: + result["end_state"] = kc.delete_clientscope_scope_mappings_realm( + changed_roles, clientscope_object["id"], realm + ) + result["msg"] = f"Clientscope scope mappings for {clientscope_id} have been deleted" + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_clientscope_type.py b/plugins/modules/keycloak_clientscope_type.py index f9cd07b..6c467a3 100644 --- a/plugins/modules/keycloak_clientscope_type.py +++ b/plugins/modules/keycloak_clientscope_type.py @@ -10,6 +10,7 @@ module: keycloak_clientscope_type short_description: Set the type of aclientscope in realm or client using Keycloak API +# Originally added in community.general 6.6.0 version_added: "3.0.0" description: @@ -23,6 +24,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_clientsecret_info.py b/plugins/modules/keycloak_clientsecret_info.py index a598fe7..d92f469 100644 --- a/plugins/modules/keycloak_clientsecret_info.py +++ b/plugins/modules/keycloak_clientsecret_info.py @@ -10,6 +10,7 @@ module: keycloak_clientsecret_info short_description: Retrieve client secret using Keycloak API +# Originally added in community.general 6.1.0 version_added: "3.0.0" description: @@ -23,6 +24,7 @@ description: the task.' attributes: action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_clientsecret_regenerate.py b/plugins/modules/keycloak_clientsecret_regenerate.py index 4b96956..5e8b86b 100644 --- a/plugins/modules/keycloak_clientsecret_regenerate.py +++ b/plugins/modules/keycloak_clientsecret_regenerate.py @@ -10,6 +10,7 @@ module: keycloak_clientsecret_regenerate short_description: Regenerate Keycloak client secret using Keycloak API +# Originally added in community.general 6.1.0 version_added: "3.0.0" description: @@ -27,6 +28,7 @@ attributes: diff_mode: support: none action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_clienttemplate.py b/plugins/modules/keycloak_clienttemplate.py index a764973..11334a3 100644 --- a/plugins/modules/keycloak_clienttemplate.py +++ b/plugins/modules/keycloak_clienttemplate.py @@ -28,6 +28,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_component.py b/plugins/modules/keycloak_component.py index 0993fc8..8176044 100644 --- a/plugins/modules/keycloak_component.py +++ b/plugins/modules/keycloak_component.py @@ -10,6 +10,7 @@ module: keycloak_component short_description: Allows administration of Keycloak components using Keycloak API +# Originally added in community.general 10.0.0 version_added: "3.0.0" description: @@ -26,6 +27,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_component_info.py b/plugins/modules/keycloak_component_info.py index a08c8fb..3934513 100644 --- a/plugins/modules/keycloak_component_info.py +++ b/plugins/modules/keycloak_component_info.py @@ -10,12 +10,14 @@ module: keycloak_component_info short_description: Retrieve component info in Keycloak +# Originally added in community.general 8.2.0 version_added: "3.0.0" description: - This module retrieve information on component from Keycloak. attributes: action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_group.py b/plugins/modules/keycloak_group.py index bcf67bd..4f3a7e7 100644 --- a/plugins/modules/keycloak_group.py +++ b/plugins/modules/keycloak_group.py @@ -30,6 +30,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_identity_provider.py b/plugins/modules/keycloak_identity_provider.py index a7052c9..96eeb10 100644 --- a/plugins/modules/keycloak_identity_provider.py +++ b/plugins/modules/keycloak_identity_provider.py @@ -10,6 +10,7 @@ module: keycloak_identity_provider short_description: Allows administration of Keycloak identity providers using Keycloak API +# Originally added in community.general 3.6.0 version_added: "3.0.0" description: @@ -25,6 +26,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_realm.py b/plugins/modules/keycloak_realm.py index 2799aa4..9eb4070 100644 --- a/plugins/modules/keycloak_realm.py +++ b/plugins/modules/keycloak_realm.py @@ -11,6 +11,7 @@ module: keycloak_realm short_description: Allows administration of Keycloak realm using Keycloak API +# Originally added in community.general 3.0.0 version_added: "3.0.0" description: @@ -30,6 +31,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_realm_info.py b/plugins/modules/keycloak_realm_info.py index 2aaf2a3..4643283 100644 --- a/plugins/modules/keycloak_realm_info.py +++ b/plugins/modules/keycloak_realm_info.py @@ -10,6 +10,7 @@ module: keycloak_realm_info short_description: Allows obtaining Keycloak realm public information using Keycloak API +# Originally added in community.general 4.3.0 version_added: "3.0.0" description: diff --git a/plugins/modules/keycloak_realm_key.py b/plugins/modules/keycloak_realm_key.py index 297e5f5..8c9500a 100644 --- a/plugins/modules/keycloak_realm_key.py +++ b/plugins/modules/keycloak_realm_key.py @@ -11,6 +11,7 @@ module: keycloak_realm_key short_description: Allows administration of Keycloak realm keys using Keycloak API +# Originally added in community.general 7.5.0 version_added: "3.0.0" description: @@ -31,6 +32,7 @@ attributes: diff_mode: support: partial action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_realm_keys_metadata_info.py b/plugins/modules/keycloak_realm_keys_metadata_info.py index 4c110e8..aafb478 100644 --- a/plugins/modules/keycloak_realm_keys_metadata_info.py +++ b/plugins/modules/keycloak_realm_keys_metadata_info.py @@ -10,6 +10,7 @@ module: keycloak_realm_keys_metadata_info short_description: Allows obtaining Keycloak realm keys metadata using Keycloak API +# Originally added in community.general 9.3.0 version_added: "3.0.0" description: @@ -18,6 +19,7 @@ description: at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). attributes: action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py index b380590..45fb509 100644 --- a/plugins/modules/keycloak_realm_localization.py +++ b/plugins/modules/keycloak_realm_localization.py @@ -11,6 +11,7 @@ module: keycloak_realm_localization short_description: Allows management of Keycloak realm localization overrides via the Keycloak API +# Originally added in community.general 12.4.0 version_added: "3.0.0" description: diff --git a/plugins/modules/keycloak_realm_rolemapping.py b/plugins/modules/keycloak_realm_rolemapping.py index 473aca1..1f3a544 100644 --- a/plugins/modules/keycloak_realm_rolemapping.py +++ b/plugins/modules/keycloak_realm_rolemapping.py @@ -10,6 +10,7 @@ module: keycloak_realm_rolemapping short_description: Allows administration of Keycloak realm role mappings into groups with the Keycloak API +# Originally added in community.general 8.2.0 version_added: "3.0.0" description: @@ -30,6 +31,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_role.py b/plugins/modules/keycloak_role.py index 114c650..9787586 100644 --- a/plugins/modules/keycloak_role.py +++ b/plugins/modules/keycloak_role.py @@ -10,6 +10,7 @@ module: keycloak_role short_description: Allows administration of Keycloak roles using Keycloak API +# Originally added in community.general 3.4.0 version_added: "3.0.0" description: @@ -28,6 +29,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_user.py b/plugins/modules/keycloak_user.py index 67dcc04..b713bf9 100644 --- a/plugins/modules/keycloak_user.py +++ b/plugins/modules/keycloak_user.py @@ -10,6 +10,7 @@ module: keycloak_user short_description: Create and configure a user in Keycloak description: - This module creates, removes, or updates Keycloak users. +# Originally added in community.general 7.1.0 version_added: "3.0.0" options: auth_username: @@ -34,8 +35,9 @@ options: type: bool email_verified: description: - - Check the validity of user email. - default: false + - Set or reset the C(emailVerified) flag of the user. + - When O(email_verified_behavior=no_defaults), the default value of this option becomes C(null) and + that causes the module not to change any existing value for that attribute. type: bool aliases: - emailVerified @@ -133,8 +135,7 @@ options: default: false required_actions: description: - - RequiredActions user Auth. - default: [] + - Set or reset a user's required actions. type: list elements: str aliases: @@ -199,6 +200,20 @@ options: - If V(true), allows to remove user and recreate it. type: bool default: false + email_verified_behavior: + description: + - The O(email_verified) option used to have a default value. This caused problems when the + user expects different behavior from keycloak by default. + - The default value of this option is V(compatibility), which will ensure that the old default value + for O(email_verified) is used. + - When set to V(no_defaults), the module will not change existing values of O(email_verified) if no value is specified. + type: str + choices: + - compatibility + - no_defaults + default: compatibility + # Originally added in community.general 13.1.0 + version_added: "3.0.0" extends_documentation_fragment: - middleware_automation.keycloak.keycloak - middleware_automation.keycloak.actiongroup_keycloak @@ -209,6 +224,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" notes: - The module does not modify the user ID of an existing user. @@ -378,7 +394,7 @@ def main(): last_name=dict(type="str", aliases=["lastName"]), email=dict(type="str"), enabled=dict(type="bool"), - email_verified=dict(type="bool", default=False, aliases=["emailVerified"]), + email_verified=dict(type="bool", 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), @@ -387,7 +403,7 @@ def main(): disableable_credential_types=dict( type="list", default=[], aliases=["disableableCredentialTypes"], elements="str" ), - required_actions=dict(type="list", default=[], aliases=["requiredActions"], elements="str"), + required_actions=dict(type="list", 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( @@ -396,6 +412,7 @@ def main(): origin=dict(type="str"), state=dict(choices=["absent", "present"], default="present"), force=dict(type="bool", default=False), + email_verified_behavior=dict(type="str", choices=["compatibility", "no_defaults"], default="compatibility"), ) argument_spec.update(meta_args) @@ -425,14 +442,21 @@ def main(): 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 + # If there is no value for email_verified, check if we should to set the old default + if module.params["email_verified"] is None and module.params["email_verified_behavior"] == "compatibility": + module.params["email_verified"] = False + + ignored_arguments = list(keycloak_argument_spec().keys()) + [ + "state", + "realm", + "force", + "groups", + "email_verified_behavior", ] + # Filter and map the parameters names that apply to the user + user_params = [x for x in module.params if x not in ignored_arguments and module.params[x] is not None] + before_user = kc.get_user_by_username(username=username, realm=realm) if before_user is None: @@ -463,97 +487,109 @@ def main(): desired_user = copy.deepcopy(before_user) desired_user.update(changeset) + if before_user: + before_groups = kc.get_user_groups(user_id=before_user["id"], realm=realm) + before_user["groups"] = before_groups + else: + before_groups = [] + result["proposed"] = changeset result["existing"] = before_user # Default values for user_created result["user_created"] = False changed = False + after_user = {} + + user_compare_excludes = [ + "access", + "notBefore", + "createdTimestamp", + "totp", + "credentials", + "disableableCredentialTypes", + "groups", + "clientConsents", + "federatedIdentities", + ] - # 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) + result["msg"] = "User does not exist, doing nothing." else: # Delete user - kc.delete_user(user_id=before_user["id"], realm=realm) + if not module.check_mode: + 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 + if (not before_user or force) and username is None: + module.fail_json(msg="username must be specified when creating a new user") + + if force and before_user and not module.check_mode: # 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 + # Create a new user + if not module.check_mode: + # 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", + ) + # Add user ID to desired_user for group updates + desired_user["id"] = after_user["id"] + else: + after_user = desired_user - 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 + changed = True else: - excludes = [ - "access", - "notBefore", - "createdTimestamp", - "totp", - "credentials", - "disableableCredentialTypes", - "groups", - "clientConsents", - "federatedIdentities", - "requiredActions", - ] + # Update an existing user # 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 + is_struct_included(desired_user, before_user, user_compare_excludes, empty_list_result=False) + ): # If the new user introduces a change to the existing user # Update the user - after_user = kc.update_user(userrep=desired_user, realm=realm) + if not module.check_mode: + after_user = kc.update_user(userrep=desired_user, realm=realm) changed = True + if not after_user: + # no change + after_user = desired_user + # 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 not module.check_mode: + changed |= kc.update_user_groups_membership(userrep=desired_user, groups=groups, realm=realm) + + present_groups = [g["name"] for g in groups if g["state"] == "present"] + absent_groups = [g["name"] for g in groups if g["state"] == "absent"] + + desired_user["groups"] = (set(before_groups) | set(present_groups)) - set(absent_groups) + + if module.check_mode: + # check if group meberships would have changed + changed |= not is_struct_included( + desired_user["groups"], before_groups, user_compare_excludes, empty_list_result=False + ) + else: + after_user["groups"] = kc.get_user_groups(user_id=desired_user["id"], realm=realm) + + if not result["msg"]: if changed: result["msg"] = f"User {desired_user['username']} updated" else: result["msg"] = f"No changes made for user {desired_user['username']}" - + result["end_state"] = after_user result["changed"] = changed + result["diff"] = dict(before=before_user, after=after_user) module.exit_json(**result) diff --git a/plugins/modules/keycloak_user_execute_actions_email.py b/plugins/modules/keycloak_user_execute_actions_email.py index 9a8765a..ef18fac 100644 --- a/plugins/modules/keycloak_user_execute_actions_email.py +++ b/plugins/modules/keycloak_user_execute_actions_email.py @@ -10,6 +10,7 @@ module: keycloak_user_execute_actions_email short_description: Send a Keycloak execute-actions email to a user +# Originally added in community.general 12.0.0 version_added: "3.0.0" description: diff --git a/plugins/modules/keycloak_user_federation.py b/plugins/modules/keycloak_user_federation.py index 8fbc7d1..ea8fb66 100644 --- a/plugins/modules/keycloak_user_federation.py +++ b/plugins/modules/keycloak_user_federation.py @@ -10,6 +10,7 @@ module: keycloak_user_federation short_description: Allows administration of Keycloak user federations using Keycloak API +# Originally added in community.general 3.7.0 version_added: "3.0.0" description: @@ -25,6 +26,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_user_rolemapping.py b/plugins/modules/keycloak_user_rolemapping.py index b4ee83c..cc20f42 100644 --- a/plugins/modules/keycloak_user_rolemapping.py +++ b/plugins/modules/keycloak_user_rolemapping.py @@ -9,6 +9,7 @@ module: keycloak_user_rolemapping short_description: Allows administration of Keycloak user_rolemapping with the Keycloak API +# Originally added in community.general 5.7.0 version_added: "3.0.0" description: @@ -29,6 +30,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: diff --git a/plugins/modules/keycloak_userprofile.py b/plugins/modules/keycloak_userprofile.py index d427db9..799e21d 100644 --- a/plugins/modules/keycloak_userprofile.py +++ b/plugins/modules/keycloak_userprofile.py @@ -16,6 +16,7 @@ description: - 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. +# Originally added in community.general 9.4.0 version_added: "3.0.0" attributes: @@ -24,6 +25,7 @@ attributes: diff_mode: support: full action_group: + # Originally added in community.general 10.2.0 version_added: "3.0.0" options: