From 4832e5da5d5cdaf738bfc6c6a21e8abf626ff6e1 Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Wed, 2 Sep 2026 10:46:57 +0200 Subject: [PATCH 1/2] Vendor middleware_automation.keycloak collection Needed for keycloak_authentication_v2, to manage Keycloak authentication flows (e.g. the CCCHH browser flow) as YAML. Chosen over community.general's own copy of the module (deprecated in favor of this collection, and limited to 4 sub-flow levels there vs. 10 here). --- .../keycloak/CHANGELOG.rst | 632 +++ .../keycloak/CONTRIBUTING.md | 48 + .../middleware_automation/keycloak/FILES.json | 1342 ++++++ .../middleware_automation/keycloak/LICENSE | 201 + .../keycloak/MANIFEST.json | 50 + .../middleware_automation/keycloak/README.md | 225 + .../middleware_automation/keycloak/bindep.txt | 10 + .../keycloak/docs/CHANGELOG.rst | 1 + .../keycloak/docs/README.md | 1 + .../keycloak/docs/developing.md | 1 + .../keycloak/docs/index.rst | 34 + .../keycloak/docs/releasing.md | 61 + .../keycloak/docs/requirements.txt | 7 + .../keycloak/docs/testing.md | 23 + .../keycloak/gh-release.md | 17 + .../keycloak/meta/execution-environment.yml | 11 + .../keycloak/meta/runtime.yml | 65 + .../keycloak/playbooks/keycloak.yml | 7 + .../keycloak_authentication_flow.yml | 27 + .../playbooks/keycloak_client_scope.yml | 48 + .../playbooks/keycloak_federation.yml | 68 + .../keycloak/playbooks/keycloak_quarkus.yml | 11 + .../playbooks/keycloak_quarkus_dev.yml | 12 + .../keycloak/playbooks/keycloak_realm.yml | 26 + .../playbooks/keycloak_realm_client.yml | 39 + .../keycloak/playbooks/rhsso.yml | 8 + .../keycloak/playbooks/roles | 1 + .../doc_fragments/actiongroup_keycloak.py | 22 + .../plugins/doc_fragments/attributes.py | 94 + .../plugins/doc_fragments/keycloak.py | 81 + .../identity/keycloak/_keycloak_utils.py | 34 + .../identity/keycloak/keycloak.py | 3667 +++++++++++++++++ .../keycloak/keycloak_clientsecret.py | 78 + .../modules/keycloak_authentication.py | 520 +++ .../modules/keycloak_authentication_flow.py | 300 ++ ...eycloak_authentication_required_actions.py | 465 +++ .../modules/keycloak_authentication_v2.py | 1281 ++++++ .../keycloak_authz_authorization_scope.py | 282 ++ .../modules/keycloak_authz_custom_policy.py | 215 + .../modules/keycloak_authz_permission.py | 445 ++ .../modules/keycloak_authz_permission_info.py | 180 + .../plugins/modules/keycloak_client.py | 1519 +++++++ .../modules/keycloak_client_rolemapping.py | 416 ++ .../modules/keycloak_client_rolescope.py | 289 ++ .../plugins/modules/keycloak_client_scope.py | 329 ++ .../keycloak_client_scope_rolemappings.py | 284 ++ .../modules/keycloak_client_scope_type.py | 317 ++ .../modules/keycloak_clientsecret_info.py | 170 + .../keycloak_clientsecret_regenerate.py | 180 + .../modules/keycloak_clienttemplate.py | 474 +++ .../plugins/modules/keycloak_component.py | 330 ++ .../modules/keycloak_component_info.py | 173 + .../plugins/modules/keycloak_group.py | 495 +++ .../modules/keycloak_identity_provider.py | 795 ++++ .../plugins/modules/keycloak_organization.py | 416 ++ .../modules/keycloak_organization_info.py | 142 + .../plugins/modules/keycloak_realm.py | 1160 ++++++ .../plugins/modules/keycloak_realm_info.py | 133 + .../plugins/modules/keycloak_realm_key.py | 1062 +++++ .../keycloak_realm_keys_metadata_info.py | 139 + .../modules/keycloak_realm_localization.py | 401 ++ .../modules/keycloak_realm_rolemapping.py | 394 ++ .../modules/keycloak_realm_users_info.py | 123 + .../keycloak/plugins/modules/keycloak_role.py | 452 ++ .../keycloak/plugins/modules/keycloak_user.py | 599 +++ .../keycloak_user_execute_actions_email.py | 207 + .../modules/keycloak_user_federation.py | 1159 ++++++ .../modules/keycloak_user_rolemapping.py | 444 ++ .../plugins/modules/keycloak_userprofile.py | 826 ++++ .../keycloak/requirements.txt | 7 + .../keycloak/requirements.yml | 15 + .../keycloak/roles/keycloak/README.md | 215 + .../keycloak/roles/keycloak/defaults/main.yml | 128 + .../keycloak/roles/keycloak/handlers/main.yml | 4 + .../roles/keycloak/meta/argument_specs.yml | 393 ++ .../keycloak/roles/keycloak/meta/main.yml | 29 + .../keycloak/roles/keycloak/tasks/debian.yml | 10 + .../roles/keycloak/tasks/fastpackages.yml | 31 + .../roles/keycloak/tasks/firewalld.yml | 28 + .../keycloak/roles/keycloak/tasks/install.yml | 306 ++ .../roles/keycloak/tasks/iptables.yml | 23 + .../roles/keycloak/tasks/jdbc_driver.yml | 46 + .../keycloak/roles/keycloak/tasks/main.yml | 88 + .../keycloak/roles/keycloak/tasks/prereqs.yml | 58 + .../keycloak/roles/keycloak/tasks/redhat.yml | 10 + .../roles/keycloak/tasks/restart_keycloak.yml | 29 + .../roles/keycloak/tasks/rhsso_cli.yml | 13 + .../roles/keycloak/tasks/rhsso_patch.yml | 174 + .../roles/keycloak/tasks/start_keycloak.yml | 17 + .../roles/keycloak/tasks/stop_keycloak.yml | 7 + .../keycloak/roles/keycloak/tasks/systemd.yml | 56 + .../15.0.8/standalone-infinispan.xml.j2 | 761 ++++ .../templates/15.0.8/standalone.xml.j2 | 658 +++ .../9.0.2/standalone-infinispan.xml.j2 | 759 ++++ .../templates/9.0.2/standalone.xml.j2 | 619 +++ .../templates/jdbc_driver_module.xml.j2 | 10 + .../templates/keycloak-profile.properties.j2 | 3 + .../keycloak/templates/keycloak-service.sh.j2 | 127 + .../keycloak/templates/keycloak-sysconfig.j2 | 19 + .../keycloak/templates/keycloak.service.j2 | 29 + .../keycloak/templates/standalone-ha.xml.j2 | 707 ++++ .../templates/standalone-infinispan.xml.j2 | 745 ++++ .../keycloak/templates/standalone.xml.j2 | 626 +++ .../keycloak/roles/keycloak/vars/debian.yml | 12 + .../keycloak/roles/keycloak/vars/main.yml | 105 + .../keycloak/roles/keycloak/vars/redhat.yml | 10 + .../keycloak/roles/keycloak_quarkus/README.md | 314 ++ .../roles/keycloak_quarkus/defaults/main.yml | 194 + .../roles/keycloak_quarkus/handlers/main.yml | 21 + .../keycloak_quarkus/meta/argument_specs.yml | 573 +++ .../roles/keycloak_quarkus/meta/main.yml | 33 + .../keycloak_quarkus/tasks/bootstrapped.yml | 16 + .../keycloak_quarkus/tasks/config_store.yml | 52 + .../roles/keycloak_quarkus/tasks/debian.yml | 10 + .../keycloak_quarkus/tasks/deprecations.yml | 162 + .../keycloak_quarkus/tasks/fastpackages.yml | 31 + .../keycloak_quarkus/tasks/firewalld.yml | 36 + .../roles/keycloak_quarkus/tasks/install.yml | 319 ++ .../tasks/invalidate_theme_cache.yml | 11 + .../roles/keycloak_quarkus/tasks/iptables.yml | 20 + .../keycloak_quarkus/tasks/jdbc_driver.yml | 22 + .../roles/keycloak_quarkus/tasks/main.yml | 139 + .../roles/keycloak_quarkus/tasks/prereqs.yml | 154 + .../keycloak_quarkus/tasks/rebuild_config.yml | 7 + .../roles/keycloak_quarkus/tasks/redhat.yml | 10 + .../roles/keycloak_quarkus/tasks/restart.yml | 24 + .../keycloak_quarkus/tasks/restart/none.yml | 4 + .../keycloak_quarkus/tasks/restart/serial.yml | 11 + .../tasks/restart/serial_then_parallel.yml | 20 + .../roles/keycloak_quarkus/tasks/start.yml | 18 + .../roles/keycloak_quarkus/tasks/systemd.yml | 27 + .../templates/cache-ispn.xml.j2 | 119 + .../templates/keycloak-sysconfig.j2 | 15 + .../templates/keycloak.conf.j2 | 110 + .../templates/keycloak.fact.j2 | 2 + .../templates/keycloak.service.j2 | 33 + .../templates/quarkus.properties.j2 | 29 + .../templates/rhbk-sysconfig.j2 | 15 + .../keycloak_quarkus/templates/rhbk.conf.j2 | 110 + .../keycloak_quarkus/templates/rhbk.fact.j2 | 2 + .../templates/rhbk.service.j2 | 33 + .../roles/keycloak_quarkus/vars/debian.yml | 12 + .../roles/keycloak_quarkus/vars/main.yml | 17 + .../roles/keycloak_quarkus/vars/redhat.yml | 11 + .../keycloak/roles/keycloak_realm/README.md | 196 + .../roles/keycloak_realm/defaults/main.yml | 61 + .../keycloak_realm/meta/argument_specs.yml | 135 + .../roles/keycloak_realm/meta/main.yml | 22 + .../roles/keycloak_realm/tasks/main.yml | 119 + .../tasks/manage_client_roles.yml | 14 + .../tasks/manage_client_users.yml | 13 + .../tasks/manage_token_lifespan.yml | 14 + .../keycloak_realm/tasks/manage_user.yml | 51 + .../tasks/manage_user_client_roles.yml | 46 + .../tasks/manage_user_roles.yml | 26 + .../keycloak_realm/templates/realm.json.j2 | 7 + .../roles/keycloak_realm/vars/main.yml | 5 + .../keycloak/tests/config.yml | 5 + requirements.yml | 4 + 159 files changed, 34399 insertions(+) create mode 100644 ansible_collections/middleware_automation/keycloak/CHANGELOG.rst create mode 100644 ansible_collections/middleware_automation/keycloak/CONTRIBUTING.md create mode 100644 ansible_collections/middleware_automation/keycloak/FILES.json create mode 100644 ansible_collections/middleware_automation/keycloak/LICENSE create mode 100644 ansible_collections/middleware_automation/keycloak/MANIFEST.json create mode 100644 ansible_collections/middleware_automation/keycloak/README.md create mode 100644 ansible_collections/middleware_automation/keycloak/bindep.txt create mode 120000 ansible_collections/middleware_automation/keycloak/docs/CHANGELOG.rst create mode 120000 ansible_collections/middleware_automation/keycloak/docs/README.md create mode 120000 ansible_collections/middleware_automation/keycloak/docs/developing.md create mode 100644 ansible_collections/middleware_automation/keycloak/docs/index.rst create mode 100644 ansible_collections/middleware_automation/keycloak/docs/releasing.md create mode 100644 ansible_collections/middleware_automation/keycloak/docs/requirements.txt create mode 100644 ansible_collections/middleware_automation/keycloak/docs/testing.md create mode 100644 ansible_collections/middleware_automation/keycloak/gh-release.md create mode 100644 ansible_collections/middleware_automation/keycloak/meta/execution-environment.yml create mode 100644 ansible_collections/middleware_automation/keycloak/meta/runtime.yml create mode 100644 ansible_collections/middleware_automation/keycloak/playbooks/keycloak.yml create mode 100644 ansible_collections/middleware_automation/keycloak/playbooks/keycloak_authentication_flow.yml create mode 100644 ansible_collections/middleware_automation/keycloak/playbooks/keycloak_client_scope.yml create mode 100644 ansible_collections/middleware_automation/keycloak/playbooks/keycloak_federation.yml create mode 100644 ansible_collections/middleware_automation/keycloak/playbooks/keycloak_quarkus.yml create mode 100644 ansible_collections/middleware_automation/keycloak/playbooks/keycloak_quarkus_dev.yml create mode 100644 ansible_collections/middleware_automation/keycloak/playbooks/keycloak_realm.yml create mode 100644 ansible_collections/middleware_automation/keycloak/playbooks/keycloak_realm_client.yml create mode 100644 ansible_collections/middleware_automation/keycloak/playbooks/rhsso.yml create mode 120000 ansible_collections/middleware_automation/keycloak/playbooks/roles create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/actiongroup_keycloak.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/attributes.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/keycloak.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/_keycloak_utils.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/keycloak.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/keycloak_clientsecret.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_flow.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_required_actions.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_v2.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_authorization_scope.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_custom_policy.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_permission.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_permission_info.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_rolemapping.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_rolescope.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope_rolemappings.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope_type.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clientsecret_info.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clientsecret_regenerate.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clienttemplate.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_component.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_component_info.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_group.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_identity_provider.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_organization.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_organization_info.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_info.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_key.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_keys_metadata_info.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_localization.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_rolemapping.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_users_info.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_role.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_execute_actions_email.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_federation.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_rolemapping.py create mode 100644 ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_userprofile.py create mode 100644 ansible_collections/middleware_automation/keycloak/requirements.txt create mode 100644 ansible_collections/middleware_automation/keycloak/requirements.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/README.md create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/defaults/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/handlers/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/meta/argument_specs.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/meta/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/debian.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/fastpackages.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/firewalld.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/install.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/iptables.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/jdbc_driver.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/prereqs.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/redhat.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/restart_keycloak.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/rhsso_cli.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/rhsso_patch.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/start_keycloak.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/stop_keycloak.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/systemd.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/15.0.8/standalone-infinispan.xml.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/15.0.8/standalone.xml.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/9.0.2/standalone-infinispan.xml.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/9.0.2/standalone.xml.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/jdbc_driver_module.xml.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-profile.properties.j2 create mode 100755 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-service.sh.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-sysconfig.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak.service.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone-ha.xml.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone-infinispan.xml.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone.xml.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/debian.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/redhat.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/README.md create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/defaults/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/handlers/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/meta/argument_specs.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/meta/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/bootstrapped.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/config_store.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/debian.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/deprecations.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/fastpackages.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/firewalld.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/install.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/invalidate_theme_cache.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/iptables.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/jdbc_driver.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/prereqs.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/rebuild_config.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/redhat.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/none.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/serial.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/serial_then_parallel.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/start.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/systemd.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.conf.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.fact.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.service.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/quarkus.properties.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk-sysconfig.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.conf.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.fact.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.service.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/debian.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/redhat.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/README.md create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/defaults/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/meta/argument_specs.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/meta/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_client_roles.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_client_users.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_token_lifespan.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user_client_roles.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user_roles.yml create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/templates/realm.json.j2 create mode 100644 ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/vars/main.yml create mode 100644 ansible_collections/middleware_automation/keycloak/tests/config.yml diff --git a/ansible_collections/middleware_automation/keycloak/CHANGELOG.rst b/ansible_collections/middleware_automation/keycloak/CHANGELOG.rst new file mode 100644 index 00000000..b0963313 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/CHANGELOG.rst @@ -0,0 +1,632 @@ +============================================= +middleware\_automation.keycloak Release Notes +============================================= + +.. contents:: Topics + +This changelog describes changes after version 0.2.6. + +v3.0.11 +======= + +Minor Changes +------------- + +- Add certification checks to the upstream repo `#378 `_ +- Fix cert workflow `#389 `_ +- Fix health check URL to include HTTP relative path `#364 `_ +- fix(keycloak_quarkus): avoid leaking maven provider password in logs `#363 `_ + +Bugfixes +-------- + +- AMW-592 AMW Github Actions are failing because of latest molecule `#366 `_ +- AMW-594 Upstream release is failing due to recent rules change `#373 `_ + +v3.0.10 +======= + +Major Changes +------------- + +- Fixing linting issues `#357 `_ + +Minor Changes +------------- + +- AMW-571 keycloak_identity_provider hide_on_login_page not working `#356 `_ +- Fixing vars naming issue `#359 `_ +- Removing from __future__ import annotations broke runtime type hints using Sequence and union syntax, causing sanity import failures and molecule module errors `#358 `_ + +v3.0.9 +====== + +Bugfixes +-------- + +- AMW-551 Providing correct rhbk version `#344 `_ + +v3.0.8 +====== + +v3.0.7 +====== + +Major Changes +------------- + +- Migrate Keycloak modules from the community.general collection to Keycloak collection. `#341 `_ + +Minor Changes +------------- + +- Fixing common module usage `#343 `_ +- fix #336: https://github.com/ansible-middleware/common/pull/38 `#338 `_ + +Bugfixes +-------- + +- Fix molecule tests `#339 `_ + +v3.0.6 +====== + +Major Changes +------------- + +- AMW-540 Fix the upstream collection requirements with common v1.2.4 `#337 `_ + +v3.0.5 +====== + +Minor Changes +------------- + +- AMW-528 Deployment fails in keycloak_quarkus due to missing escalation variables `#335 `_ + +v3.0.4 +====== + +Major Changes +------------- + +- AMW-467 Download keycloak binary from password protected HTTP location `#321 `_ +- v26.4.x compability `#317 `_ + +Minor Changes +------------- + +- AMW-518 Validating arguments against arg spec 'main' fails unexpectedly. `#324 `_ + +Bugfixes +-------- + +- Removing parseable from lint file as Additional properties are not allowed `#319 `_ + +v3.0.3 +====== + +Major Changes +------------- + +- Update to keycloak 26.3.0 `#293 `_ +- ansible-core 2.19 compatibility `#310 `_ + +Minor Changes +------------- + +- Allow to install provider jars from remote paths `#303 `_ +- Declared proxy_mode as deprecated, updated quarkus and realm readme `#306 `_ +- Fix config_key_store_file description to match variable name `#308 `_ + +Bugfixes +-------- + +- keycloak collection CI label is showing no status `#312 `_ +- keycloak_realm: allow secret in keycloak_clients `#304 `_ + +v3.0.2 +====== + +Minor Changes +------------- + +- New ``checksum`` property for keycloak_quarkus_providers `#280 `_ +- New parameter to set the jgroups host IP address `#281 `_ +- Session storage / distributed caches `#287 `_ +- Update keycloak/RHBK to v26.2.4 `#283 `_ + +Bugfixes +-------- + +- Fix ``keycloak_quarkus_force_install`` parameter being ignored by install `#296 `_ +- Fix alternate download location being ignored (JBossNeworkAPI always used) `#298 `_ +- Run config rebuild after SPI providers update `#285 `_ +- Use jdk21 as default in debian `#289 `_ +- keycloak_realm: federation default provider type should be a string `#302 `_ + +v3.0.1 +====== + +Minor Changes +------------- + +- Version update to 26.0.8 / rhbk 26.0.11 `#277 `_ + +Bugfixes +-------- + +- Trigger rebuild handler on envvars file change `#276 `_ + +v3.0.0 +====== + +Minor Changes +------------- + +- Add theme cache invalidation handler `#252 `_ +- keycloak_realm: change url variables to defaults `#268 `_ + +Breaking Changes / Porting Guide +-------------------------------- + +- Bump major and ansible-core versions `#266 `_ +- Rename parameters to follow upstream `#270 `_ +- Update for keycloak v26 `#254 `_ + +Bugfixes +-------- + +- Access token lifespan is too short for ansible run `#251 `_ +- Load environment vars during kc rebuild `#274 `_ +- Rebuild config and restart service for local providers `#250 `_ +- Rename and honour parameter ``keycloak_quarkus_http_host`` `#271 `_ + +New Modules +----------- + +- middleware_automation.keycloak.keycloak_realm - Allows administration of Keycloak realm via Keycloak API + +v2.4.3 +====== + +Minor Changes +------------- + +- Update keycloak to 24.0.5 `#241 `_ + +v2.4.2 +====== + +Minor Changes +------------- + +- New parameter ``keycloak_quarkus_download_path`` `#239 `_ + +Bugfixes +-------- + +- Add wait_for_port number parameter `#237 `_ + +v2.4.1 +====== + +Release Summary +--------------- + +Internal release, documentation or test changes only. + +v2.4.0 +====== + +Major Changes +------------- + +- Enable by default health check on restart `#234 `_ +- Update minimum ansible-core version > 2.15 `#232 `_ + +v2.3.0 +====== + +Major Changes +------------- + +- Allow for custom providers hosted on maven repositories `#223 `_ +- Restart handler strategy behaviour `#231 `_ + +Minor Changes +------------- + +- Add support for policy files `#225 `_ +- Allow to add extra custom env vars in sysconfig file `#229 `_ +- Download from alternate URL with optional http authentication `#220 `_ +- Update Keycloak to version 24.0.4 `#218 `_ +- ``proxy-header`` enhancement `#227 `_ + +Bugfixes +-------- + +- ``kc.sh build`` uses configured jdk `#211 `_ + +v2.2.2 +====== + +Minor Changes +------------- + +- Copying of key material for TLS configuration `#210 `_ +- Validate certs parameter for JDBC driver downloads `#207 `_ + +Bugfixes +-------- + +- Turn off controller privilege escalation `#209 `_ + +v2.2.1 +====== + +Release Summary +--------------- + +Internal release, documentation or test changes only. + +Bugfixes +-------- + +- JDBC provider: fix clause in argument validation `#204 `_ + +v2.2.0 +====== + +Major Changes +------------- + +- Support java keystore for configuration of sensitive options `#189 `_ + +Minor Changes +------------- + +- Add ``wait_for_port`` and ``wait_for_log`` systemd unit logic `#199 `_ +- Customize jdbc driver downloads, optional authentication `#202 `_ +- Keystore-based vault SPI configuration `#196 `_ +- New ``keycloak_quarkus_hostname_strict_https`` parameter `#195 `_ +- Providers config and custom providers `#201 `_ +- Remove administrator credentials from files once keycloak is bootstrapped `#197 `_ +- Update keycloak to 24.0 `#194 `_ + +v2.1.2 +====== + +Release Summary +--------------- + +Internal release, documentation or test changes only. + +v2.1.1 +====== + +Minor Changes +------------- + +- Add reverse ``proxy_headers`` config, supersedes ``proxy_mode`` `#187 `_ +- Debian/Ubuntu compatibility `#178 `_ +- Use ``keycloak_realm`` as default for sub-entities `#180 `_ + +Bugfixes +-------- + +- Fix permissions on controller-side downloaded artifacts `#184 `_ +- JVM args moved to ``JAVA_OPTS`` envvar (instead of JAVA_OPTS_APPEND) `#186 `_ +- Unrelax configuration file permissions `#191 `_ +- Utilize comment filter for ``ansible_managed`` annotations `#176 `_ + +v2.1.0 +====== + +Major Changes +------------- + +- Implement infinispan TCPPING discovery protocol `#159 `_ + +Minor Changes +------------- + +- Set enable-recovery when xa transactions are enabled `#167 `_ +- keycloak_quarkus: Allow configuring log rotate options in quarkus configuration `#161 `_ +- keycloak_quarkus: ``sticky-session`` for infinispan routes `#163 `_ + +Breaking Changes / Porting Guide +-------------------------------- + +- keycloak_quarkus: renamed infinispan host list configuration `#157 `_ + +Bugfixes +-------- + +- keycloak_quarkus: fix custom JAVA_HOME parameter name `#171 `_ + +v2.0.2 +====== + +Minor Changes +------------- + +- keycloak_quarkus: Add support for sqlserver jdbc driver `#148 `_ +- keycloak_quarkus: allow configuration of ``hostname-strict-backchannel`` `#152 `_ +- keycloak_quarkus: systemd restart behavior `#145 `_ + +Bugfixes +-------- + +- keycloak_quarkus: Use ``keycloak_quarkus_java_opts`` `#154 `_ +- keycloak_quarkus: allow ports <1024 (e.g. :443) in systemd unit `#150 `_ + +v2.0.1 +====== + +Minor Changes +------------- + +- keycloak_quarkus: add hostname-strict parameter `#139 `_ +- keycloak_quarkus: update to version 23.0.1 `#133 `_ + +Bugfixes +-------- + +- keycloak_quarkus: template requires lowercase boolean values `#138 `_ + +v2.0.0 +====== + +Minor Changes +------------- + +- Add new parameter for port offset configuration `#124 `_ +- Update Keycloak to version 22.0.5 `#122 `_ + +Breaking Changes / Porting Guide +-------------------------------- + +- Add support for more http-related configs `#115 `_ +- Update minimum ansible-core version > 2.14 `#119 `_ +- keycloak_quarkus: enable config of key store and trust store `#116 `_ + +v1.3.0 +====== + +Major Changes +------------- + +- Run service as ``keycloak_service_user`` `#106 `_ + +Minor Changes +------------- + +- keycloak_quarkus: Update Keycloak to version 22.0.3 `#112 `_ +- keycloak_quarkus: fix admin console redirect when running locally `#111 `_ +- keycloak_quarkus: skip proxy config if ``keycloak_quarkus_proxy_mode`` is ``none`` `#109 `_ + +Bugfixes +-------- + +- keycloak_quarkus: fix validation failure upon port configuration change `#113 `_ + +v1.2.8 +====== + +Minor Changes +------------- + +- keycloak_quarkus: set openjdk 17 as default `#103 `_ +- keycloak_quarkus: update to version 22.0.1 `#107 `_ + +Bugfixes +-------- + +- Fix incorrect checks for ``keycloak_jgroups_subnet`` `#98 `_ +- Undefine ``keycloak_db_valid_conn_sql`` default `#91 `_ +- Update bindep.txt package python3-devel to support RHEL9 `#105 `_ + +v1.2.7 +====== + +Minor Changes +------------- + +- Allow to override jgroups subnet `#93 `_ +- keycloak-quarkus: update keycloakx to v21.1.1 `#92 `_ + +v1.2.6 +====== + +Minor Changes +------------- + +- Add profile features enabling/disabling `#87 `_ +- Improve service restart behavior configuration `#88 `_ +- Update default xa_datasource_class value for mariadb jdbc configuration `#89 `_ + +Bugfixes +-------- + +- Handle WFLYCTL0117 when background validation millis is 0 `#90 `_ + +v1.2.5 +====== + +Minor Changes +------------- + +- Add configuration for database connection pool validation `#85 `_ +- Allow to configure administration endpoint URL `#86 `_ +- Allow to force backend URLs to frontend URLs `#84 `_ +- Introduce systemd unit restart behavior `#81 `_ + +v1.2.4 +====== + +Minor Changes +------------- + +- Add ``sqlserver`` to keycloak role jdbc configurations `#78 `_ +- Add configurability for XA transactions `#73 `_ + +Bugfixes +-------- + +- Fix deprecation warning for ``ipaddr`` `#77 `_ +- Fix undefined facts when offline patching sso `#71 `_ + +v1.2.1 +====== + +Minor Changes +------------- + +- Allow to setup keycloak HA cluster without remote cache store `#68 `_ + +Bugfixes +-------- + +- Pass attributes to realm clients `#69 `_ + +v1.2.0 +====== + +Major Changes +------------- + +- Provide config for multiple modcluster proxies `#60 `_ + +Minor Changes +------------- + +- Allow to configure TCPPING for cluster discovery `#62 `_ +- Drop community.general from dependencies `#61 `_ +- Switch middleware_automation.redhat_csp_download for middleware_automation.common `#63 `_ +- Switch to middleware_automation.common for rh-sso patching `#64 `_ + +v1.1.1 +====== + +Bugfixes +-------- + +- keycloak-quarkus: fix ``cache-config-file`` path in keycloak.conf.j2 template `#53 `_ + +v1.1.0 +====== + +Minor Changes +------------- + +- Update keycloak to 18.0.2 - sso to 7.6.1 `#46 `_ +- Variable ``keycloak_no_log`` controls ansible ``no_log`` parameter (for debugging purposes) `#47 `_ +- Variables to override service start retries and delay `#51 `_ +- keycloak_quarkus: variable to enable development mode `#45 `_ + +Breaking Changes / Porting Guide +-------------------------------- + +- Rename variables from ``infinispan_`` prefix to ``keycloak_infinispan_`` `#42 `_ + +Bugfixes +-------- + +- keycloak_quarkus: fix /var/log/keycloak symlink to keycloak log directory `#44 `_ + +v1.0.7 +====== + +Breaking Changes / Porting Guide +-------------------------------- + +- keycloak_quarkus: use absolute path for certificate files `#39 `_ + +Bugfixes +-------- + +- keycloak_quarkus: use become for tasks that will otherwise fail `#38 `_ + +v1.0.6 +====== + +Bugfixes +-------- + +- keycloak_quarkus: add selected java to PATH in systemd unit `#34 `_ +- keycloak_quarkus: set logfile path correctly under keycloak home `#35 `_ + +v1.0.5 +====== + +Minor Changes +------------- + +- Update config options: keycloak and quarkus `#32 `_ + +v1.0.4 +====== + +Release Summary +--------------- + +Internal release, documentation or test changes only. + +v1.0.3 +====== + +Major Changes +------------- + +- New role for installing keycloak >= 17.0.0 (quarkus) `#29 `_ + +Minor Changes +------------- + +- Add ``keycloak_config_override_template`` parameter for passing a custom xml config template `#30 `_ + +Bugfixes +-------- + +- Make sure systemd unit starts with selected java JVM `#31 `_ + +v1.0.2 +====== + +Minor Changes +------------- + +- Make ``keycloak_admin_password`` a default with assert (was: role variable) `#26 `_ +- Simplify dependency install logic and reduce play execution time `#19 `_ + +Bugfixes +-------- + +- Set ``keycloak_frontend_url`` default according to other defaults `#25 `_ + +v1.0.1 +====== + +Release Summary +--------------- + +Minor enhancements, bug and documentation fixes. + +Major Changes +------------- + +- Apply latest cumulative patch of RH-SSO automatically when new parameter ``keycloak_rhsso_apply_patches`` is ``true`` `#18 `_ + +Minor Changes +------------- + +- Clustered installs now perform database initialization on first node to avoid locking issues `#17 `_ + +v1.0.0 +====== + +Release Summary +--------------- + +This is the first stable release of the ``middleware_automation.keycloak`` collection. diff --git a/ansible_collections/middleware_automation/keycloak/CONTRIBUTING.md b/ansible_collections/middleware_automation/keycloak/CONTRIBUTING.md new file mode 100644 index 00000000..95b60edc --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/CONTRIBUTING.md @@ -0,0 +1,48 @@ +## Developing + +### Build and install locally + +Clone the repository, checkout the tag you want to build, or pick the main branch for the development version; then: + + ansible-galaxy collection build . + ansible-galaxy collection install middleware_automation-keycloak-*.tar.gz + + +### Development environment + +Make sure your development machine has avilable: + +* python 3.11+ +* virtualenv +* docker (or podman) + +In order to run setup the development environment and run the molecule tests locally, after cloning the repository: + +``` +# create new virtualenv using python 3 +virtualenv $PATH_TO_DEV_VIRTUALENV +# activate the virtual env +source $PATH_TO_DEV_VIRTUALENV/bin/activate +# install ansible and tools onto the virtualenv +pip install yamllint 'molecule>=6.0' 'molecule-plugins[docker]' 'ansible-core>=2.16' ansible-lint +# install collection dependencies +ansible-galaxy collection install -r requirements.yml +# install python dependencies +pip install -r requirements.txt molecule/requirements.txt +# execute the tests (replace --all with -s subdirectory to run a single test) +molecule test --all +``` + +## Contributor's Guidelines + +- All YAML files named with `.yml` extension +- Use spaces around jinja variables. `{{ var }}` over `{{var}}` +- Variables that are internal to the role should be lowercase and start with the role name +- Keep roles self contained - Roles should avoid including tasks from other roles when possible +- Plays should do nothing more than include a list of roles, except where `pre_tasks` and `post_tasks` are required, when possible +- Separators - Use valid names, ie. underscores (e.g. `my_role` `my_playbook`) not dashes (`my-role`) +- Paths - When defining paths, do not include trailing slashes (e.g. `my_path: /foo` not `my_path: /foo/`); when concatenating paths, follow the same convention (e.g. `{{ my_path }}/bar` not `{{ my_path }}bar`) +- Indentation - Use 2 spaces for each indent +- `vars/` vs `defaults/` - internal or interpolated variables that don't need to change or be overridden by user go in `vars/`, those that a user would likely override, go under `defaults/` directory +- All role arguments have a specification in `meta/argument_specs.yml` +- All playbooks/roles should be focused on compatibility with Ansible Automation Platform diff --git a/ansible_collections/middleware_automation/keycloak/FILES.json b/ansible_collections/middleware_automation/keycloak/FILES.json new file mode 100644 index 00000000..44510a93 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/FILES.json @@ -0,0 +1,1342 @@ +{ + "files": [ + { + "name": ".", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "LICENSE", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c5accbbd8546e94c34aed24afe689a617627d18eed5a6c48277e48db57c23851", + "format": 1 + }, + { + "name": "requirements.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "62b9fcbe66a43232653063da869d2f7a6a4167d20468116925c6274259cbe97a", + "format": 1 + }, + { + "name": "plugins", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/doc_fragments", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/doc_fragments/actiongroup_keycloak.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "62b517c6175c21ed16dccb225db5f65380e08d0933be66f338b4e84abdf00b68", + "format": 1 + }, + { + "name": "plugins/doc_fragments/attributes.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c8e6d5fd3904114ba76541527bd03cd42b2496bec989a8ec93a0e61aeaaf514e", + "format": 1 + }, + { + "name": "plugins/doc_fragments/keycloak.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f592dddd06bc5dff0f349492feccad85bcf445acf9ba7bda1d1445b74ea92cb8", + "format": 1 + }, + { + "name": "plugins/module_utils", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/module_utils/identity", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/module_utils/identity/keycloak", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/module_utils/identity/keycloak/_keycloak_utils.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6acbc34fbee1db99b72add6713fc33c65b2c34fc4caa30add887b56e716b3cfa", + "format": 1 + }, + { + "name": "plugins/module_utils/identity/keycloak/keycloak.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "59cdef884ab3304098a13a6d5efa2e082bf0053d1868af79f69ac5adb6f79d70", + "format": 1 + }, + { + "name": "plugins/module_utils/identity/keycloak/keycloak_clientsecret.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0c78d6bf8a867c25578681ec2a1610df2004afe82e41f8c9a537dd155721116e", + "format": 1 + }, + { + "name": "plugins/modules", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/modules/keycloak_group.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "49f88f24fb9036210c5591b31f2cf9be16225bde392e55737349cb603517f3ef", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_realm_rolemapping.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "5f5fb29bc44734bd5f80c3087eb3da84525c79c2de65aa7054b9065b284bdf7a", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_client_rolescope.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a30e7db56b4e9992ea3cf5026afae9f25383e2e1b70b0a3524dfa6b2658140a8", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_realm_keys_metadata_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c8ad6ae5787eddd5f103f0a1cfda57060c73ab24012bd2e0f18c412b376a4455", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_clientsecret_regenerate.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6345105da3281c4bf8fe841762aa71956f6c56495a77e611b715bc3c12ccf624", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_component.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1dff354343ba96ba050b80b9a1cfbaa9d42f5902b70f2b5eb1bb0b022dc52f73", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_realm_key.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "06419e01f5be8698f5738c6d9ec2b2f29cc3916d3a7b4bc72019ac88fbcfa043", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_user_federation.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d1fbee2f6e49e355fd83e018569d7e171dd4fef80be64beb12e23f9d9826c9ee", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_realm_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ecc4e8964e7c58c09f6c6a7267555e9e196cc17af4c1e72db3ba750755a1e0c2", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_authentication_v2.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a1dd757cd689b6128ea9e54bbf2340121becb2598e721b98feda4ddab3ff5137", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_authentication.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "5279ecdf27912f9983b298b16678beff1de5cd70a4efa65640df781e4442058a", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_authz_authorization_scope.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c73a934b13b862ced155adf39138b400c9f731ea862e8820ec3a8ea74bad2f6a", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_realm_users_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ec728a32f3ba87d907a67784e399859f648a94e0825809ed2733dcec804c4dbe", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_authentication_required_actions.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a91c0c2a4ca15312af4f8f1c393b04d3ac3a6c9fdf3933957880dd2fedd0e231", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_client.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "5b81b741c80c4a8c85fc5d593f73d4232bc2c68c06e5f4c1ddc98aca60a2640f", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_realm.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ec2c6fd3e9dafa3d1846fe1abda8ca3ea3469dfb814ac722549ed9f37b183bfb", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_userprofile.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b221c2d7d62bff26e29a7d54f1708dfe670127a396d65a1784826ee3b1cb37bf", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_user_rolemapping.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4de6955f1227f7f322fbfbc38c00933f7a92a1aaa3cf373f68d7d8a394c43a37", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_client_scope_rolemappings.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9e1d5a723a4c487cecfddb2a107d318444728c68a10f9fcb6cf680d877963b1b", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_organization_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ab15eb33c723dd4c297e95036de96b1a5f1e1f4f07570ec2b93f0b9487274390", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_authz_custom_policy.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "112440d9ee3a684eebc7b3592cf14088f230831dbe7e58f8344fa4210a5cb3a1", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_user.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9d6c810e9f429da4403f4c135bf3d3ae50e0f6257948d6b314882c31c8b3800f", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_authz_permission.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c3aa5b40a3738d87f29d101a683b93201f369b1f5bd32146f789ce6800b69eb9", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_client_scope_type.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a7c83dd539100843b5499cebdc9de97606d9576f33ccad7cbf7b52d9d08e70c3", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_component_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f0b7d3e8de96513710b8b111ba9f6f7df321d54f465f9ec9957f51e30e6fc815", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_identity_provider.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c78150b4d4955112f010a9947fdf55d9c15fd067ff009eecffbf3f8b0306e9ed", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_authentication_flow.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "02cd9666c700c6f113b099e8355d800ec3990f42496de9d402df73168b819f1d", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_clienttemplate.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "86833f54e4ef36f25b972c7525e08729a221961270b8081e51585dbe4b3f3f12", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_authz_permission_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7dbc20263f635978783b2c7d9872644d668f1ecafc9d189b69a04c2a5f611b73", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_client_scope.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "5f80fc7fe6f01e9737202dbb27cbe774e27c88e579572d38550c73361d442d25", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_client_rolemapping.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "327507e52269324f05487105e148a23139689ec183350a6c32559b774e408d98", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_user_execute_actions_email.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "59b51c2a000704ff2203b7c99702a3b7763818dfe1d44d2ab0d06146f80b81e2", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_realm_localization.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d03130f75b7d783cf76c11987dbbc91b76910726f0eab4cc1dcb9a70f6922632", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_clientsecret_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3afa6c413e570aa5b59e628787100d0d61f388735707c9270da90c2442e6a236", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_role.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f74f924a53fc74f2ca2dfe910e926dda88d2c9ba86bd676647b1ecf114bba4d1", + "format": 1 + }, + { + "name": "plugins/modules/keycloak_organization.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6aacf29cf2b3e53c80149a6092da690b74d1810eed93c949b979ecc01e5fb396", + "format": 1 + }, + { + "name": "tests", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/config.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "676eebafffc260b040cf5e7ec5d9b585577832c5ee4a3853f0901aa2892b515e", + "format": 1 + }, + { + "name": "meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "meta/execution-environment.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f3285a029987341b8d8c11e189dc50f8ef5321329702a71f0ab54b0ce56cf032", + "format": 1 + }, + { + "name": "meta/runtime.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7b786d5e6bc9c2f2aad2c3096522b4b6cc987f01f65ee9ac4dc13e2063e55177", + "format": 1 + }, + { + "name": "roles", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_realm", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_realm/vars", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_realm/vars/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4b345843fc8549023002dabceed2bf95cdad1d11ca2e5291af292b51719f5aa9", + "format": 1 + }, + { + "name": "roles/keycloak_realm/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_realm/tasks/manage_user.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "475fd37180d9a18fd886b4a1d45e156422851ab52c9f57a35a8bca09b291ddb1", + "format": 1 + }, + { + "name": "roles/keycloak_realm/tasks/manage_user_roles.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "683139ac9b4be0c6b360a9d0af12fc455340c70ee791e82cb1dfb14d2b698874", + "format": 1 + }, + { + "name": "roles/keycloak_realm/tasks/manage_client_users.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d2da4b172da680929cc84c2d545f80a085e32f2f9199871da7750f9b58130504", + "format": 1 + }, + { + "name": "roles/keycloak_realm/tasks/manage_token_lifespan.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "24fdecfab760f2996ccdb31fdf70e6c3fbd1a4859d1a151b38be70572cd8d79f", + "format": 1 + }, + { + "name": "roles/keycloak_realm/tasks/manage_client_roles.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "646e672899b94a0bcb0b8d5c070be410c450e037acebbcd814344d4779ea6f58", + "format": 1 + }, + { + "name": "roles/keycloak_realm/tasks/manage_user_client_roles.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "bad108985a2589ef72db100b9fdd0a23a4ba96bd10a0160cab9ce70185f9cb56", + "format": 1 + }, + { + "name": "roles/keycloak_realm/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d24aca241e5fa20712daa8f3fb03c91bef04e9b02e62ef2ddd370430803aad56", + "format": 1 + }, + { + "name": "roles/keycloak_realm/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_realm/meta/argument_specs.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "698f17455d3e1a7f54f44b456cf349daf600392be180df1b462fc86dc65a9240", + "format": 1 + }, + { + "name": "roles/keycloak_realm/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a3dc9c13f5d068a66e5cb167476e575d2ea5141cf912d6ea164d53ac56741cf4", + "format": 1 + }, + { + "name": "roles/keycloak_realm/README.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d20faf6b90755adcbd51d210c75811f31a2e05c7871746842b085e78d7d5634a", + "format": 1 + }, + { + "name": "roles/keycloak_realm/defaults", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_realm/defaults/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d37486ed3b2cf12cfa5e82b9d63f0c54f100016723a3262dfc094991a8383990", + "format": 1 + }, + { + "name": "roles/keycloak_realm/templates", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_realm/templates/realm.json.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f34b8c456b95ef21332f57b0c452cf575b72a22bcaeefda178ac7bb76abfabed", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/vars", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/vars/redhat.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "fbfa3a0590deb64d6b6c8b296d0bbb0dc1c09f28dfb54c29f6edde638020afde", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/vars/debian.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9ea7babef37d8d21149a8a68b025bb18c538cedd6c8a01d63e1e86c890a07668", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/vars/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d8d91c07f1bfadf1499d8d8fb0c303a041e2db568dafb72e5482c0f7f96b2598", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/redhat.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7a595bacc8a9c0349f28c9f42e4150820bcba8d030e1a60e673708580de9336c", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/systemd.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "2e53906a20c4ee050b54e5a751253959869a35f9e5e89deb72d14a2f49646e61", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/rebuild_config.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9ecc5a12afa3fe1d9344a576ef1cad37809a87767cbacbfc277642b6ae60506e", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/start.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d9b40f452164c4e84069964c1e5a68891842995f453119fcf24ac1745e604660", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/config_store.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "5c2cfddd3d3cb760d1da164b457d10f47b8830b17ffc52b6e0dfb860231967d3", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/bootstrapped.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "79129ea4710b8ce6074ae866229ac4f7c31f5e6bc08449f27989aa5201cdbd31", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/prereqs.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "2f4fc8cf746055d423644a17dd1d5ba9e644d4dbb85dd9be68eae530cf01915c", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/jdbc_driver.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6ba8029d68b4a94bc211d59f6ac85f6968ed9dc6d2ef2b1777641225a3d571b4", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/debian.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "90ba09966495b85b7cced33b752309fbcd644b1f76fd3d78619e4c9e427a7ad9", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/invalidate_theme_cache.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d0052aba0d6d0226e56c7cf8303b21399835ff8d35f5f668e72be4f1e4af145f", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/iptables.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "62c25a4b4cb260e0dc7a2ca7572dc7990025f103d339b7620528147f20a6106e", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/deprecations.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "18fdf23bfb78388b366939b4211876f763f2825de143e55b84dc2e21143d4eb4", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/restart", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/restart/none.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f350151c429db2ee040fa29c5245e16939237f0344d4793fea183eb34c694692", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/restart/serial.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b032ac4c252616013e368d84523c5b8b5a498368deb6f0a642547e6261fe2709", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/restart/serial_then_parallel.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1f3822e518792f060e18a9fbffea42fa7eb437cc2153b74953d813176ea55a77", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/restart.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "90bec060f6ae881d6aa6e7039b3baf2aa48262438bfc941e310366417e2b520e", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/firewalld.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "848ac8492c77c3600e7cf69a32d7197dc34f8af01a6904d8c689870f311714ad", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/install.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "cca9d6bea514ef976a37a613d176b9e41cd49b0f40ca536771adf8747f3b82c4", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/fastpackages.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "090bfa9a84c71a4ff0180a6896f26de83a45a335649154692000a13722574672", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d05414f2e5eb3d9046a501e2070bd8b1114d9ae06a9d34725576d4ec7dddacc3", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/meta/argument_specs.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "de42275077d842bd52253ed1914541fbf607caa39089f16ffb9e7d2b392f693a", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f6416100fcfd727cf16aa79459e5ece8d902b7e10b78410a012d462622911b89", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/README.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4de242a2f2d0d0efd84986c2ee8f8ec3f50374b362c20b5eb31b7e7c4df4f4c1", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/defaults", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/defaults/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "01a8c7ed8f7a7178681086d673cacd0141c2ddc859f74bc948e224e9923b150d", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates/rhbk.conf.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3aa9e25df9a3295c09152f15d6e4dee64eff4b2935087db7db6e7c10e14d17ca", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates/rhbk.fact.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "46cd28321efe9a9315d4ff0f33631e23a59c303e9468320b24f6dbdbd8de9bb0", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates/keycloak.service.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "46ffc1d7bb28e01ffdd3a88b5845306c496a2356520814eb18785778d7688109", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates/keycloak-sysconfig.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d4853c49c55a7536ee6bbcf9252060615026c1a631c619103f9158e0033b7da8", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates/cache-ispn.xml.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "779369c8d0bd7b91faa5e42230957b10a301b1ebea36deca181403fe2f47ab5b", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates/quarkus.properties.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a89d80157093bdd3643f0ecd6f8e8348a40ee4464e9fb213925fa7be27276805", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates/rhbk-sysconfig.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d4853c49c55a7536ee6bbcf9252060615026c1a631c619103f9158e0033b7da8", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates/keycloak.conf.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3aa9e25df9a3295c09152f15d6e4dee64eff4b2935087db7db6e7c10e14d17ca", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates/keycloak.fact.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "46cd28321efe9a9315d4ff0f33631e23a59c303e9468320b24f6dbdbd8de9bb0", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/templates/rhbk.service.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "46ffc1d7bb28e01ffdd3a88b5845306c496a2356520814eb18785778d7688109", + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/handlers", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak_quarkus/handlers/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "310ea9e26c3f859831b6f5adc7f24987f390cb61ea25d50aa1f1867afb9d1765", + "format": 1 + }, + { + "name": "roles/keycloak", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak/vars", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak/vars/redhat.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1e3cc831e91e7daec90a158812d12aee6efef5ea8f60c34cd36c6e193cb81a17", + "format": 1 + }, + { + "name": "roles/keycloak/vars/debian.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "67d6cec8ecf8d4ae797232ec0c86a612bb7c1eae30f9099a0209b5cd3086ec1e", + "format": 1 + }, + { + "name": "roles/keycloak/vars/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "fc227b7c8101ce06f8c07f7dd4a3fdac8d8df7f01972513fea0a65432cda8bac", + "format": 1 + }, + { + "name": "roles/keycloak/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak/tasks/redhat.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "772519d8e46468d69eee54deca78314d64c5ce5d39e65d8cd3c25b3ade2044de", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/systemd.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "8a310d44e5a2610b1fe50d5b955856bad3c1909be462a897cd8ce12b2fb603b4", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/restart_keycloak.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "53e37cb73c1900cb2c62565d5794a9b0c273b8cab2eb1cb29290c1eccef469d7", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/start_keycloak.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c3d3050c3fad387917c465f032349b7c7060c37c44569c57521fd0154e7849ef", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/prereqs.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e968d747938a3813ef44d311f0c8bcd57d5d278d47330584e8af80103c6f7b95", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/jdbc_driver.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ee92d36c574a627e51ba66e2c27d57926cff6dc79ab7ab80164ffb60fc419c26", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/debian.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b4addefc50fd194eb9d940fa77e6c34d7ceb38ede6632d4f3cdae0152b64573d", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/rhsso_patch.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "28c2e4193d32ea4c6f6797d772ab3f42d891cf178ec2f4438eb475a8db354ef4", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/iptables.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f359df07de3c8ec8aa6c7cf784aa72325b258c303a3f994ffe2605d78d9a3e7b", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/stop_keycloak.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "29523ffe76a60989de8498f563bbbf6f35bbc184c6ca27a5411fca76e0116f2a", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/firewalld.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6d4cad4c859c955abf59c387a72a909fa3392f7ff1597220128533607f0f3df7", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/install.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "824bef83a3e8490bf1100cff45763f47d1e6577406326dcd045f1e3a3026de1e", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/fastpackages.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "5854506a4f2856f29c50966ed882866a0e7d45683edf210e5e055ca7976917e5", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1ccd71e616ac4e01acacbc0dce9ba10046e974201f7b1ec726ec3edadb80aa71", + "format": 1 + }, + { + "name": "roles/keycloak/tasks/rhsso_cli.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f2255bfef8fc8250eff084fb7693595d048ee92dc8c16627cb02c633657489e1", + "format": 1 + }, + { + "name": "roles/keycloak/meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak/meta/argument_specs.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "002f0916ab9aadeae04185fada0ef50f72a3a65190cfb1d0936f0b58e3845907", + "format": 1 + }, + { + "name": "roles/keycloak/meta/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7fbf93956ca4eda9e4f643a29de87d160740a1338b5291fcb0ed6178c2e30028", + "format": 1 + }, + { + "name": "roles/keycloak/README.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3f7e7eb919180b2aa0ad6d34b16e7132014e73f2964a55e5759851862d45c937", + "format": 1 + }, + { + "name": "roles/keycloak/defaults", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak/defaults/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6ba12a7ac24244ffc4d8c4ae46feabe2fa527e9ae44278f0a4e8e767326dba06", + "format": 1 + }, + { + "name": "roles/keycloak/templates", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak/templates/keycloak.service.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "bc95e3e3f9728fd9eee8a19639a8530d3200cac3d39bb3703541acc3fc668e16", + "format": 1 + }, + { + "name": "roles/keycloak/templates/keycloak-sysconfig.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "da98e79c8f56aa5e8ed84e71b68fcf8c62903f89912c32c8dda6930b1aad4f41", + "format": 1 + }, + { + "name": "roles/keycloak/templates/jdbc_driver_module.xml.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a57fae16c95a5a34bb452bd755778eb7d4c4ea1bf318c8fa092b24a3dabce94c", + "format": 1 + }, + { + "name": "roles/keycloak/templates/9.0.2", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak/templates/9.0.2/standalone.xml.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "862921a8271267b18d02e04ef500555e76b689787a4096aa9c02297e26a68870", + "format": 1 + }, + { + "name": "roles/keycloak/templates/9.0.2/standalone-infinispan.xml.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "20381315228826484e37eaacdfa081727b45a2e3925af4bb2c9102854743ab6f", + "format": 1 + }, + { + "name": "roles/keycloak/templates/standalone.xml.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "204af7240a687bc1ba5abaf628f9fbf50b4242a1e6c6bbe7b976cd30b9a8319e", + "format": 1 + }, + { + "name": "roles/keycloak/templates/standalone-infinispan.xml.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "56180efc04e856eb3f6c5eea089fb9039d89db93159e20379516225be677441d", + "format": 1 + }, + { + "name": "roles/keycloak/templates/standalone-ha.xml.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "da78c77f200a1523b8c46378028a7ec4ea3d5d2e1ccd31feebe9c36d0af2a991", + "format": 1 + }, + { + "name": "roles/keycloak/templates/keycloak-service.sh.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7a907f0cd16b07fe8121f0725ac34f099e371f55fc9bf45bb52ea282745d08bf", + "format": 1 + }, + { + "name": "roles/keycloak/templates/15.0.8", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak/templates/15.0.8/standalone.xml.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e1417a95946d32fb97ed28066574a3ab9feb211036665631f939db40293af719", + "format": 1 + }, + { + "name": "roles/keycloak/templates/15.0.8/standalone-infinispan.xml.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d75829a469fa44b61dba4f019845640de4256bf032a03e65712235a97410c00b", + "format": 1 + }, + { + "name": "roles/keycloak/templates/keycloak-profile.properties.j2", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e8da324ea9203fb40560e9e091e63124abe3e69e9d9d7f1e2b251a16bcc96a2a", + "format": 1 + }, + { + "name": "roles/keycloak/handlers", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "roles/keycloak/handlers/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "242f064638ab37d360678ec7361a9239edbfe118e6d13c9cf0b3bd29c0c556d1", + "format": 1 + }, + { + "name": "playbooks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "playbooks/rhsso.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b985ba6d7651d7c4930dcad6c3bcf67b6c628324b02eb8a757456f9d3ad203b0", + "format": 1 + }, + { + "name": "playbooks/keycloak.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "17ffc7d35de2050a5cb78fc90519ffdd3c8b20337771b500001b4f602eb01fe3", + "format": 1 + }, + { + "name": "playbooks/keycloak_realm.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ae150bd651bce66fa4b9521754232c110d29fffeb0c84397bacc7de4e585861a", + "format": 1 + }, + { + "name": "playbooks/roles", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "playbooks/keycloak_federation.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b0c20e3d3b9f6f581460dcad666e7c7a215d8cf4a374a11e1fe5a19195df6d40", + "format": 1 + }, + { + "name": "playbooks/keycloak_quarkus.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0a2c4786479379f1e74076fdbe47b6bcf8d891d4022d9de66acdb8f074d58ceb", + "format": 1 + }, + { + "name": "playbooks/keycloak_quarkus_dev.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b479a912ddb8d3e1e6ef83e8a8ed8478b58c2d23352974683ee3676e18c24ca7", + "format": 1 + }, + { + "name": "playbooks/keycloak_realm_client.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4f7c0a9933e3c2c60645d605cbaf7e372d24a56a2ab139ef1a26e75d1cf36403", + "format": 1 + }, + { + "name": "playbooks/keycloak_client_scope.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e850b2c4144b7e6bb3d59382c9e948c24f49d4d394fb418d16a9b4a7ddc23f1d", + "format": 1 + }, + { + "name": "playbooks/keycloak_authentication_flow.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b9c3fc839bf506d6ab5320a061a3d77a5f36838904e07a1b70629bdbe4d00b", + "format": 1 + }, + { + "name": "docs", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "docs/index.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b6ab5a64517a1fec2da3772b7438a5d0c947dfe275d1a49d39400df446936b5d", + "format": 1 + }, + { + "name": "docs/releasing.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3e7253741227bc9b2be9e08fd77db000a125ce0d282ce5b2eb7be68c1106bae0", + "format": 1 + }, + { + "name": "docs/requirements.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4bb6fd172ce0521e6875703521b481f362190eb9dbfdc228b35436c52961c971", + "format": 1 + }, + { + "name": "docs/developing.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ae832464c20e3b370203afc16eb4ff7a7525008f87d713a77449acb8faf1d0e0", + "format": 1 + }, + { + "name": "docs/testing.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0903050079ef60933c6ad7d0e02fcd5902d8d41cc5884b811640189d8c72388f", + "format": 1 + }, + { + "name": "docs/README.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c9f99f1835959d85f8be9fb6775e6e8eab32b213ae0c787a3d579f1c90d36f0e", + "format": 1 + }, + { + "name": "docs/CHANGELOG.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7b2508e3bd5f90ae163c30a1500220383e1a826b301479a4bed7eed9f3543dc6", + "format": 1 + }, + { + "name": "gh-release.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d1923d8fcd9f5bd059900641e65d00414eda613e02cc41a658c7dd3cf2366ada", + "format": 1 + }, + { + "name": "README.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c9f99f1835959d85f8be9fb6775e6e8eab32b213ae0c787a3d579f1c90d36f0e", + "format": 1 + }, + { + "name": "bindep.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "fd915c2e512ced3736ac2af81be1601cf6490360966ba21e81b5b7827176b2ab", + "format": 1 + }, + { + "name": "CONTRIBUTING.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ae832464c20e3b370203afc16eb4ff7a7525008f87d713a77449acb8faf1d0e0", + "format": 1 + }, + { + "name": "requirements.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d6fa5a0e4e7aecbd92d021223cd27485fca7b569c65715993bfdff629dce7c6f", + "format": 1 + }, + { + "name": "CHANGELOG.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7b2508e3bd5f90ae163c30a1500220383e1a826b301479a4bed7eed9f3543dc6", + "format": 1 + } + ], + "format": 1 +} \ No newline at end of file diff --git a/ansible_collections/middleware_automation/keycloak/LICENSE b/ansible_collections/middleware_automation/keycloak/LICENSE new file mode 100644 index 00000000..9c8f3ea0 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/ansible_collections/middleware_automation/keycloak/MANIFEST.json b/ansible_collections/middleware_automation/keycloak/MANIFEST.json new file mode 100644 index 00000000..5cdef90f --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/MANIFEST.json @@ -0,0 +1,50 @@ +{ + "collection_info": { + "namespace": "middleware_automation", + "name": "keycloak", + "version": "3.0.11", + "authors": [ + "Romain Pelisse ", + "Guido Grazioli ", + "Pavan Kumar Motaparthi ", + "Helmut Wolf ", + "Harsha Cherukuri " + ], + "readme": "README.md", + "tags": [ + "keycloak", + "redhat", + "rhel", + "sso", + "openid", + "application", + "identity", + "security", + "infrastructure", + "authentication", + "java", + "runtimes", + "middleware", + "a4mw" + ], + "description": "Install and configure a keycloak, or Red Hat Single Sign-on, service.", + "license": [], + "license_file": "LICENSE", + "dependencies": { + "middleware_automation.common": ">=1.2.1", + "ansible.posix": ">=1.4.0" + }, + "repository": "https://github.com/ansible-middleware/keycloak", + "documentation": "https://ansible-middleware.github.io/keycloak", + "homepage": "https://github.com/ansible-middleware/keycloak", + "issues": "https://github.com/ansible-middleware/keycloak/issues" + }, + "file_manifest_file": { + "name": "FILES.json", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "2e1d81deb872cc75f3b57d336f05fe3cc7f76210e310d4e212a1565877fcdd68", + "format": 1 + }, + "format": 1 +} \ No newline at end of file diff --git a/ansible_collections/middleware_automation/keycloak/README.md b/ansible_collections/middleware_automation/keycloak/README.md new file mode 100644 index 00000000..5aa3cacc --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/README.md @@ -0,0 +1,225 @@ +# Ansible Collection - middleware_automation.keycloak + + +[![Build Status](https://github.com/ansible-middleware/keycloak/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ansible-middleware/keycloak/actions/workflows/ci.yml) + +> **_NOTE:_ If you are Red Hat customer, install `redhat.rhbk` (for Red Hat Build of Keycloak) or `redhat.sso` (for Red Hat Single Sign-On) from [Automation Hub](https://console.redhat.com/ansible/ansible-dashboard) as the certified version of this collection.** + + + +Collection to install and configure [Keycloak](https://www.keycloak.org/) or [Red Hat Single Sign-On](https://access.redhat.com/products/red-hat-single-sign-on) / [Red Hat Build of Keycloak](https://access.redhat.com/products/red-hat-build-of-keycloak). + + +## Ansible version compatibility + +This collection has been tested against following Ansible versions: **>=2.16.0**. + +Plugins and modules within a collection may be tested with only specific Ansible versions. A collection may contain metadata that identifies these versions. + + + +## Installation + + +### Installing the Collection from Ansible Galaxy + +Before using the collection, you need to install it with the Ansible Galaxy CLI: + + ansible-galaxy collection install middleware_automation.keycloak + + + +You can also include it in a `requirements.yml` file and install it via `ansible-galaxy collection install -r requirements.yml`, using the format: + +```yaml +--- +collections: + - name: middleware_automation.keycloak +``` + +The keycloak collection also depends on the following python packages to be present on the controller host: + +* netaddr +* lxml + +A requirement file is provided to install: + + pip install -r requirements.txt + + +### Included roles + +* [`keycloak_quarkus`](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak_quarkus/README.md): role for installing keycloak (>= 19.0.0, quarkus based). +* [`keycloak_realm`](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak_realm/README.md): role for configuring a realm, user federation(s), clients and users, in an installed service. +* [`keycloak`](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak/README.md): role for installing legacy keycloak (<= 19.0, wildfly based). + + + +### Included modules + +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_client_scope_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_realm_users_info`: retrieve users from a realm. +* `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). + +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. + +### Install Keycloak + +* [`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) + +Making the keycloak zip archive available to the playbook working directory, and setting `keycloak_offline_install` to `true`, allows to skip +the download tasks. The local path for the archive does match the downloaded archive path, so that it is also used as a cache when multiple hosts are provisioned in a cluster. + +```yaml +keycloak_offline_install: true +``` + + + + + + +#### Install from alternate sources (like corporate Nexus, artifactory, proxy, etc) + +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 + +Execute the following command from the source root directory: + +```bash +ansible-playbook -i playbooks/keycloak_quarkus.yml -e keycloak_quarkus_bootstrap_admin_password= +``` + +- `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 + + ``` + [keycloak] + localhost ansible_connection=local + ``` + +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 + + +* [`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_federation.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_federation.yml) configures user federation providers. + +#### Example configuration command + +Execute the following command from the source root directory: + +```bash +ansible-playbook -i playbooks/keycloak_realm.yml -e keycloak_realm_admin_password= -e keycloak_realm_realm=test +``` + +- `keycloak_realm_admin_password` password for the administration console user account. +- `keycloak_realm_realm` name of the realm to be created/used. +- `ansible_hosts` is the inventory, below is an example inventory for deploying to localhost + + ``` + [keycloak] + localhost ansible_connection=local + ``` + +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 + + + +For bug reports and feature requests, use [GitHub Issues](https://github.com/ansible-middleware/keycloak/issues). + + + + +## Release and Upgrade Notes + +For details on changes between versions, please see the [CHANGELOG](https://github.com/ansible-middleware/keycloak/blob/main/CHANGELOG.rst) for this collection. + + +## License + +Apache License v2.0 or later + +See [LICENSE](https://github.com/ansible-middleware/keycloak/blob/main/LICENSE) to view the full text. + diff --git a/ansible_collections/middleware_automation/keycloak/bindep.txt b/ansible_collections/middleware_automation/keycloak/bindep.txt new file mode 100644 index 00000000..e27e8c00 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/bindep.txt @@ -0,0 +1,10 @@ +python3-dev [compile platform:dpkg] +python3-devel [compile platform:rpm] +python39-devel [compile platform:centos-8 platform:rhel-8] +git-lfs [platform:rpm platform:dpkg] +python3-netaddr [platform:rpm platform:dpkg] +python3-lxml [platform:rpm platform:dpkg] +python3-jmespath [platform:rpm platform:dpkg] +python3-requests [platform:rpm platform:dpkg] +podman [platform:rpm platform:dpkg] + diff --git a/ansible_collections/middleware_automation/keycloak/docs/CHANGELOG.rst b/ansible_collections/middleware_automation/keycloak/docs/CHANGELOG.rst new file mode 120000 index 00000000..e22698ba --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/docs/CHANGELOG.rst @@ -0,0 +1 @@ +../CHANGELOG.rst \ No newline at end of file diff --git a/ansible_collections/middleware_automation/keycloak/docs/README.md b/ansible_collections/middleware_automation/keycloak/docs/README.md new file mode 120000 index 00000000..32d46ee8 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/docs/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/ansible_collections/middleware_automation/keycloak/docs/developing.md b/ansible_collections/middleware_automation/keycloak/docs/developing.md new file mode 120000 index 00000000..44fcc634 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/docs/developing.md @@ -0,0 +1 @@ +../CONTRIBUTING.md \ No newline at end of file diff --git a/ansible_collections/middleware_automation/keycloak/docs/index.rst b/ansible_collections/middleware_automation/keycloak/docs/index.rst new file mode 100644 index 00000000..6c46ab18 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/docs/index.rst @@ -0,0 +1,34 @@ +.. Red Hat middleware_automation Keycloak Ansible Collection documentation main file + +Welcome to Keycloak Collection documentation +============================================ + +.. toctree:: + :maxdepth: 2 + :caption: User documentation + + README + plugins/index + roles/index + Changelog + +.. toctree:: + :maxdepth: 2 + :caption: Developer documentation + + Developing + Testing + Releasing + +.. toctree:: + :maxdepth: 2 + :caption: Middleware collections + + Keycloak / Red Hat Single Sign-On + Infinispan / Red Hat Data Grid + Wildfly / Red Hat JBoss EAP + Tomcat / Red Hat JWS + ActiveMQ / Red Hat AMQ Broker + Kafka / Red Hat AMQ Streams + Ansible Middleware utilities + JCliff diff --git a/ansible_collections/middleware_automation/keycloak/docs/releasing.md b/ansible_collections/middleware_automation/keycloak/docs/releasing.md new file mode 100644 index 00000000..537ba1e9 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/docs/releasing.md @@ -0,0 +1,61 @@ +# Collection Versioning Strategy + +Each supported collection maintained by Ansible follows Semantic Versioning 2.0.0 (https://semver.org/), for example: +Given a version number MAJOR.MINOR.PATCH, the following is incremented: + +MAJOR version: when making incompatible API changes (see Feature Release scenarios below for examples) + +MINOR version: when adding features or functionality in a backwards compatible manner, or updating testing matrix and/or metadata (deprecation) + +PATCH version: when adding backwards compatible bug fixes or security fixes (strict). + +Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format. + +The first version of a generally available supported collection on Ansible Automation Hub shall be version 1.0.0. NOTE: By default, all newly created collections may begin with a smaller default version of 0.1.0, and therefore a version of 1.0.0 should be explicitly stated by the collection maintainer. + +## New content is added to an existing collection + +Assuming the current release is 1.0.0, and a new module is ready to be added to the collection, the minor version would be incremented to 1.1.0. The change in the MINOR version indicates an additive change was made while maintaining backward compatibility for existing content within the collection. + + +## New feature to existing plugin or role within a collection (backwards compatible) + +Assuming the current release is 1.0.0, and new features for an existing module are ready for release . We would increment the MINOR version to 1.1.0. The change in the MINOR version indicates an additive change was made while maintaining backward compatibility for existing content within the collection. + + +## Bug fix or security fix to existing content within a collection + +Assuming the current release is 1.0.0 and a bug is fixed prior to the next minor release, the PATCH version would be incremented to 1.0.1. The patch indicates only a bug was fixed within a current version. The PATCH release does not contain new content, nor was functionality removed. Bug fixes may be included in a MINOR or MAJOR feature release if the timing allows, eliminating the need for a PATCH dedicated to the fix. + + +## Breaking change to any content within a collection + +Assuming the current release is 1.0.0, and a breaking change (API or module) is introduced for a user or developer. The MAJOR version would be incremented to 2.0.0. + +Examples of breaking changes within a collection may include but are not limited to: + + - Argspec changes for a module that require either inventory structure or playbook changes. + - A change in the shape of either the inbound or returned payload of a filter plugin. + - Changes to a connection plugin that require additional inventory parameters or ansible.cfg entries. + - New functionality added to a module that changes the outcome of that module as released in previous versions. + - The removal of plugins from a collection. + + +## Content removed from a collection + +Deleting a module or API is a breaking change. Please see the 'Breaking change' section for how to version this. + + +## A typographical error was fixed in the documentation for a collection + +A correction to the README would be considered a bug fix and the PATCH incremented. See 'Bug fix' above. + + +## Documentation added/removed/modified within a collection + +Only the PATCH version should be increased for a release that contains changes limited to revised documentation. + + +## Release automation + +New releases are triggered by annotated git tags named after semantic versioning. The automation publishes the built artifacts to ansible-galaxy and github releases page. diff --git a/ansible_collections/middleware_automation/keycloak/docs/requirements.txt b/ansible_collections/middleware_automation/keycloak/docs/requirements.txt new file mode 100644 index 00000000..0d9df9ba --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/docs/requirements.txt @@ -0,0 +1,7 @@ +antsibull>=0.17.0 +antsibull-docs +antsibull-changelog +ansible-core>=2.16.0 +ansible-pygments +sphinx-rtd-theme +myst-parser diff --git a/ansible_collections/middleware_automation/keycloak/docs/testing.md b/ansible_collections/middleware_automation/keycloak/docs/testing.md new file mode 100644 index 00000000..8e773eab --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/docs/testing.md @@ -0,0 +1,23 @@ +# Testing + +## Continuous integration + +The collection is tested with a [molecule](https://github.com/ansible-community/molecule) setup covering the included roles and verifying correct installation and idempotency. +In order to run the molecule tests locally with python 3.9 available, after cloning the repository: +The test scenarios are available on the source code repository each on his own subdirectory under [molecule/](https://github.com/ansible-middleware/keycloak/molecule). + + +## Test playbooks + +Sample playbooks are provided in the `playbooks/` directory; to run the playbooks locally (requires a rhel system with python 3.9+, ansible, and systemd) the steps are as follows: + +``` +# setup environment as in developing +# create inventory for localhost +cat << EOF > inventory +[keycloak] +localhost ansible_connection=local +EOF +# run the playbook +ansible-playbook -i inventory playbooks/keycloak.yml +``` diff --git a/ansible_collections/middleware_automation/keycloak/gh-release.md b/ansible_collections/middleware_automation/keycloak/gh-release.md new file mode 100644 index 00000000..2ac28c2f --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/gh-release.md @@ -0,0 +1,17 @@ + v3.0.11 + ======= + + Minor Changes + ------------- + + - Add certification checks to the upstream repo #378 + - Fix cert workflow #389 + - Fix health check URL to include HTTP relative path #364 + - fix(keycloak_quarkus): avoid leaking maven provider password in logs #363 + + Bugfixes + -------- + + - AMW-592 AMW Github Actions are failing because of latest molecule #366 + - AMW-594 Upstream release is failing due to recent rules change #373 + diff --git a/ansible_collections/middleware_automation/keycloak/meta/execution-environment.yml b/ansible_collections/middleware_automation/keycloak/meta/execution-environment.yml new file mode 100644 index 00000000..dba85a82 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/meta/execution-environment.yml @@ -0,0 +1,11 @@ +--- +version: 1 +build_arg_defaults: + EE_BASE_IMAGE: 'quay.io/ansible/ansible-runner:stable-2.12-devel' +dependencies: + galaxy: requirements.yml + python: requirements.txt + system: bindep.txt +additional_build_steps: + append: + - RUN alternatives --set python /usr/bin/python3 diff --git a/ansible_collections/middleware_automation/keycloak/meta/runtime.yml b/ansible_collections/middleware_automation/keycloak/meta/runtime.yml new file mode 100644 index 00000000..ecdb9d9d --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/meta/runtime.yml @@ -0,0 +1,65 @@ +--- +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_client_scope_type + - keycloak_client_scope_rolemappings + - keycloak_clientsecret_info + - keycloak_clientsecret_regenerate + - keycloak_clienttemplate + - keycloak_component + - keycloak_component_info + - keycloak_group + - keycloak_identity_provider + - keycloak_organization + - keycloak_organization_info + - keycloak_realm + - keycloak_realm_info + - keycloak_realm_key + - keycloak_realm_keys_metadata_info + - keycloak_realm_localization + - keycloak_realm_rolemapping + - keycloak_realm_users_info + - 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. + + keycloak_clientscope_type: + redirect: middleware_automation.keycloak.keycloak_client_scope_type + deprecation: + removal_version: 5.0.0 + warning_text: >- + The module has been renamed to keycloak_client_scope_type for Keycloak 17+ (Quarkus). + Update playbooks to use middleware_automation.keycloak.keycloak_client_scope_type. + + keycloak_clientscope_rolemappings: + redirect: middleware_automation.keycloak.keycloak_client_scope_rolemappings + deprecation: + removal_version: 5.0.0 + warning_text: >- + The module has been renamed to keycloak_client_scope_rolemappings for Keycloak 17+ (Quarkus). + Update playbooks to use middleware_automation.keycloak.keycloak_client_scope_rolemappings. diff --git a/ansible_collections/middleware_automation/keycloak/playbooks/keycloak.yml b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak.yml new file mode 100644 index 00000000..6c7ca4a4 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak.yml @@ -0,0 +1,7 @@ +--- +- name: Playbook for Keycloak Hosts + hosts: all + vars: + keycloak_admin_password: "remembertochangeme" + roles: + - middleware_automation.keycloak.keycloak diff --git a/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_authentication_flow.yml b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_authentication_flow.yml new file mode 100644 index 00000000..38878b53 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_authentication_flow.yml @@ -0,0 +1,27 @@ +--- +- name: Playbook for Keycloak Authentication Flow Configuration + hosts: all + vars: + keycloak_admin_user: admin + keycloak_admin_password: "remembertochangeme" + keycloak_url: "http://localhost:8080" + keycloak_realm: TestRealm + tasks: + - name: Create authentication flow with executions + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: "{{ keycloak_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + alias: my-browser-flow + description: "Custom browser authentication flow" + provider_id: basic-flow + executions: + - provider_id: auth-cookie + requirement: ALTERNATIVE + - provider_id: auth-password + requirement: REQUIRED + - provider_id: auth-otp-form + requirement: ALTERNATIVE + state: present diff --git a/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_client_scope.yml b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_client_scope.yml new file mode 100644 index 00000000..aa5ed3d3 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_client_scope.yml @@ -0,0 +1,48 @@ +--- +- name: Playbook for Keycloak Client Scope Configuration + hosts: all + vars: + keycloak_admin_user: admin + keycloak_admin_password: "remembertochangeme" + keycloak_url: "http://localhost:8080" + keycloak_realm: TestRealm + tasks: + - name: Create client scope with protocol mappers + middleware_automation.keycloak.keycloak_client_scope: + auth_keycloak_url: "{{ keycloak_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + name: TestClientScope + description: "Client scope created via Ansible" + protocol: openid-connect + protocol_mappers: + - name: email + protocolMapper: oidc-usermodel-attribute-mapper + config: + user.attribute: email + claim.name: email + jsonType.label: String + id.token.claim: "true" + access.token.claim: "true" + userinfo.token.claim: "true" + - name: firstName + protocolMapper: oidc-usermodel-attribute-mapper + config: + user.attribute: firstName + claim.name: given_name + jsonType.label: String + id.token.claim: "true" + access.token.claim: "true" + userinfo.token.claim: "true" + - name: username + protocolMapper: oidc-usermodel-attribute-mapper + config: + user.attribute: username + claim.name: preferred_username + jsonType.label: String + id.token.claim: "true" + access.token.claim: "true" + userinfo.token.claim: "true" + state: present diff --git a/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_federation.yml b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_federation.yml new file mode 100644 index 00000000..89541514 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_federation.yml @@ -0,0 +1,68 @@ +--- +- name: Playbook for Keycloak Hosts + hosts: all + tasks: + - name: Keycloak Realm Role + ansible.builtin.include_role: + name: keycloak_realm + vars: + keycloak_realm_admin_password: "remembertochangeme" + keycloak_realm_realm: TestRealm + keycloak_realm_user_federation: + - realm: TestRealm + name: my-ldap + 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 + mappers: + - name: "full name" + providerId: "full-name-ldap-mapper" + providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" + config: + ldap.full.name.attribute: cn + read.only: true + write.only: false + keycloak_realm_clients: + - name: TestClient1 + client_id: TestClient1 + roles: + - TestClient1Admin + - TestClient1User + realm: "{{ keycloak_realm_realm }}" + public_client: true + web_origins: + - http://testclient1origin/application + - http://testclient1origin/other + users: + - username: TestUser + password: password + client_roles: + - client: TestClient1 + role: TestClient1User + realm: "{{ keycloak_realm_realm }}" diff --git a/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_quarkus.yml b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_quarkus.yml new file mode 100644 index 00000000..b8aedf21 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_quarkus.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook for Keycloak X Hosts with HTTPS enabled + hosts: all + vars: + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_hostname: http://localhost + keycloak_quarkus_port: 8443 + keycloak_quarkus_log: file + keycloak_quarkus_proxy_mode: none + roles: + - middleware_automation.keycloak.keycloak_quarkus diff --git a/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_quarkus_dev.yml b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_quarkus_dev.yml new file mode 100644 index 00000000..c8bb54e6 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_quarkus_dev.yml @@ -0,0 +1,12 @@ +--- +- name: Playbook for Keycloak X Hosts in develop mode + hosts: all + vars: + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_hostname: http://localhost + keycloak_quarkus_port: 8080 + keycloak_quarkus_log: file + keycloak_quarkus_start_dev: true + keycloak_quarkus_proxy_mode: none + roles: + - middleware_automation.keycloak.keycloak_quarkus diff --git a/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_realm.yml b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_realm.yml new file mode 100644 index 00000000..cbdb0d28 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_realm.yml @@ -0,0 +1,26 @@ +--- +- name: Playbook for Keycloak Hosts + hosts: all + vars: + keycloak_realm_admin_password: "remembertochangeme" + keycloak_realm_clients: + - name: TestClient1 + client_id: TestClient1 + roles: + - TestClient1Admin + - TestClient1User + realm: TestRealm + public_client: true + web_origins: + - http://testclient1origin/application + - http://testclient1origin/other + users: + - username: TestUser + password: password + client_roles: + - client: TestClient1 + role: TestClient1User + realm: TestRealm + roles: + - role: middleware_automation.keycloak.keycloak_realm + keycloak_realm_realm: TestRealm diff --git a/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_realm_client.yml b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_realm_client.yml new file mode 100644 index 00000000..84b3db1f --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/playbooks/keycloak_realm_client.yml @@ -0,0 +1,39 @@ +--- +- name: Playbook for Keycloak Realm and Client Configuration + hosts: all + tasks: + - name: Keycloak Realm Role + ansible.builtin.include_role: + name: middleware_automation.keycloak.keycloak_realm + vars: + keycloak_realm_admin_password: "remembertochangeme" + keycloak_realm_realm: TestRealm + keycloak_realm_client_default_roles: + - TestRoleAdmin + - TestRoleUser + keycloak_realm_client_users: + - username: TestUser + password: password + client_roles: + - client: TestClient1 + role: TestRoleUser + realm: TestRealm + - username: TestAdmin + password: password + client_roles: + - client: TestClient1 + role: TestRoleUser + realm: TestRealm + - client: TestClient1 + role: TestRoleAdmin + realm: TestRealm + keycloak_realm_clients: + - name: TestClient1 + client_id: TestClient1 + roles: "{{ keycloak_realm_client_default_roles }}" + realm: TestRealm + public_client: true + web_origins: + - http://testclient1origin/application + - http://testclient1origin/other + users: "{{ keycloak_realm_client_users }}" diff --git a/ansible_collections/middleware_automation/keycloak/playbooks/rhsso.yml b/ansible_collections/middleware_automation/keycloak/playbooks/rhsso.yml new file mode 100644 index 00000000..ea61f660 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/playbooks/rhsso.yml @@ -0,0 +1,8 @@ +--- +- name: Playbook for Red Hat SSO Hosts + hosts: sso + vars: + keycloak_admin_password: "remembertochangeme" + sso_enable: true + roles: + - middleware_automation.keycloak.keycloak diff --git a/ansible_collections/middleware_automation/keycloak/playbooks/roles b/ansible_collections/middleware_automation/keycloak/playbooks/roles new file mode 120000 index 00000000..d8c4472c --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/playbooks/roles @@ -0,0 +1 @@ +../roles \ No newline at end of file diff --git a/ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/actiongroup_keycloak.py b/ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/actiongroup_keycloak.py new file mode 100644 index 00000000..e6f8f582 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/actiongroup_keycloak.py @@ -0,0 +1,22 @@ +# -*- 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) +from __future__ import annotations +__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/ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/attributes.py b/ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/attributes.py new file mode 100644 index 00000000..86e43acf --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/attributes.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) Ansible Project +# 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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard documentation fragment + DOCUMENTATION = r''' +options: {} +attributes: + check_mode: + description: Can run in C(check_mode) and return changed status prediction without modifying target. + diff_mode: + description: Will return details on what has changed (or possibly needs changing in C(check_mode)), when in diff mode. +''' + + PLATFORM = r''' +options: {} +attributes: + platform: + description: Target OS/families that can be operated against. + support: N/A +''' + + # Should be used together with the standard fragment + INFO_MODULE = r''' +options: {} +attributes: + check_mode: + support: full + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. +''' + + CONN = r''' +options: {} +attributes: + become: + description: Is usable alongside C(become) keywords. + connection: + description: Uses the target's configured connection information to execute code on it. + delegation: + description: Can be used in conjunction with C(delegate_to) and related keywords. +''' + + FACTS = r''' +options: {} +attributes: + facts: + description: Action returns an C(ansible_facts) dictionary that will update existing host facts. +''' + + # Should be used together with the standard fragment and the FACTS fragment + FACTS_MODULE = r''' +options: {} +attributes: + check_mode: + support: full + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. + facts: + support: full +''' + + FILES = r''' +options: {} +attributes: + safe_file_operations: + description: Uses Ansible's strict file operation functions to ensure proper permissions and avoid data corruption. +''' + + FLOW = r''' +options: {} +attributes: + action: + description: Indicates this has a corresponding action plugin so some parts of the options can be executed on the controller. + async: + description: Supports being used with the C(async) keyword. +''' diff --git a/ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/keycloak.py b/ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/keycloak.py new file mode 100644 index 00000000..e88effb5 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/doc_fragments/keycloak.py @@ -0,0 +1,81 @@ +# -*- 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) +from __future__ import annotations +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard documentation fragment + DOCUMENTATION = r''' +options: + auth_keycloak_url: + description: + - URL to the Keycloak instance. + type: str + required: true + aliases: + - url + + auth_client_id: + description: + - OpenID Connect I(client_id) to authenticate to the API with. + type: str + default: admin-cli + + auth_realm: + description: + - Keycloak realm name to authenticate to for API access. + type: str + + auth_client_secret: + description: + - Client Secret to use in conjunction with I(auth_client_id) (if required). + type: str + + auth_username: + description: + - Username to authenticate for API access with. + type: str + aliases: + - username + + auth_password: + description: + - Password to authenticate for API access with. + type: str + aliases: + - password + + token: + description: + - Authentication token for Keycloak API. + type: str + + refresh_token: + description: + - Authentication refresh token for Keycloak API. + type: str + + validate_certs: + description: + - Verify TLS certificates (do not disable this in production). + type: bool + default: true + + connection_timeout: + description: + - Controls the HTTP connections timeout period (in seconds) to Keycloak API. + type: int + default: 10 + http_agent: + description: + - Configures the HTTP User-Agent header. + type: str + default: Ansible +''' diff --git a/ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/_keycloak_utils.py b/ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/_keycloak_utils.py new file mode 100644 index 00000000..ba443b49 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/_keycloak_utils.py @@ -0,0 +1,34 @@ +# 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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + +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/ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/keycloak.py b/ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/keycloak.py new file mode 100644 index 00000000..23a42b99 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/keycloak.py @@ -0,0 +1,3667 @@ +# 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) +from __future__ import annotations +__metaclass__ = type + +import copy +import json +import traceback +import typing as t +from http import HTTPStatus +from urllib.error import HTTPError +from urllib.parse import quote, urlencode + +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" + +URL_CLIENT_ROLES = "{url}/admin/realms/{realm}/clients/{id}/roles" +URL_CLIENT_ROLE = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}" +URL_CLIENT_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}/composites" + +URL_CLIENT_ROLE_SCOPE_CLIENTS = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/clients/{scopeid}" +URL_CLIENT_ROLE_SCOPE_REALM = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/realm" + +URL_REALM_ROLES = "{url}/admin/realms/{realm}/roles" +URL_REALM_ROLE = "{url}/admin/realms/{realm}/roles/{name}" +URL_REALM_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm" +URL_REALM_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm/available" +URL_REALM_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm/composite" +URL_REALM_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/roles/{name}/composites" + +URL_ROLES_BY_ID = "{url}/admin/realms/{realm}/roles-by-id/{id}" +URL_ROLES_BY_ID_COMPOSITES_CLIENTS = "{url}/admin/realms/{realm}/roles-by-id/{id}/composites/clients/{cid}" +URL_ROLES_BY_ID_COMPOSITES = "{url}/admin/realms/{realm}/roles-by-id/{id}/composites" + +URL_CLIENTTEMPLATE = "{url}/admin/realms/{realm}/client-templates/{id}" +URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates" +URL_GROUPS = "{url}/admin/realms/{realm}/groups" +URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}" +URL_GROUP_CHILDREN = "{url}/admin/realms/{realm}/groups/{groupid}/children" + +URL_CLIENT_SCOPES = "{url}/admin/realms/{realm}/client-scopes" +URL_CLIENT_SCOPE = "{url}/admin/realms/{realm}/client-scopes/{id}" +URL_CLIENT_SCOPE_SCOPE_MAPPINGS = "{url}/admin/realms/{realm}/client-scopes/{id}/scope-mappings" +URL_CLIENT_SCOPE_SCOPE_MAPPINGS_REALM = "{url}/admin/realms/{realm}/client-scopes/{id}/scope-mappings/realm" +URL_CLIENT_SCOPE_SCOPE_MAPPINGS_CLIENT = "{url}/admin/realms/{realm}/client-scopes/{id}/scope-mappings/clients/{client}" +URL_CLIENT_SCOPE_PROTOCOLMAPPERS = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models" +URL_CLIENT_SCOPE_PROTOCOLMAPPER = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models/{mapper_id}" + +URL_DEFAULT_CLIENT_SCOPES = "{url}/admin/realms/{realm}/default-default-client-scopes" +URL_DEFAULT_CLIENT_SCOPE = "{url}/admin/realms/{realm}/default-default-client-scopes/{id}" +URL_OPTIONAL_CLIENT_SCOPES = "{url}/admin/realms/{realm}/default-optional-client-scopes" +URL_OPTIONAL_CLIENT_SCOPE = "{url}/admin/realms/{realm}/default-optional-client-scopes/{id}" + +URL_CLIENT_DEFAULT_CLIENT_SCOPES = "{url}/admin/realms/{realm}/clients/{cid}/default-client-scopes" +URL_CLIENT_DEFAULT_CLIENT_SCOPE = "{url}/admin/realms/{realm}/clients/{cid}/default-client-scopes/{id}" +URL_CLIENT_OPTIONAL_CLIENT_SCOPES = "{url}/admin/realms/{realm}/clients/{cid}/optional-client-scopes" +URL_CLIENT_OPTIONAL_CLIENT_SCOPE = "{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_USERS = "{url}/admin/realms/{realm}/users" +URL_USER = "{url}/admin/realms/{realm}/users/{id}" +URL_USER_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings" +URL_USER_REALM_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm" +URL_USER_CLIENTS_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients" +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_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_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" +URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY = "{url}/admin/realms/{realm}/authentication/executions/{id}/lower-priority" +URL_AUTHENTICATION_CONFIG = "{url}/admin/realms/{realm}/authentication/config/{id}" +URL_AUTHENTICATION_REGISTER_REQUIRED_ACTION = "{url}/admin/realms/{realm}/authentication/register-required-action" +URL_AUTHENTICATION_REQUIRED_ACTIONS = "{url}/admin/realms/{realm}/authentication/required-actions" +URL_AUTHENTICATION_REQUIRED_ACTIONS_ALIAS = "{url}/admin/realms/{realm}/authentication/required-actions/{alias}" + +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}" + +URL_AUTHZ_AUTHORIZATION_SCOPE = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/scope/{id}" +URL_AUTHZ_AUTHORIZATION_SCOPES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/scope" + +# This URL is used for: +# - Querying client authorization permissions +# - Removing client authorization permissions +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_RESOURCES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/resource" + +URL_AUTHZ_CUSTOM_POLICY = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/policy/{policy_type}" +URL_AUTHZ_CUSTOM_POLICIES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/policy" + +URL_ORGANIZATIONS = "{url}/admin/realms/{realm}/organizations" +URL_ORGANIZATION = "{url}/admin/realms/{realm}/organizations/{id}" + + +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), + refresh_token=dict(type="str", no_log=True), + http_agent=dict(type="str", default="Ansible"), + ) + + +def camel(words: str) -> str: + return words.split("_")[0] + "".join(x.capitalize() or "_" for x in words.split("_")[1:]) + + +class KeycloakError(Exception): + 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 _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 + """ + base_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") + + 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: + 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) + + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +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: + description: + reference structure + :param struct2: + description: + structure to compare with first parameter. + :param exclude: + description: + Key to exclude from the comparison. + :param empty_list_result: + description: + Return this value, when struct1 is an empty list. + :return: + 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, empty_list_result): + break + else: + return False + else: + if item1 not in struct2: + return False + return True + elif isinstance(struct1, dict) and isinstance(struct2, dict): + if not struct1 and not struct2: + return True + try: + for key in struct1: + if not (exclude and key in exclude): + if not is_struct_included(struct1[key], struct2[key], exclude, empty_list_result): + return False + except KeyError: + return False + return True + elif isinstance(struct1, bool) and isinstance(struct2, bool): + return struct1 == struct2 + else: + return to_text(struct1, "utf-8") == to_text(struct2, "utf-8") + + +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: AnsibleModule, connection_header: dict[str, str]) -> None: + 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 _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 + """ + realm_info_url = URL_REALM_INFO.format(url=self.baseurl, realm=realm) + + try: + return self._request_and_deserialize(realm_info_url, method="GET") + + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + 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=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=f"Could not obtain realm {realm}: {e}", exception=traceback.format_exc()) + + 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 + + :return: None, or a 'KeysMetadataRepresentation' + (https://www.keycloak.org/docs-api/latest/rest-api/index.html#KeysMetadataRepresentation) + -- a dict containing the keys 'active' and 'keys', the former containing a mapping + from algorithms to key-ids, the latter containing a list of dicts with key + information. + """ + realm_keys_metadata_url = URL_REALM_KEYS_METADATA.format(url=self.baseurl, realm=realm) + + try: + return self._request_and_deserialize(realm_keys_metadata_url, method="GET") + + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + 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=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=f"Could not obtain realm {realm}: {e}", exception=traceback.format_exc()) + + # 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 + """ + realm_url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return self._request_and_deserialize(realm_url, method="GET") + + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + 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=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=f"Could not obtain realm {realm}: {e}", exception=traceback.format_exc()) + + 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 + """ + realm_url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return self._request(realm_url, method="PUT", data=json.dumps(realmrep)) + except Exception as e: + 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 + :param realmrep: Realm representation of realm to be created. + :return: HTTPResponse object on success + """ + realm_url = URL_REALMS.format(url=self.baseurl) + + try: + return self._request(realm_url, method="POST", data=json.dumps(realmrep)) + except Exception as e: + self.fail_request(e, msg=f"Could not create realm {realmrep['id']}: {e}", exception=traceback.format_exc()) + + def delete_realm(self, realm: str = "master"): + """Delete a realm from Keycloak + + :param realm: realm to be deleted + :return: HTTPResponse object on success + """ + realm_url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return self._request(realm_url, method="DELETE") + except Exception as e: + self.fail_request(e, msg=f"Could not delete realm {realm}: {e}", exception=traceback.format_exc()) + + 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 + :return: list of dicts of client representations + """ + clientlist_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) + if filter is not None: + clientlist_url += f"?clientId={filter}" + + try: + return self._request_and_deserialize(clientlist_url, method="GET") + except ValueError as 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_request(e, msg=f"Could not obtain list of clients for realm {realm}: {e}") + + def get_client_by_client_id(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 + """ + r = self.get_clients(realm=realm, filter=client_id) + if len(r) > 0: + return r[0] + else: + return None + + 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 + :return: dict of client representation or None if none matching exist + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + return self._request_and_deserialize(client_url, method="GET") + + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + self.fail_request(e, msg=f"Could not obtain client {id} for realm {realm}: {e}") + except ValueError as 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=f"Could not obtain client {id} for realm {realm}: {e}") + + 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_client_id(client_id, realm) + if isinstance(result, dict) and "id" in result: + return result["id"] + else: + return None + + 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 + :return: HTTPResponse object on success + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + return self._request(client_url, method="PUT", data=json.dumps(clientrep)) + except Exception as e: + self.fail_request(e, msg=f"Could not update client {id} in realm {realm}: {e}") + + 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 + """ + client_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) + + try: + return self._request(client_url, method="POST", data=json.dumps(clientrep)) + except Exception as e: + self.fail_request(e, msg=f"Could not create client {clientrep['clientId']} in realm {realm}: {e}") + + 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 + :return: HTTPResponse object on success + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + return self._request(client_url, method="DELETE") + except Exception as 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: 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. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + client_roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) + try: + return self._request_and_deserialize(client_roles_url, method="GET") + except Exception as 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: 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. + :param realm: Realm from which to obtain the rolemappings. + :return: The ID of the role, None if not found. + """ + rolemappings = self.get_client_roles_by_id(cid, realm=realm) + for role in rolemappings: + if name == role["name"]: + return role["id"] + return None + + 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. + :param rid: ID of the role. + :param realm: client from this realm + :return: dict of rolemapping representation or None if none matching exist + """ + rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + rolemappings = self._request_and_deserialize(rolemappings_url, method="GET") + for role in rolemappings: + if rid == role["id"]: + return role + except Exception as 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: 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 + ) + try: + return self._request_and_deserialize(available_rolemappings_url, method="GET") + except Exception as 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: 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 + ) + try: + return self._request_and_deserialize(composite_rolemappings_url, method="GET") + except Exception as 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: 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. + :return: The role. + """ + client_roles_url = URL_ROLES_BY_ID.format(url=self.baseurl, realm=realm, id=rid) + try: + return self._request_and_deserialize(client_roles_url, method="GET") + except Exception as 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: 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. + :param realm: Realm from which to obtain the rolemappings. + :return: The role. + """ + client_roles_url = URL_ROLES_BY_ID_COMPOSITES_CLIENTS.format(url=self.baseurl, realm=realm, id=rid, cid=cid) + try: + return self._request_and_deserialize(client_roles_url, method="GET") + except Exception as 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: str = "master"): + """Assign roles to composite role + + :param rid: ID of the composite role. + :param roles_rep: Representation of the roles to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + available_rolemappings_url = URL_ROLES_BY_ID_COMPOSITES.format(url=self.baseurl, realm=realm, id=rid) + try: + self._request(available_rolemappings_url, method="POST", data=json.dumps(roles_rep)) + except Exception as 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: 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. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + url = URL_REALM_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, group=gid) + try: + self._request(url, method="POST", data=json.dumps(role_rep)) + except Exception as 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: 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. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + url = URL_REALM_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, group=gid) + try: + self._request(url, method="DELETE", data=json.dumps(role_rep)) + except Exception as 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: 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 role_rep: Representation of the role to assign. + :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 + ) + try: + self._request(available_rolemappings_url, method="POST", data=json.dumps(role_rep)) + except Exception as 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: 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. + :param role_rep: Representation of the role to assign. + :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 + ) + try: + self._request(available_rolemappings_url, method="DELETE", data=json.dumps(role_rep)) + except Exception as 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: 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. + :param rid: ID of the role. + :param realm: client from this realm + :return: dict of rolemapping representation or None if none matching exist + """ + rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid, client=cid) + try: + rolemappings = self._request_and_deserialize(rolemappings_url, method="GET") + for role in rolemappings: + if rid == role["id"]: + return role + except Exception as 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: 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 + ) + try: + return self._request_and_deserialize(available_rolemappings_url, method="GET") + except Exception as 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: 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 + ) + try: + return self._request_and_deserialize(composite_rolemappings_url, method="GET") + except Exception as 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: 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. + :param realm: client from this realm + :return: dict of rolemapping representation or None if none matching exist + """ + rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) + try: + rolemappings = self._request_and_deserialize(rolemappings_url, method="GET") + for role in rolemappings: + if rid == role["id"]: + return role + except Exception as 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: 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. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + available_rolemappings_url = URL_REALM_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=uid) + try: + return self._request_and_deserialize(available_rolemappings_url, method="GET") + except Exception as 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: 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. + :return: The effective rollemappings of specified client and user of the realm (default "master"). + """ + composite_rolemappings_url = URL_REALM_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=uid) + try: + return self._request_and_deserialize(composite_rolemappings_url, method="GET") + except Exception as 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: 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 += f"?username={quote(username, safe='')}&exact=true" + try: + userrep = None + users = self._request_and_deserialize(users_url, method="GET") + for user in users: + if user["username"] == username: + userrep = user + break + return userrep + + except ValueError as 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_request(e, msg=f"Could not obtain the user for realm {realm} and username {username}: {e}") + + def get_realm_users(self, realm: str = "master") -> list[dict[str, t.Any]]: + """Obtain list of users from the realm + + :param realm: realm id + :return: list of user representations + """ + users_url = URL_USERS.format(url=self.baseurl, realm=realm) + try: + return self._request_and_deserialize(users_url, method="GET") + except ValueError as e: + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain the users for realm {realm}: {e}" + ) + except Exception as e: + self.fail_request(e, msg=f"Could not obtain the users for realm {realm}: {e}") + + 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. + :param realm: Realm in which the user resides; default 'master' + """ + cid = self.get_client_id(client_id, realm=realm) + + service_account_user_url = URL_CLIENT_SERVICE_ACCOUNT_USER.format(url=self.baseurl, realm=realm, id=cid) + try: + return self._request_and_deserialize(service_account_user_url, method="GET") + except ValueError as 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_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: 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 + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + if cid is None: + user_realm_rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) + try: + self._request(user_realm_rolemappings_url, method="POST", data=json.dumps(role_rep)) + except Exception as 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 + ) + try: + self._request(user_client_rolemappings_url, method="POST", data=json.dumps(role_rep)) + except Exception as 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: 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. + :param role_rep: Representation of the role to remove from rolemappings. + :param realm: Realm from which to remove the rolemappings. + :return: None. + """ + if cid is None: + user_realm_rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) + try: + self._request(user_realm_rolemappings_url, method="DELETE", data=json.dumps(role_rep)) + except Exception as 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 + ) + try: + self._request(user_client_rolemappings_url, method="DELETE", data=json.dumps(role_rep)) + except Exception as 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: 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 + """ + url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) + + try: + return self._request_and_deserialize(url, method="GET") + except ValueError as 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_request(e, msg=f"Could not obtain list of client templates for realm {realm}: {e}") + + 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 + :return: dict of client template representation or None if none matching exist + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, id=id, realm=realm) + + try: + return self._request_and_deserialize(url, method="GET") + except ValueError as 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_request(e, msg=f"Could not obtain client template {id} for realm {realm}: {e}") + + 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 + :return: dict of client template representation or None if none matching exist + """ + result = self.get_client_templates(realm) + if isinstance(result, list): + 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: 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"] + else: + return None + + 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 + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) + + try: + return self._request(url, method="PUT", data=json.dumps(clienttrep)) + except Exception as e: + self.fail_request(e, msg=f"Could not update client template {id} in realm {realm}: {e}") + + 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 + """ + url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) + + try: + return self._request(url, method="POST", data=json.dumps(clienttrep)) + except Exception as 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: 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 + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) + + try: + return self._request(url, method="DELETE") + except Exception as e: + self.fail_request(e, msg=f"Could not delete client template {id} in realm {realm}: {e}") + + def get_client_scopes(self, realm: str = "master"): + """Fetch the name and ID of all client scopes on the Keycloak server. + + To fetch the full data of the group, make a subsequent call to + get_client_scope_by_client_scope_id, passing in the ID of the group you wish to return. + + :param realm: Realm in which the client scope resides; default 'master'. + :return The client scopes of this realm (default "master") + """ + client_scopes_url = URL_CLIENT_SCOPES.format(url=self.baseurl, realm=realm) + try: + return self._request_and_deserialize(client_scopes_url, method="GET") + except Exception as e: + self.fail_request(e, msg=f"Could not fetch list of client scopes in realm {realm}: {e}") + + def get_client_scope_by_client_scope_id(self, cid, realm: str = "master"): + """Fetch a keycloak client scope from the provided realm using the client scope's unique ID. + + If the client scope does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + :param cid: UUID of the client scope to be returned + :param realm: Realm in which the client scope resides; default 'master'. + """ + client_scope_url = URL_CLIENT_SCOPE.format(url=self.baseurl, realm=realm, id=cid) + try: + return self._request_and_deserialize(client_scope_url, method="GET") + + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + self.fail_request(e, msg=f"Could not fetch client scope {cid} in realm {realm}: {e}") + except Exception as e: + self.module.fail_json(msg=f"Could not client scope group {cid} in realm {realm}: {e}") + + def get_client_scope_by_name(self, name, realm: str = "master"): + """Fetch a keycloak client scope within a realm based on its name. + + The Keycloak API does not allow filtering of the client scopes resource by name. + As a result, this method first retrieves the entire list of client scopes - name and ID - + then performs a second query to fetch the group. + + If the client scope does not exist, None is returned. + :param name: Name of the client scope to fetch. + :param realm: Realm in which the client scope resides; default 'master' + """ + try: + all_client_scopes = self.get_client_scopes(realm=realm) + + for client_scope in all_client_scopes: + if client_scope["name"] == name: + return self.get_client_scope_by_client_scope_id(client_scope["id"], realm=realm) + + return None + + except Exception as e: + self.module.fail_json(msg=f"Could not fetch client scope {name} in realm {realm}: {e}") + + def create_client_scope(self, client_scope_rep, realm: str = "master"): + """Create a Keycloak client scope. + + :param client_scope_rep: a ClientScopeRepresentation of the client scope to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + client_scopes_url = URL_CLIENT_SCOPES.format(url=self.baseurl, realm=realm) + try: + return self._request(client_scopes_url, method="POST", data=json.dumps(client_scope_rep)) + except Exception as e: + self.fail_request(e, msg=f"Could not create client scope {client_scope_rep['name']} in realm {realm}: {e}") + + def update_client_scope(self, client_scope_rep, realm: str = "master"): + """Update an existing client scope. + + :param grouprep: A GroupRepresentation of the updated group. + :return HTTPResponse object on success + """ + client_scope_url = URL_CLIENT_SCOPE.format(url=self.baseurl, realm=realm, id=client_scope_rep["id"]) + + try: + return self._request(client_scope_url, method="PUT", data=json.dumps(client_scope_rep)) + + except Exception as e: + self.fail_request(e, msg=f"Could not update client scope {client_scope_rep['name']} in realm {realm}: {e}") + + def delete_client_scope(self, name=None, cid=None, realm: str = "master"): + """Delete a client scope. One of name or cid must be provided. + + Providing the client scope ID is preferred as it avoids a second lookup to + convert a client scope name to an ID. + + :param name: The name of the client scope. A lookup will be performed to retrieve the client scope ID. + :param cid: The ID of the client scope (preferred to name). + :param realm: The realm in which this group resides, default "master". + """ + + if cid is None and name is None: + # 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 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 client_scope in self.get_client_scopes(realm=realm): + if client_scope["name"] == name: + cid = client_scope["id"] + break + + # if the group doesn't exist - no problem, nothing to delete. + if cid is None: + return None + + # should have a good cid by here. + client_scope_url = URL_CLIENT_SCOPE.format(realm=realm, id=cid, url=self.baseurl) + try: + return self._request(client_scope_url, method="DELETE") + + except Exception as e: + self.fail_request(e, msg=f"Unable to delete client scope {cid}: {e}") + + def get_client_scope_protocolmappers(self, cid, realm: str = "master"): + """Fetch the name and ID of all client scopes on the Keycloak server. + + To fetch the full data of the group, make a subsequent call to + get_client_scope_by_client_scope_id, passing in the ID of the group you wish to return. + + :param cid: id of client scope (not name). + :param realm: Realm in which the client scope resides; default 'master'. + :return The protocolmappers of this realm (default "master") + """ + protocolmappers_url = URL_CLIENT_SCOPE_PROTOCOLMAPPERS.format(id=cid, url=self.baseurl, realm=realm) + try: + return self._request_and_deserialize(protocolmappers_url, method="GET") + except Exception as e: + self.fail_request(e, msg=f"Could not fetch list of protocolmappers in realm {realm}: {e}") + + def get_client_scope_protocolmapper_by_protocolmapperid(self, pid, cid, realm: str = "master"): + """Fetch a keycloak client scope from the provided realm using the client scope's unique ID. + + If the client scope does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + + :param cid: UUID of the protocolmapper to be returned + :param cid: UUID of the client scope to be returned + :param realm: Realm in which the client scope resides; default 'master'. + """ + protocolmapper_url = URL_CLIENT_SCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=pid) + try: + return self._request_and_deserialize(protocolmapper_url, method="GET") + + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + self.fail_request(e, msg=f"Could not fetch protocolmapper {pid} in realm {realm}: {e}") + except Exception as e: + self.module.fail_json(msg=f"Could not fetch protocolmapper {cid} in realm {realm}: {e}") + + def get_client_scope_protocolmapper_by_name(self, cid, name, realm: str = "master"): + """Fetch a keycloak client scope within a realm based on its name. + + The Keycloak API does not allow filtering of the client scopes resource by name. + As a result, this method first retrieves the entire list of client scopes - name and ID - + then performs a second query to fetch the group. + + If the client scope does not exist, None is returned. + :param cid: Id of the client scope (not name). + :param name: Name of the protocolmapper to fetch. + :param realm: Realm in which the client scope resides; default 'master' + """ + try: + all_protocolmappers = self.get_client_scope_protocolmappers(cid, realm=realm) + + for protocolmapper in all_protocolmappers: + if protocolmapper["name"] == name: + return self.get_client_scope_protocolmapper_by_protocolmapperid( + protocolmapper["id"], cid, realm=realm + ) + + return None + + except Exception as e: + self.module.fail_json(msg=f"Could not fetch protocolmapper {name} in realm {realm}: {e}") + + def create_client_scope_protocolmapper(self, cid, mapper_rep, realm: str = "master"): + """Create a Keycloak client scope protocolmapper. + + :param cid: Id of the client scope. + :param mapper_rep: a ProtocolMapperRepresentation of the protocolmapper to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + protocolmappers_url = URL_CLIENT_SCOPE_PROTOCOLMAPPERS.format(url=self.baseurl, id=cid, realm=realm) + try: + return self._request(protocolmappers_url, method="POST", data=json.dumps(mapper_rep)) + except Exception as e: + self.fail_request(e, msg=f"Could not create protocolmapper {mapper_rep['name']} in realm {realm}: {e}") + + def update_client_scope_protocolmappers(self, cid, mapper_rep, realm: str = "master"): + """Update an existing client scope. + + :param cid: Id of the client scope. + :param mapper_rep: A ProtocolMapperRepresentation of the updated protocolmapper. + :return HTTPResponse object on success + """ + protocolmapper_url = URL_CLIENT_SCOPE_PROTOCOLMAPPER.format( + url=self.baseurl, realm=realm, id=cid, mapper_id=mapper_rep["id"] + ) + + try: + return self._request(protocolmapper_url, method="PUT", data=json.dumps(mapper_rep)) + + except Exception as e: + self.fail_request( + e, msg=f"Could not update protocolmappers for client scope {mapper_rep} in realm {realm}: {e}" + ) + + def get_default_client_scopes(self, realm, client_id=None): + """Fetch the name and ID of all client scopes on the Keycloak server. + + To fetch the full data of the client scope, make a subsequent call to + get_client_scope_by_client_scope_id, passing in the ID of the client scope you wish to return. + + :param realm: Realm in which the client scope resides. + :param client_id: The client in which the client scope resides. + :return The default client scopes of this realm or client + """ + url = URL_DEFAULT_CLIENT_SCOPES if client_id is None else URL_CLIENT_DEFAULT_CLIENT_SCOPES + return self._get_client_scopes_of_type(realm, url, "default", client_id) + + def get_optional_client_scopes(self, realm, client_id=None): + """Fetch the name and ID of all client scopes on the Keycloak server. + + To fetch the full data of the client scope, make a subsequent call to + get_client_scope_by_client_scope_id, passing in the ID of the client scope you wish to return. + + :param realm: Realm in which the client scope resides. + :param client_id: The client in which the client scope resides. + :return The optional client scopes of this realm or client + """ + url = URL_OPTIONAL_CLIENT_SCOPES if client_id is None else URL_CLIENT_OPTIONAL_CLIENT_SCOPES + return self._get_client_scopes_of_type(realm, url, "optional", client_id) + + def _get_client_scopes_of_type(self, realm, url_template, scope_type, client_id=None): + """Fetch the name and ID of all client scopes on the Keycloak server. + + To fetch the full data of the client scope, make a subsequent call to + get_client_scope_by_client_scope_id, passing in the ID of the client scope you wish to return. + + :param realm: Realm in which the client scope resides. + :param url_template the template for the right type + :param scope_type this can be either optional or default + :param client_id: The client in which the client scope resides. + :return The client scopes of the specified type of this realm + """ + if client_id is None: + client_scopes_url = url_template.format(url=self.baseurl, realm=realm) + try: + return self._request_and_deserialize(client_scopes_url, method="GET") + except Exception as e: + self.fail_request(e, msg=f"Could not fetch list of {scope_type} client scopes in realm {realm}: {e}") + else: + cid = self.get_client_id(client_id=client_id, realm=realm) + client_scopes_url = url_template.format(url=self.baseurl, realm=realm, cid=cid) + try: + return self._request_and_deserialize(client_scopes_url, method="GET") + except Exception as e: + self.fail_request( + e, + msg=f"Could not fetch list of {scope_type} client scopes in client {client_id}: {client_scopes_url}", + ) + + def _decide_url_type_client_scope(self, client_id=None, scope_type="default"): + """Decides which url to use. + :param scope_type this can be either optional or default + :param client_id: The client in which the client scope resides. + """ + if client_id is None: + if scope_type == "default": + return URL_DEFAULT_CLIENT_SCOPE + if scope_type == "optional": + return URL_OPTIONAL_CLIENT_SCOPE + else: + if scope_type == "default": + return URL_CLIENT_DEFAULT_CLIENT_SCOPE + if scope_type == "optional": + return URL_CLIENT_OPTIONAL_CLIENT_SCOPE + + def add_default_client_scope(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 client scope resides. + :param client_id: The client in which the client scope resides. + """ + self._action_type_client_scope(id, client_id, "default", realm, "add") + + def add_optional_client_scope(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 client scope resides. + :param client_id: The client in which the client scope resides. + """ + self._action_type_client_scope(id, client_id, "optional", realm, "add") + + def delete_default_client_scope(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 client scope resides. + :param client_id: The client in which the client scope resides. + """ + self._action_type_client_scope(id, client_id, "default", realm, "delete") + + def delete_optional_client_scope(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 client scope resides. + :param client_id: The client in which the client scope resides. + """ + self._action_type_client_scope(id, client_id, "optional", realm, "delete") + + def _action_type_client_scope( + self, id=None, client_id=None, scope_type="default", realm: str = "master", action="add" + ): + """Delete or add a client scope of type. + :param name: The name of the client scope. A lookup will be performed to retrieve the client scope ID. + :param client_id: The ID of the client scope (preferred to name). + :param scope_type 'default' or 'optional' + :param realm: The realm in which this group resides, default "master". + """ + 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. + client_scope_type_url = self._decide_url_type_client_scope(client_id, scope_type).format( + realm=realm, id=id, cid=cid, url=self.baseurl + ) + try: + method = "PUT" if action == "add" else "DELETE" + return self._request(client_scope_type_url, method=method) + + except Exception as e: + place = "realm" if client_id is None else f"client {client_id}" + self.fail_request(e, msg=f"Unable to {action} {scope_type} client scope {id} @ {place} : {e}") + + 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 + :return: dict of credential representation + """ + clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id) + + try: + return self._request_and_deserialize(clientsecret_url, method="POST") + + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + 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=f"Could not obtain clientsecret of client {id} for realm {realm}: {e}") + + 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 + :return: dict of credential representation + """ + clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id) + + try: + return self._request_and_deserialize(clientsecret_url, method="GET") + + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + 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=f"Could not obtain clientsecret of client {id} for realm {realm}: {e}") + + 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. + + :param realm: Return the groups of this realm (default "master"). + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + return self._request_and_deserialize(groups_url, method="GET") + except Exception as 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: 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. + + gid is a UUID provided by the Keycloak API + :param gid: UUID of the group to be returned + :param realm: Realm in which the group resides; default 'master'. + """ + groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) + try: + return self._request_and_deserialize(groups_url, method="GET") + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + self.fail_request(e, msg=f"Could not fetch group {gid} in realm {realm}: {e}") + except Exception as e: + self.module.fail_json(msg=f"Could not fetch group {gid} in realm {realm}: {e}") + + 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 + + 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 + """ + try: + if parents: + parent = self.get_subgroup_direct_parent(parents, realm) + + if not parent: + return None + + # 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: + # 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="") + ) + + 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=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. + + :param parent: parent describing dict + """ + if parent["id"]: + return (parent["id"], True) + + return (parent["name"], False) + + 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. + + If the group cannot be found, None is returned. + :param name_chain: Topdown ordered list of subgroup parent (ids or names) + its own name at the end + :param realm: Realm in which the group resides; default 'master' + """ + cp = name_chain[0] + + # for 1st parent in chain we must query the server + cp, is_id = self._get_normed_group_parent(cp) + + if is_id: + tmp = self.get_group_by_groupid(cp, realm=realm) + else: + # given as name, assume toplvl group + tmp = self.get_group_by_name(cp, realm=realm) + + if not tmp: + return None + + for p in name_chain[1:]: + for sg in self.get_subgroups(tmp, realm): + pv, is_id = self._get_normed_group_parent(p) + + if is_id: + cmpkey = "id" + else: + cmpkey = "name" + + if pv == sg[cmpkey]: + tmp = sg + break + + if not tmp: + return None + + return tmp + + 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 + the ID for the direct predecessor of current subgroup. This + method will guarantee us this information getting there with + as minimal work as possible. + + Note that given parent list can and might be incomplete at the + upper levels as long as it starts with an ID instead of a name + + If the group does not exist, None is returned. + :param parents: Topdown ordered list of subgroup parents + :param realm: Realm in which the group resides; default 'master' + """ + if children_to_resolve is None: + # start recursion by reversing parents (in optimal cases + # we dont need to walk the whole tree upwarts) + parents = list(reversed(parents)) + children_to_resolve = [] + + if not parents: + # walk complete parents list to the top, all names, no id's, + # try to resolve it assuming list is complete and 1st + # element is a toplvl group + return self.get_subgroup_by_chain(list(reversed(children_to_resolve)), realm=realm) + + cp = parents[0] + unused, is_id = self._get_normed_group_parent(cp) + + if is_id: + # current parent is given as ID, we can stop walking + # upwards searching for an entry point + return self.get_subgroup_by_chain([cp] + list(reversed(children_to_resolve)), realm=realm) + else: + # 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) + + 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 self._request(groups_url, method="POST", data=json.dumps(grouprep)) + except Exception as e: + self.fail_request(e, msg=f"Could not create group {grouprep['name']} in realm {realm}: {e}") + + 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. + :return: HTTPResponse object on success + """ + parent_id = "---UNDETERMINED---" + try: + parent_id = self.get_subgroup_direct_parent(parents, realm) + + if not parent_id: + raise Exception( + "Could not determine subgroup parent ID for given" + 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" + ) + + parent_id = parent_id["id"] + url = URL_GROUP_CHILDREN.format(url=self.baseurl, realm=realm, groupid=parent_id) + return self._request(url, method="POST", data=json.dumps(grouprep)) + except Exception as 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: 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"]) + + try: + return self._request(group_url, method="PUT", data=json.dumps(grouprep)) + except Exception as 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: 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. + + :param name: The name of the group. A lookup will be performed to retrieve the group ID. + :param groupid: The ID of the group (preferred to name). + :param realm: The realm in which this group resides, default "master". + """ + + if groupid is None and name is None: + # 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 groupid isn't provided. + # 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"] + break + + # if the group doesn't exist - no problem, nothing to delete. + if groupid is None: + return None + + # should have a good groupid by here. + group_url = URL_GROUP.format(realm=realm, groupid=groupid, url=self.baseurl) + try: + return self._request(group_url, method="DELETE") + except Exception as e: + self.fail_request(e, msg=f"Unable to delete group {groupid}: {e}") + + 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 self._request_and_deserialize(rolelist_url, method="GET") + except ValueError as 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_request(e, msg=f"Could not obtain list of roles for realm {realm}: {e}") + + 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="")) + try: + return self._request_and_deserialize(role_url, method="GET") + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + self.fail_request(e, msg=f"Could not fetch role {name} in realm {realm}: {e}") + except Exception as e: + self.module.fail_json(msg=f"Could not fetch role {name} in realm {realm}: {e}") + + 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 + """ + roles_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) + try: + if "composites" in rolerep: + keycloak_compatible_composites = self.convert_role_composites(rolerep["composites"]) + rolerep["composites"] = keycloak_compatible_composites + return self._request(roles_url, method="POST", data=json.dumps(rolerep)) + except Exception as e: + self.fail_request(e, msg=f"Could not create role {rolerep['name']} in realm {realm}: {e}") + + 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="") + try: + composites = None + if "composites" in rolerep: + composites = copy.deepcopy(rolerep["composites"]) + del rolerep["composites"] + 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_request(e, msg=f"Could not update role {rolerep['name']} in realm {realm}: {e}") + + def get_role_composites(self, rolerep, clientid=None, realm: str = "master"): + composite_url = "" + try: + if clientid is not None: + client = self.get_client_by_client_id(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="") + ) + else: + composite_url = URL_REALM_ROLE_COMPOSITES.format( + url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe="") + ) + # Get existing composites + return self._request_and_deserialize(composite_url, method="GET") + except Exception as 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: str = "master"): + composite_url = "" + try: + if clientid is not None: + client = self.get_client_by_client_id(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="") + ) + else: + composite_url = URL_REALM_ROLE_COMPOSITES.format( + url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe="") + ) + # Get existing composites + # create new composites + return self._request(composite_url, method="POST", data=json.dumps(composites)) + except Exception as 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: str = "master"): + composite_url = "" + try: + if clientid is not None: + client = self.get_client_by_client_id(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="") + ) + else: + composite_url = URL_REALM_ROLE_COMPOSITES.format( + url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe="") + ) + # Get existing composites + # create new composites + return self._request(composite_url, method="DELETE", data=json.dumps(composites)) + except Exception as 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: str = "master"): + # Get existing composites + existing_composites = self.get_role_composites(rolerep=rolerep, clientid=clientid, realm=realm) + composites_to_be_created = [] + composites_to_be_deleted = [] + for composite in composites: + composite_found = False + existing_composite_client = None + 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"] + ): + composite_found = True + break + else: + 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) + for client_role in client_roles: + 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) + for client_role in client_roles: + if client_role["name"] == composite["name"]: + composites_to_be_deleted.append(client_role) + break + else: + realm_role = self.get_realm_role(name=composite["name"], realm=realm) + composites_to_be_deleted.append(realm_role) + + 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 + ) + 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 + ) + + 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="")) + try: + return self._request(role_url, method="DELETE") + except Exception as e: + self.fail_request(e, msg=f"Unable to delete role {name} in realm {realm}: {e}") + + 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 + :return: List of dicts of role representations + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + 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 self._request_and_deserialize(rolelist_url, method="GET") + except ValueError as 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_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: 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 + :param realm: Realm in which the role resides + :return: Dict of role representation + If the role does not exist, None is returned. + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + 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 self._request_and_deserialize(role_url, method="GET") + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + 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=f"Could not fetch role {name} for client {clientid} in realm {realm}: {e}") + + 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 + :param realm: Realm in which the role resides + :return: HTTPResponse object on success + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + 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 self._request(roles_url, method="POST", data=json.dumps(rolerep)) + except Exception as 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": []} + for composite in composites: + 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"]] = [] + keycloak_compatible_composites["client"][composite["client_id"]].append(composite["name"]) + else: + keycloak_compatible_composites["realm"].append(composite["name"]) + return keycloak_compatible_composites + + 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 + :param realm: Realm in which the role resides + :return HTTPResponse object on success + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + 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 = 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_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: 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 + :param realm: Realm in which the role resides + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + 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 self._request(role_url, method="DELETE") + except Exception as e: + self.fail_request(e, msg=f"Unable to delete role {name} for client {clientid} in realm {realm}: {e}") + + def get_authenticator_providers(self, realm: str = "master"): + """ + 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. + """ + try: + authentication_flow = {} + # Check if the authentication flow exists on the Keycloak serveraders + 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_request(e, msg=f"Unable get authentication flow {alias}: {e}") + + 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 + :param realm: realm of client to be deleted + :return: HTTPResponse object on success + """ + flow_url = URL_AUTHENTICATION_FLOW.format(url=self.baseurl, realm=realm, id=id) + + try: + return self._request(flow_url, method="DELETE") + except Exception as e: + self.fail_request(e, msg=f"Could not delete authentication flow {id} in realm {realm}: {e}") + + 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. + :param realm: Realm. + :return: Representation of the new authentication flow. + """ + try: + 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", + data=json.dumps(new_name), + ) + flow_list = json.load( + 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_request(e, msg=f"Could not copy authentication flow {config['alias']} in realm {realm}: {e}") + + 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. + :param realm: Realm. + :return: Representation of the new authentication flow. + """ + try: + new_flow = dict( + 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) + ) + flow_list = json.load( + 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_request( + e, msg=f"Could not create empty authentication flow {config['alias']} in realm {realm}: {e}" + ) + + 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: + self._request( + URL_AUTHENTICATION_FLOW_EXECUTIONS.format( + url=self.baseurl, realm=realm, flowalias=quote(flowAlias, safe="") + ), + method="PUT", + data=json.dumps(updatedExec), + ) + except HTTPError as e: + 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=f"Unable to update executions {updatedExec}: {e}") + + 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: + self._request( + URL_AUTHENTICATION_EXECUTION_CONFIG.format(url=self.baseurl, realm=realm, id=executionId), + method="POST", + data=json.dumps(authenticationConfig), + ) + except Exception as e: + self.fail_request(e, msg=f"Unable to add authenticationConfig {executionId}: {e}") + + 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 + :return: HTTPResponse object on success + """ + try: + newSubFlow = {} + newSubFlow["alias"] = subflowName + newSubFlow["provider"] = "registration-page-form" + newSubFlow["type"] = flowType + self._request( + URL_AUTHENTICATION_FLOW_EXECUTIONS_FLOW.format( + url=self.baseurl, realm=realm, flowalias=quote(flowAlias, safe="") + ), + method="POST", + data=json.dumps(newSubFlow), + ) + except Exception as e: + self.fail_request(e, msg=f"Unable to create new subflow {subflowName}: {e}") + + 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 + :return: HTTPResponse object on success + """ + try: + newExec = {} + newExec["provider"] = execution["providerId"] + newExec["requirement"] = execution["requirement"] + self._request( + URL_AUTHENTICATION_FLOW_EXECUTIONS_EXECUTION.format( + url=self.baseurl, realm=realm, flowalias=quote(flowAlias, safe="") + ), + method="POST", + data=json.dumps(newExec), + ) + except HTTPError as e: + 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=f"Unable to create new execution '{flowAlias}' {execution['providerId']}: {e}") + + 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 + :param diff: Integer number, raise of diff time if positive lower of diff time if negative + :return: HTTPResponse object on success + """ + try: + if diff > 0: + for _i in range(diff): + self._request( + URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY.format( + url=self.baseurl, realm=realm, id=executionId + ), + method="POST", + ) + elif diff < 0: + for _i in range(-diff): + self._request( + URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY.format( + url=self.baseurl, realm=realm, id=executionId + ), + method="POST", + ) + except Exception as e: + self.fail_request(e, msg=f"Unable to change execution priority {executionId}: {e}") + + 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 + :param realm: Realm + :return: Representation of the executions + """ + try: + # Get executions created + executions = json.load( + self._request( + URL_AUTHENTICATION_FLOW_EXECUTIONS.format( + 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( + 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_request( + e, msg=f"Could not get executions for authentication flow {config['alias']} in realm {realm}: {e}" + ) + + def get_required_actions(self, realm: str = "master"): + """ + Get required actions. + :param realm: Realm name (not id). + :return: List of representations of the required actions. + """ + + try: + required_actions = json.load( + 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: str = "master"): + """ + Register required action. + :param rep: JSON containing 'providerId', and 'name' attributes. + :param realm: Realm name (not id). + :return: Representation of the required action. + """ + + data = {"name": rep["name"], "providerId": rep["providerId"]} + + try: + return self._request( + URL_AUTHENTICATION_REGISTER_REQUIRED_ACTION.format(url=self.baseurl, realm=realm), + method="POST", + data=json.dumps(data), + ) + except Exception as 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: str = "master"): + """ + Update required action. + :param alias: Alias of required action. + :param rep: JSON describing new state of required action. + :param realm: Realm name (not id). + :return: HTTPResponse object on success. + """ + + try: + return self._request( + URL_AUTHENTICATION_REQUIRED_ACTIONS_ALIAS.format( + url=self.baseurl, alias=quote(alias, safe=""), realm=realm + ), + method="PUT", + data=json.dumps(rep), + ) + except Exception as e: + self.fail_request(e, msg=f"Unable to update required action {alias} in realm {realm}: {e}") + + def delete_required_action(self, alias, realm: str = "master"): + """ + Delete required action. + :param alias: Alias of required action. + :param realm: Realm name (not id). + :return: HTTPResponse object on success. + """ + + try: + return self._request( + URL_AUTHENTICATION_REQUIRED_ACTIONS_ALIAS.format( + url=self.baseurl, alias=quote(alias, safe=""), realm=realm + ), + method="DELETE", + ) + except Exception as e: + self.fail_request(e, msg=f"Unable to delete required action {alias} in realm {realm}: {e}") + + 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 self._request_and_deserialize(idps_url, method="GET") + except ValueError as 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_request(e, msg=f"Could not obtain list of identity providers for realm {realm}: {e}") + + 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 self._request_and_deserialize(idp_url, method="GET") + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + 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=f"Could not fetch identity provider {alias} in realm {realm}: {e}") + + 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 self._request(idps_url, method="POST", data=json.dumps(idprep)) + except Exception as 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: 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"]) + try: + return self._request(idp_url, method="PUT", data=json.dumps(idprep)) + except Exception as 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: 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 self._request(idp_url, method="DELETE") + except Exception as 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: 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 self._request_and_deserialize(mappers_url, method="GET") + except ValueError as 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_request( + e, msg=f"Could not obtain list of identity provider mappers for idp {alias} in realm {realm}: {e}" + ) + + 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. + :param realm: Realm in which the identity provider resides; default 'master'. + """ + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mid) + try: + return self._request_and_deserialize(mapper_url, method="GET") + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + 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=f"Could not fetch mapper {mid} for identity provider {alias} in realm {realm}: {e}" + ) + + 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". + :return: HTTPResponse object on success + """ + mappers_url = URL_IDENTITY_PROVIDER_MAPPERS.format(url=self.baseurl, realm=realm, alias=alias) + try: + return self._request(mappers_url, method="POST", data=json.dumps(mapper)) + except Exception as 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: 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"]) + try: + return self._request(mapper_url, method="PUT", data=json.dumps(mapper)) + except Exception as 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: 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 self._request(mapper_url, method="DELETE") + except Exception as 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: 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 += f"?{filter}" + + try: + return self._request_and_deserialize(comps_url, method="GET") + except ValueError as 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_request(e, msg=f"Could not obtain list of components for realm {realm}: {e}") + + 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 self._request_and_deserialize(comp_url, method="GET") + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + self.fail_request(e, msg=f"Could not fetch component {cid} in realm {realm}: {e}") + except Exception as e: + self.module.fail_json(msg=f"Could not fetch component {cid} in realm {realm}: {e}") + + 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 = 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=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_request(e, msg=f"Could not create component in realm {realm}: {e}") + + 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") + if cid is None: + self.module.fail_json(msg="Cannot update component without id") + comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid) + try: + return self._request(comp_url, method="PUT", data=json.dumps(comprep)) + except Exception as e: + self.fail_request(e, msg=f"Could not update component {cid} in realm {realm}: {e}") + + 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 self._request(comp_url, method="DELETE") + except Exception as 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 = f"{url}/search?name={quote(name, safe='')}" + + try: + return self._request_and_deserialize(search_url, method="GET") + except Exception: + return False + + def create_authz_authorization_scope(self, payload, client_id, realm): + """Create an authorization scope for a Keycloak client""" + url = URL_AUTHZ_AUTHORIZATION_SCOPES.format(url=self.baseurl, client_id=client_id, realm=realm) + + try: + return self._request(url, method="POST", data=json.dumps(payload)) + except Exception as 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 self._request(url, method="PUT", data=json.dumps(payload)) + except Exception as 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 self._request(url, method="DELETE") + except Exception as 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: str = "master"): + """ + Get a User by its ID. + :param user_id: ID of the user. + :param realm: Realm + :return: Representation of the user. + """ + try: + 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: + self.fail_request(e, msg=f"Could not get user {user_id} in realm {realm}: {e}") + + def create_user(self, userrep, realm: str = "master"): + """ + Create a new User. + :param userrep: Representation of the user to create + :param realm: Realm + :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) + 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_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 [] + 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] + module_attributes_list.append(attr) + return module_attributes_list + + 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. + :param realm: Realm + :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"]) + 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_request(e, msg=f"Could not update user {userrep['username']} in realm {realm}: {e}") + + def delete_user(self, user_id, realm: str = "master"): + """ + Delete a User. + :param user_id: ID of the user to be deleted + :param realm: Realm + :return: HTTP response. + """ + try: + 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_request(e, msg=f"Could not delete user {user_id} in realm {realm}: {e}") + + def get_user_groups(self, user_id, realm: str = "master"): + """ + Get the group names for a user. + :param user_id: User ID + :param realm: Realm + :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: + 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_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: 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 + :param group_id: Group Id to add the user to. + :param realm: Realm + :return: HTTP Response + """ + try: + 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_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: str = "master"): + """ + Remove a user from a group for a user. + :param user_id: User ID + :param group_id: Group Id to add the user to. + :param realm: Realm + :return: HTTP response + """ + try: + 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_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: 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. + """ + try: + 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) + 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: + 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_to_add = [] + groups_to_remove = [] + 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): + 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 + + 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 = [] + if isinstance(groups, list) and len(groups) > 0: + for group in groups: + if isinstance(group, str): + group_dict = {} + 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 + ) + + try: + return self._request(url, method="POST", data=json.dumps(payload)) + except Exception as 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 = f"{url}/{policy_id}" + + try: + return self._request(delete_url, method="DELETE") + except Exception as 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 = f"{url}/search?name={quote(name, safe='')}" + + try: + 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 + ) + + try: + return self._request(url, method="POST", data=json.dumps(payload)) + except Exception as 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 self._request(url, method="DELETE") + except Exception as 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 + ) + + try: + return self._request(url, method="PUT", data=json.dumps(payload)) + except Exception as 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 = f"{url}/search?name={quote(name, safe='')}" + + try: + 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 = f"{url}/search?name={quote(name, safe='')}" + + try: + return self._request_and_deserialize(search_url, method="GET") + except Exception: + return False + + def get_all_client_scope_scope_mappings(self, client_scope_id, realm: str = "master"): + """Fetch all (realm and client) roles (scope-mappings) associated with the client scope for a specific client scope on the Keycloak server. + :param client_scope_id: ID of the client scope from which to obtain the associated roles. + :param realm: Realm from which to obtain the scope. + :return: The client scope scope-mappings. + """ + client_role_scope_url = URL_CLIENT_SCOPE_SCOPE_MAPPINGS.format(url=self.baseurl, realm=realm, id=client_scope_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 {client_scope_id} in realm {realm}: {e}") + + def get_client_scope_scope_mappings_realm(self, client_scope_id, realm: str = "master"): + """Fetch the realm roles (scope-mappings) associated with the client scope for a specific client scope on the Keycloak server. + :param client_scope_id: ID of the client scope from which to obtain the associated roles. + :param realm: Realm from which to obtain the scope. + :return: The client scope realm scope-mappings. + """ + client_role_scope_url = URL_CLIENT_SCOPE_SCOPE_MAPPINGS_REALM.format( + url=self.baseurl, realm=realm, id=client_scope_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 {client_scope_id} in realm {realm}: {e}" + ) + + def get_client_scope_scope_mappings_client(self, client_scope_id, client_id, realm: str = "master"): + """Fetch the client roles (scope-mappings) associated with the client scope for a specific client scope and client on the Keycloak server. + :param client_scope_id: ID of the client scope 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 client scope client scope-mappings. + """ + client_role_scope_url = URL_CLIENT_SCOPE_SCOPE_MAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=client_scope_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 {client_scope_id} in realm {realm}: {e}", + ) + + def get_client_role_scope_from_client(self, target_client_id, role_owner_client_id, realm: str = "master"): + """Fetch the roles associated with the client's scope for a specific client on the Keycloak server. + :param target_client_id: ID of the client from which to obtain the associated roles. + :param role_owner_client_id: 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=target_client_id, scopeid=role_owner_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 roles scope for client {target_client_id} in realm {realm}: {e}") + + def update_client_role_scope_from_client(self, roles, target_client_id, role_owner_client_id, realm: str = "master"): + """Update and fetch the roles associated with the client's scope on the Keycloak server. + :param roles: List of roles to be added to the scope. + :param target_client_id: ID of the client to update scope. + :param role_owner_client_id: 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=target_client_id, scopeid=role_owner_client_id + ) + try: + self._request(client_role_scope_url, method="POST", data=json.dumps(roles)) + + except Exception as e: + self.fail_request(e, msg=f"Could not update roles scope for client {target_client_id} in realm {realm}: {e}") + + return self.get_client_role_scope_from_client(target_client_id, role_owner_client_id, realm) + + def delete_client_role_scope_from_client(self, roles, target_client_id, role_owner_client_id, realm: str = "master"): + """Delete the roles from the client's scope on the Keycloak server. + :param roles: List of roles to be deleted. + :param target_client_id: ID of the client to delete roles from scope. + :param role_owner_client_id: 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=target_client_id, scopeid=role_owner_client_id + ) + try: + self._request(client_role_scope_url, method="DELETE", data=json.dumps(roles)) + + except Exception as e: + self.fail_request(e, msg=f"Could not delete roles from scope for client {target_client_id} in realm {realm}: {e}") + + return self.get_client_role_scope_from_client(target_client_id, role_owner_client_id, realm) + + def update_client_scope_scope_mappings_client( + self, roles: list[dict], client_scope_id: str, role_owner_client_id: str, realm: str = "master" + ): + """Update and fetch the client roles (scope-mappings) associated with the client scope on the Keycloak server. + :param roles: List of client roles to be added to the scope. + :param client_scope_id: ID of the client scope to update scope-mappings. + :param role_owner_client_id: ID of the client from which to obtain the associated roles. + :param realm: Realm from which to obtain the client. + :return: The client scope client scope-mappings. + """ + client_role_scope_url = URL_CLIENT_SCOPE_SCOPE_MAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=client_scope_id, client=role_owner_client_id + ) + try: + self._request(client_role_scope_url, method="POST", data=json.dumps(roles)) + + except Exception as e: + self.fail_request( + e, + msg=f"Could not update scope mappings for client scope {role_owner_client_id}.{client_scope_id} in realm {realm}: {e}", + ) + + return self.get_client_scope_scope_mappings_client(client_scope_id, role_owner_client_id, realm) + + def update_client_scope_scope_mappings_realm(self, roles: list[dict], client_scope_id: str, realm: str = "master"): + """Update and fetch the realm roles (scope-mappings) associated with the client scope on the Keycloak server. + :param roles: List of realm roles to be added to the scope. + :param client_scope_id: ID of the client scope to update scope-mappings. + :param realm: Realm from which to obtain the roles. + :return: The client scope realm scope-mappings. + """ + client_role_scope_url = URL_CLIENT_SCOPE_SCOPE_MAPPINGS_REALM.format( + url=self.baseurl, realm=realm, id=client_scope_id + ) + try: + self._request(client_role_scope_url, method="POST", data=json.dumps(roles)) + + except Exception as e: + self.fail_request( + e, msg=f"Could not update scope mappings for client scope {client_scope_id} in realm {realm}: {e}" + ) + + return self.get_client_scope_scope_mappings_realm(client_scope_id, realm) + + def delete_client_scope_scope_mappings_client( + self, roles: list[dict], client_scope_id: str, role_owner_client_id: str, realm: str = "master" + ): + """Delete the client roles (scope_mappings) from the client scope on the Keycloak server. + :param roles: List of roles to be deleted. + :param client_scope_id: ID of the client scope to delete roles from scope-mappings. + :param role_owner_client_id: ID of the client who owns the roles. + :param realm: Realm from which to obtain the client. + :return: The client scope client scope-mappings. + """ + client_role_scope_url = URL_CLIENT_SCOPE_SCOPE_MAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=client_scope_id, client=role_owner_client_id + ) + try: + self._request(client_role_scope_url, method="DELETE", data=json.dumps(roles)) + + except Exception as e: + self.fail_request( + e, + msg=f"Could not delete scope mappings for client scope {role_owner_client_id}.{client_scope_id} in realm {realm}: {e}", + ) + + return self.get_client_scope_scope_mappings_client(client_scope_id, role_owner_client_id, realm) + + def delete_client_scope_scope_mappings_realm(self, roles: list[dict], client_scope_id: str, realm: str = "master"): + """Delete the realm roles (scope_mappings) contained in the roles from the client scope on the Keycloak server. + :param roles: List of roles to be deleted. + :param client_scope_id: ID of the client scope to delete roles from scope-mappings. + :param realm: Realm from which to obtain the roles. + :return: The client scope realm scope-mappings. + """ + client_role_scope_url = URL_CLIENT_SCOPE_SCOPE_MAPPINGS_REALM.format( + url=self.baseurl, realm=realm, id=client_scope_id + ) + try: + self._request(client_role_scope_url, method="DELETE", data=json.dumps(roles)) + + except Exception as e: + self.fail_request( + e, msg=f"Could not delete scope mappings for client scope {client_scope_id} in realm {realm}: {e}" + ) + + return self.get_client_scope_scope_mappings_realm(client_scope_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. + :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 self._request_and_deserialize(client_role_scope_url, method="GET") + except Exception as 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, roles, clientid, realm: str = "master"): + """Update and fetch the realm roles from the client's scope on the Keycloak server. + :param roles: List of realm roles to add. + :param clientid: ID of the client to update scope. + :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: + self._request(client_role_scope_url, method="POST", data=json.dumps(roles)) + + except Exception as 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, roles, clientid, realm: str = "master"): + """Delete the realm roles from the client's scope on the Keycloak server. + :param roles: 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. + :return: The client realm roles scope. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_REALM.format(url=self.baseurl, realm=realm, id=clientid) + try: + self._request(client_role_scope_url, method="DELETE", data=json.dumps(roles)) + + except Exception as 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 get_organizations(self, search_filter=None, realm: str = "master"): + """Fetch representations for organizations in a realm. + :param search_filter: Optional query string to append (e.g. 'search=foo&exact=true'). + :param realm: Realm to be queried; default 'master'. + :return: List of organization representations. + """ + orgs_url = URL_ORGANIZATIONS.format(url=self.baseurl, realm=realm) + if search_filter: + orgs_url = f"{orgs_url}?{search_filter}" + try: + return self._request_and_deserialize(orgs_url, method="GET") + except ValueError as e: + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain list of organizations for realm {realm}: {e}" + ) + except Exception as e: + self.fail_request(e, msg=f"Could not obtain list of organizations for realm {realm}: {e}") + + def get_organization_by_id(self, org_id, realm: str = "master"): + """Fetch an organization by its UUID. + If the organization does not exist, None is returned. + :param org_id: UUID of the organization to fetch. + :param realm: Realm in which the organization resides; default 'master'. + """ + org_url = URL_ORGANIZATION.format(url=self.baseurl, realm=realm, id=org_id) + try: + return self._request_and_deserialize(org_url, method="GET") + except HTTPError as e: + if e.code == HTTPStatus.NOT_FOUND: + return None + else: + self.fail_request(e, msg=f"Could not fetch organization {org_id} in realm {realm}: {e}") + except Exception as e: + self.fail_request(e, msg=f"Could not fetch organization {org_id} in realm {realm}: {e}") + + def get_organization_by_name(self, name, realm: str = "master"): + """Fetch an organization by name using search API with exact matching. + Returns the full representation (fetched by ID) or None if not found. + :param name: Name of the organization to fetch. + :param realm: Realm in which the organization resides; default 'master'. + """ + search_url = URL_ORGANIZATIONS.format(url=self.baseurl, realm=realm) + search_url = f"{search_url}?search={quote(name, safe='')}&exact=true" + try: + orgs = self._request_and_deserialize(search_url, method="GET") + if not orgs: + return None + return self.get_organization_by_id(orgs[0]["id"], realm=realm) + except Exception as e: + self.fail_request(e, msg=f"Could not fetch organization {name} in realm {realm}: {e}") + + def create_organization(self, orgrep, realm: str = "master"): + """Create an organization. + :param orgrep: Organization representation of the organization to be created. + :param realm: Realm in which this organization resides, default "master". + :return: HTTPResponse object on success. + """ + orgs_url = URL_ORGANIZATIONS.format(url=self.baseurl, realm=realm) + try: + return self._request(orgs_url, method="POST", data=json.dumps(orgrep)) + except Exception as e: + self.fail_request(e, msg=f"Could not create organization {orgrep.get('name')} in realm {realm}: {e}") + + def update_organization(self, orgrep, realm: str = "master"): + """Update an existing organization. + :param orgrep: Organization representation of the organization to be updated. + :param realm: Realm in which this organization resides, default "master". + :return: HTTPResponse object on success. + """ + org_url = URL_ORGANIZATION.format(url=self.baseurl, realm=realm, id=orgrep["id"]) + try: + return self._request(org_url, method="PUT", data=json.dumps(orgrep)) + except Exception as e: + self.fail_request(e, msg=f"Could not update organization {orgrep.get('name')} in realm {realm}: {e}") + + def delete_organization(self, org_id, realm: str = "master"): + """Delete an organization by UUID. + :param org_id: UUID of the organization to delete. + :param realm: Realm in which this organization resides, default "master". + """ + org_url = URL_ORGANIZATION.format(url=self.baseurl, realm=realm, id=org_id) + try: + return self._request(org_url, method="DELETE") + except Exception as e: + self.fail_request(e, msg=f"Could not delete organization {org_id} in realm {realm}: {e}") + + 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 = 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/ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/keycloak_clientsecret.py b/ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/keycloak_clientsecret.py new file mode 100644 index 00000000..847a5062 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/module_utils/identity/keycloak/keycloak_clientsecret.py @@ -0,0 +1,78 @@ +# 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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + +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_client_id(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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication.py new file mode 100644 index 00000000..6b016e06 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication.py @@ -0,0 +1,520 @@ +# 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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + +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. +# Originally added in community.general 3.3.0 +version_added: "3.0.0" + +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_flow.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_flow.py new file mode 100644 index 00000000..d65b153d --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_flow.py @@ -0,0 +1,300 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Contributors to the middleware_automation.keycloak collection +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = ''' +--- +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 + 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. + + - This module supports creating new top-level authentication flows, copying existing flows, + and adding execution steps to a flow. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the authentication flow. + - On V(present), the flow will be created if it does not yet exist. + - On V(absent), the flow will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + alias: + type: str + required: true + description: + - Alias (name) of the authentication flow. + + description: + type: str + description: + - Description of the authentication flow. + default: '' + + realm: + type: str + description: + - The Keycloak realm under which this authentication flow resides. + default: 'master' + + provider_id: + type: str + description: + - The provider ID for the flow. + default: 'basic-flow' + aliases: + - providerId + + copy_from: + type: str + description: + - If set, the new flow is created as a copy of the flow with this alias. + - Cannot be used together with O(executions). + aliases: + - copyFrom + + executions: + type: list + elements: dict + description: + - A list of executions (authenticator steps) to add to the flow. + - Each execution is a dict with keys C(provider_id) (or C(providerId)) and C(requirement). + - Executions are only added when the flow is first created. + default: [] + suboptions: + provider_id: + type: str + required: true + description: + - The authenticator provider ID (e.g. V(auth-cookie), V(auth-password), V(auth-otp-form)). + aliases: + - providerId + requirement: + type: str + required: true + description: + - The requirement level for this execution. + choices: + - REQUIRED + - ALTERNATIVE + - DISABLED + - CONDITIONAL + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Paulo Menon (@paulomenon) +''' + +EXAMPLES = ''' +- name: Create an authentication flow with executions + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: TestRealm + alias: my-browser-flow + description: "Custom browser flow" + provider_id: basic-flow + executions: + - provider_id: auth-cookie + requirement: ALTERNATIVE + - provider_id: auth-password + requirement: REQUIRED + - provider_id: auth-otp-form + requirement: ALTERNATIVE + state: present + delegate_to: localhost + +- name: Create an authentication flow by copying an existing one + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: TestRealm + alias: my-copy-of-browser + copy_from: browser + state: present + delegate_to: localhost + +- name: Create a flow using token authentication + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: http://localhost:8080 + token: MY_TOKEN + realm: TestRealm + alias: my-flow + state: present + delegate_to: localhost + +- name: Delete an authentication flow + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: TestRealm + alias: my-browser-flow + state: absent + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Authentication flow my-browser-flow has been created" + +end_state: + description: Representation of the authentication flow after module execution. + returned: on success + type: dict + sample: { + "id": "uuid-here", + "alias": "my-browser-flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": false + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule + + +def main(): + argument_spec = keycloak_argument_spec() + + execution_spec = dict( + provider_id=dict(type='str', required=True, aliases=['providerId']), + requirement=dict(type='str', required=True, choices=['REQUIRED', 'ALTERNATIVE', 'DISABLED', 'CONDITIONAL']), + ) + + meta_args = dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + alias=dict(type='str', required=True), + description=dict(type='str', default=''), + realm=dict(type='str', default='master'), + provider_id=dict(type='str', default='basic-flow', aliases=['providerId']), + copy_from=dict(type='str', aliases=['copyFrom']), + executions=dict(type='list', default=[], options=execution_spec, elements='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']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']]), + mutually_exclusive=[['copy_from', 'executions']]) + + result = dict(changed=False, msg='', diff={}, end_state={}) + + 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') + description = module.params.get('description') + provider_id = module.params.get('provider_id') + copy_from = module.params.get('copy_from') + executions = module.params.get('executions') + + before_flow = kc.get_authentication_flow_by_alias(alias, realm=realm) + flow_exists = bool(before_flow) + + if state == 'absent': + if flow_exists: + result['changed'] = True + if module._diff: + result['diff'] = dict(before=before_flow, after='') + if module.check_mode: + module.exit_json(**result) + kc.delete_authentication_flow_by_id(before_flow['id'], realm=realm) + result['msg'] = "Authentication flow {alias} has been deleted".format(alias=alias) + else: + result['msg'] = "Authentication flow {alias} does not exist, doing nothing".format(alias=alias) + result['end_state'] = {} + module.exit_json(**result) + + if flow_exists: + result['changed'] = False + result['end_state'] = before_flow + result['msg'] = "Authentication flow {alias} already exists".format(alias=alias) + module.exit_json(**result) + + result['changed'] = True + + flow_config = { + 'alias': alias, + 'description': description, + 'providerId': provider_id, + } + + if module._diff: + result['diff'] = dict(before='', after=flow_config) + + if module.check_mode: + module.exit_json(**result) + + if copy_from: + flow_config['copyFrom'] = copy_from + after_flow = kc.copy_auth_flow(flow_config, realm=realm) + result['msg'] = "Authentication flow {alias} has been created (copied from {src})".format(alias=alias, src=copy_from) + else: + after_flow = kc.create_empty_auth_flow(flow_config, realm=realm) + + if executions: + for execution in executions: + exec_rep = { + 'providerId': execution['provider_id'], + 'requirement': execution['requirement'], + } + kc.create_execution(exec_rep, alias, realm=realm) + + result['msg'] = "Authentication flow {alias} has been created".format(alias=alias) + + after_flow = kc.get_authentication_flow_by_alias(alias, realm=realm) + result['end_state'] = after_flow + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_required_actions.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_required_actions.py new file mode 100644 index 00000000..187900e5 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_required_actions.py @@ -0,0 +1,465 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +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. +# Originally added in community.general 7.1.0 +version_added: "3.0.0" + +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_v2.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_v2.py new file mode 100644 index 00000000..bc6118ab --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authentication_v2.py @@ -0,0 +1,1281 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +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. + - 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 10 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. + 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 5. + 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 6. + 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 7. + 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 8. + 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 9. + 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 10 (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(10), + ), + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_authorization_scope.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_authorization_scope.py new file mode 100644 index 00000000..a4593df7 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_authorization_scope.py @@ -0,0 +1,282 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_custom_policy.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_custom_policy.py new file mode 100644 index 00000000..c857e1c2 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_custom_policy.py @@ -0,0 +1,215 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_permission.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_permission.py new file mode 100644 index 00000000..4f7585ec --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_permission.py @@ -0,0 +1,445 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +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 + +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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_permission_info.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_permission_info.py new file mode 100644 index 00000000..9618ba43 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_authz_permission_info.py @@ -0,0 +1,180 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +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 + +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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client.py new file mode 100644 index 00000000..186a9061 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client.py @@ -0,0 +1,1519 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +module: keycloak_client + +short_description: Allows administration of Keycloak clients using Keycloak API + +version_added: "3.0.0" + + +description: + - 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 + action_group: + # Originally added in community.general 10.2.0 + version_added: "3.0.0" + +options: + 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: + - 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 + + 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 + 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 + + 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. 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: + - 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: + - 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 is used to authenticate the client. + type: str + + 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.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Eike Frost (@eikef) + - Ivan Kokalović (@koke1997) +""" + +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_realm: master + auth_username: USERNAME + auth_password: PASSWORD + client_id: test + state: present + delegate_to: localhost + + +- 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_realm: master + token: TOKEN + client_id: test + state: present + delegate_to: localhost + + +- name: Delete a Keycloak client + 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 + client_id: test + state: absent + delegate_to: localhost + + +- 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_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + state: present + client_id: test + client_authenticator_type: client-x509 + attributes: + x509.subjectdn: "CN=client" + x509.allow.regex.pattern.comparison: false + + +- 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_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + realm: master + client_id: test + id: d8b127a3-31f6-44c8-a7e4-4ab9a3e78d95 + name: this_is_a_test + description: Description of this wonderful client + root_url: https://www.example.com/ + admin_url: https://www.example.com/admin_url + base_url: basepath + enabled: true + client_authenticator_type: client-secret + secret: REALLYWELLKEPTSECRET + redirect_uris: + - https://www.example.com/* + - http://localhost:8888/ + web_origins: + - https://www.example.com/* + not_before: 1507825725 + bearer_only: false + consent_required: false + standard_flow_enabled: true + implicit_flow_enabled: false + direct_access_grants_enabled: false + service_accounts_enabled: false + authorization_services_enabled: false + public_client: false + frontchannel_logout: false + protocol: openid-connect + full_scope_allowed: false + node_re_registration_timeout: -1 + client_template: test + use_template_config: false + use_template_scope: false + use_template_mappers: false + always_display_in_console: true + registered_nodes: + node01.example.com: 1507828202 + registration_access_token: eyJWT_TOKEN + surrogate_auth_required: false + default_roles: + - test01 + - test02 + authentication_flow_binding_overrides: + browser: 4c90336b-bf1d-4b87-916d-3677ba4e5fbb + 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 + - config: + attribute.name: Role + attribute.nameformat: Basic + single: false + consentRequired: false + name: role list + protocol: saml + protocolMapper: saml-role-list-mapper + attributes: + saml.authnstatement: true + saml.client.signature: true + saml.force.post.binding: true + saml.server.signature: true + saml.signature.algorithm: RSA_SHA256 + saml.signing.certificate: CERTIFICATEHERE + saml.signing.private.key: PRIVATEKEYHERE + saml_force_name_id_format: false + saml_name_id_format: username + saml_signature_canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#" + user.info.response.signature.alg: RS256 + request.object.signature.alg: RS256 + use.jwks.url: true + jwks.url: JWKS_URL_FOR_CLIENT_AUTH_JWT + jwt.credential.certificate: JWT_CREDENTIAL_CERTIFICATE_FOR_CLIENT_AUTH + delegate_to: localhost +""" + +RETURN = r""" +msg: + 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"} + +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" + } + } + +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" + } + } +""" + +import copy + +from ansible.module_utils.basic import AnsibleModule + +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 + the change detection is more effective. + + :param clientrep: the clientrep dict to be sanitized + :param remove_ids: If set to true, then the unique ID's of objects is removed to make the diff and checks for changed + not alert when the ID's of objects are not usually known, (e.g. for protocol_mappers) + :return: normalised clientrep dict + """ + # Avoid the dict passed in to be modified + clientrep = copy.deepcopy(clientrep) + + if remove_ids: + clientrep.pop("id", None) + + for url_field in EMPTY_OPTIONAL_URL_FIELDS: + if clientrep.get(url_field) == "": + clientrep.pop(url_field, None) + + _sorted_str_list(clientrep, "defaultClientScopes") + _sorted_str_list(clientrep, "optionalClientScopes") + _sorted_str_list(clientrep, "redirectUris") + _sorted_str_list(clientrep, "defaultRoles") + + 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) + + # 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) + 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. + + :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): + 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. + + Args: + flow_name (str): The name of the authentication flow. + realm (str): The name of the realm. + kc (KeycloakClient): The Keycloak client instance. + + Returns: + str: The ID of the authentication flow. + + Raises: + KeycloakAPIException: If the authentication flow with the given name is not found in the realm. + """ + flow = kc.get_authentication_flow_by_alias(flow_name, realm) + if flow: + return flow["id"] + 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. + + Args: + newClientFlowBinding (dict): A dictionary containing client flow bindings. + realm (str): The name of the realm. + kc (KeycloakClient): An instance of the KeycloakClient class. + + Returns: + dict: A dictionary representing the model flow bindings. The dictionary has two keys: + - "browser" (str or None): The ID of the browser authentication flow binding, or None if not provided. + - "direct_grant" (str or None): The ID of the direct grant authentication flow binding, or None if not provided. + + Raises: + KeycloakAPIException: If the authentication flow with the given name is not found in the realm. + + """ + + modelFlow = {"browser": None, "direct_grant": None} + + for k, v in newClientFlowBinding.items(): + if not v: + continue + if k == "browser": + modelFlow["browser"] = v + elif k == "browser_name": + modelFlow["browser"] = get_authentication_flow_id(v, realm, kc) + elif k == "direct_grant": + modelFlow["direct_grant"] = v + elif k == "direct_grant_name": + modelFlow["direct_grant"] = get_authentication_flow_id(v, 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_client_scopes(realm) + for name in missing_scopes: + scope = find_match(client_scopes, "name", name) + if scope: + kc.add_default_client_scope(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_client_scopes(realm) + for name in missing_scopes: + scope = find_match(client_scopes, "name", name) + if scope: + kc.add_optional_client_scope(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_client_scopes(realm, desired_client["clientId"]) + for name in missing_scopes: + scope = find_match(client_scopes, "name", name) + if scope: + kc.delete_default_client_scope(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_client_scopes(realm, desired_client["clientId"]) + for name in missing_scopes: + scope = find_match(client_scopes, "name", name) + if scope: + kc.delete_optional_client_scope(scope["id"], realm, desired_client["clientId"]) + + +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=[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"]), + ) + + 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"]), + 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", "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") + 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 + ] + + # See if it already exists in Keycloak + if cid is None: + before_client = kc.get_client_by_client_id(module.params.get("client_id"), realm=realm) + if before_client is not None: + 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) + + # 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": + 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": + 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 = copy.deepcopy(before_client) + desired_client.update(changeset) + + 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": + # 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." + module.exit_json(**result) + + # Process a creation + 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 module._diff: + 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_client_id(desired_client["clientId"], realm=realm) + + result["end_state"] = sanitize_cr(after_client) + + result["msg"] = f"Client {desired_client['clientId']} has been created." + module.exit_json(**result) + + else: + 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 + + if module.check_mode: + 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)) + 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) + normalize_kc_resp(after_client) + + if module._diff: + result["diff"] = dict(before=sanitize_cr(before_client), after=sanitize_cr(after_client)) + + result["end_state"] = sanitize_cr(after_client) + + 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 + + if module._diff: + 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["end_state"] = {} + + result["msg"] = f"Client {before_client['clientId']} has been deleted." + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_rolemapping.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_rolemapping.py new file mode 100644 index 00000000..5a7e03aa --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_rolemapping.py @@ -0,0 +1,416 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_rolescope.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_rolescope.py new file mode 100644 index 00000000..4713893e --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_rolescope.py @@ -0,0 +1,289 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +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 + +# Originally added in community.general 8.6.0 +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(target_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: + # Originally added in community.general 10.2.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 it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + type: str + description: + - The Keycloak realm under which clients resides. + default: 'master' + + target_client_id: + type: str + required: true + description: + - Roles provided in O(role_names) while be added to this client scope. + role_owner_client_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(role_owner_client_id) is present, all roles must be under this client. + - If O(role_owner_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: + - 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 + target_client_id: frontend-client-public + role_owner_client_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 + target_client_id: frontend-client-public + role_owner_client_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 + target_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( + target_client_id=dict(type="str", required=True), + role_owner_client_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") + target_client_id = module.params.get("target_client_id") + role_owner_client_id = module.params.get("role_owner_client_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_client_id(target_client_id, realm) + if not objClient: + module.fail_json(msg=f"Failed to retrive client '{realm}.{target_client_id}'") + if objClient["fullScopeAllowed"] and state == "present": + module.fail_json(msg=f"FullScopeAllowed is active for Client '{realm}.{target_client_id}'") + + if role_owner_client_id: + role_owner_client = kc.get_client_by_client_id(role_owner_client_id, realm) + if not role_owner_client: + module.fail_json(msg=f"Failed to retrive client '{realm}.{role_owner_client_id}'") + before_role_mapping = kc.get_client_role_scope_from_client(objClient["id"], role_owner_client["id"], realm) + else: + before_role_mapping = kc.get_client_role_scope_from_realm(objClient["id"], realm) + + if role_owner_client_id: + # retrive all role from client_scope + client_scope_roles_by_name = kc.get_client_roles_by_id(role_owner_client["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 role_owner_client_id: + module.fail_json(msg=f"Failed to retrive role '{realm}.{role_owner_client_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 {target_client_id}." + elif state == "present": + # doing update + if module.check_mode: + result["end_state"] = desired_role_mapping + elif role_owner_client_id: + result["end_state"] = kc.update_client_role_scope_from_client( + role_mapping_to_manipulate, objClient["id"], role_owner_client["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 {target_client_id} has been updated" + else: + # doing delete + if module.check_mode: + result["end_state"] = desired_role_mapping + elif role_owner_client_id: + result["end_state"] = kc.delete_client_role_scope_from_client( + role_mapping_to_manipulate, objClient["id"], role_owner_client["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 {target_client_id} has been deleted" + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope.py new file mode 100644 index 00000000..9607420d --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope.py @@ -0,0 +1,329 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Contributors to the middleware_automation.keycloak collection +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = ''' +--- +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: + - 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 + 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. + + - This module also supports managing protocol mappers within a client scope. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the client scope. + - On V(present), the client scope will be created if it does not yet exist, or updated with the parameters you provide. + - On V(absent), the client scope will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + name: + type: str + required: true + description: + - Name of the client scope. + + description: + type: str + default: '' + description: + - Description of the client scope. + + realm: + type: str + description: + - The Keycloak realm under which this client scope resides. + default: 'master' + + protocol: + type: str + description: + - The protocol associated with the client scope. + default: 'openid-connect' + choices: + - openid-connect + - saml + + attributes: + type: dict + description: + - A dict of key/value pairs to set as attributes for the client scope. + + protocol_mappers: + type: list + elements: dict + description: + - A list of protocol mappers to associate with the client scope. + - Each mapper is a dict with the keys C(name), C(protocol), C(protocolMapper), and C(config). + default: [] + suboptions: + name: + type: str + required: true + description: + - Name of the protocol mapper. + protocol: + type: str + description: + - Protocol for the mapper. + default: 'openid-connect' + protocolMapper: + type: str + required: true + description: + - The mapper type (e.g. V(oidc-usermodel-attribute-mapper), V(oidc-audience-mapper)). + aliases: + - protocol_mapper_type + config: + type: dict + required: true + description: + - Configuration for the protocol mapper. + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Paulo Menon (@paulomenon) +''' + +EXAMPLES = ''' +- name: Create a client scope with protocol mappers + middleware_automation.keycloak.keycloak_client_scope: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: TestRealm + name: my-client-scope + description: "A custom client scope" + protocol: openid-connect + protocol_mappers: + - name: email + protocol: openid-connect + protocolMapper: oidc-usermodel-attribute-mapper + config: + user.attribute: email + claim.name: email + jsonType.label: String + id.token.claim: "true" + access.token.claim: "true" + userinfo.token.claim: "true" + state: present + delegate_to: localhost + +- name: Create a client scope using token authentication + middleware_automation.keycloak.keycloak_client_scope: + auth_keycloak_url: http://localhost:8080 + token: MY_TOKEN + realm: TestRealm + name: my-scope + state: present + delegate_to: localhost + +- name: Delete a client scope + middleware_automation.keycloak.keycloak_client_scope: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: password + realm: TestRealm + name: my-client-scope + state: absent + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Client scope my-scope has been created" + +end_state: + description: Representation of the client scope after module execution. + returned: on success + type: dict + sample: { + "id": "uuid-here", + "name": "my-scope", + "protocol": "openid-connect", + "description": "A custom scope" + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule + + +def main(): + argument_spec = keycloak_argument_spec() + + mapper_spec = dict( + name=dict(type='str', required=True), + protocol=dict(type='str', default='openid-connect'), + protocolMapper=dict(type='str', required=True, aliases=['protocol_mapper_type']), + config=dict(type='dict', required=True), + ) + + meta_args = dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + name=dict(type='str', required=True), + description=dict(type='str', default=''), + realm=dict(type='str', default='master'), + protocol=dict(type='str', default='openid-connect', choices=['openid-connect', 'saml']), + attributes=dict(type='dict'), + protocol_mappers=dict(type='list', default=[], options=mapper_spec, elements='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']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + result = dict(changed=False, msg='', diff={}, end_state={}) + + 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') + name = module.params.get('name') + state = module.params.get('state') + protocol = module.params.get('protocol') + description = module.params.get('description') + attributes = module.params.get('attributes') + protocol_mappers = module.params.get('protocol_mappers') + + before_scope = kc.get_client_scope_by_name(name, realm=realm) + + if state == 'absent': + if before_scope: + result['changed'] = True + if module._diff: + result['diff'] = dict(before=before_scope, after='') + if module.check_mode: + module.exit_json(**result) + kc.delete_client_scope(cid=before_scope['id'], realm=realm) + result['msg'] = "Client scope {name} has been deleted".format(name=name) + else: + result['msg'] = "Client scope {name} does not exist, doing nothing".format(name=name) + result['end_state'] = {} + module.exit_json(**result) + + scope_rep = { + 'name': name, + 'protocol': protocol, + 'description': description, + } + if attributes: + scope_rep['attributes'] = attributes + + if not before_scope: + result['changed'] = True + if module._diff: + result['diff'] = dict(before='', after=scope_rep) + if module.check_mode: + module.exit_json(**result) + + kc.create_client_scope(scope_rep, realm=realm) + after_scope = kc.get_client_scope_by_name(name, realm=realm) + + if protocol_mappers: + for mapper in protocol_mappers: + mapper_rep = { + 'name': mapper['name'], + 'protocol': mapper.get('protocol', protocol), + 'protocolMapper': mapper['protocolMapper'], + 'config': mapper['config'], + } + kc.create_client_scope_protocolmapper(after_scope['id'], mapper_rep, realm=realm) + after_scope = kc.get_client_scope_by_name(name, realm=realm) + + result['end_state'] = after_scope + result['msg'] = "Client scope {name} has been created".format(name=name) + module.exit_json(**result) + + else: + changed = False + for key in ('protocol', 'description'): + if scope_rep.get(key) and scope_rep[key] != before_scope.get(key): + changed = True + break + + if attributes and attributes != before_scope.get('attributes', {}): + changed = True + + if changed: + result['changed'] = True + scope_rep['id'] = before_scope['id'] + if module._diff: + result['diff'] = dict(before=before_scope, after=scope_rep) + if module.check_mode: + module.exit_json(**result) + kc.update_client_scope(scope_rep, realm=realm) + + if protocol_mappers: + existing_mappers = kc.get_client_scope_protocolmappers(before_scope['id'], realm=realm) + existing_mapper_names = {m['name'] for m in existing_mappers} + + for mapper in protocol_mappers: + if mapper['name'] not in existing_mapper_names: + result['changed'] = True + if not module.check_mode: + mapper_rep = { + 'name': mapper['name'], + 'protocol': mapper.get('protocol', protocol), + 'protocolMapper': mapper['protocolMapper'], + 'config': mapper['config'], + } + kc.create_client_scope_protocolmapper(before_scope['id'], mapper_rep, realm=realm) + + after_scope = kc.get_client_scope_by_name(name, realm=realm) + result['end_state'] = after_scope + + if result['changed']: + result['msg'] = "Client scope {name} has been updated".format(name=name) + else: + result['msg'] = "No changes required to client scope {name}".format(name=name) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope_rolemappings.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope_rolemappings.py new file mode 100644 index 00000000..63b112d4 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope_rolemappings.py @@ -0,0 +1,284 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +module: keycloak_client_scope_rolemappings + +short_description: Allows administration of Keycloak client scope scope mappings to restrict the usage of certain roles to + specific client scopes + +# 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 client scopes 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' + + client_scope_id: + required: true + type: str + description: + - Roles provided in O(role_names) will be added to this client scope. + + 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 client scope + middleware_automation.keycloak.keycloak_client_scope_rolemappings: + 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: frontend-client-scope + role_names: + - backend-role-admin + - backend-role-user + +- name: Remove roles from client scope + middleware_automation.keycloak.keycloak_client_scope_rolemappings: + 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: frontend-client-scope + role_names: + - backend-role-admin + state: absent + +- name: Add realm roles to client scope + middleware_automation.keycloak.keycloak_client_scope_rolemappings: + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_scope_id: frontend-client-scope + role_names: + - realm-role-admin + - realm-role-user +""" + +RETURN = r""" +end_state: + description: Representation of client scope 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"), + client_scope_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"] + client_scope_id = module.params["client_scope_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}'") + + client_scope_object = kc.get_client_scope_by_name(client_scope_id, realm) + if not client_scope_object: + module.fail_json(msg=f"Failed to retrieve client scope '{client_scope_id}'") + + if client_id: + # add client role + client_object = kc.get_client_by_client_id(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_client_scope_scope_mappings_client(client_scope_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_client_scope_scope_mappings_realm(client_scope_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 client scope {client_scope_id}." + elif state == "present": + # doing update + if module.check_mode: + result["end_state"] = desired_role_mapping + elif client_id: + result["end_state"] = kc.update_client_scope_scope_mappings_client( + changed_roles, client_scope_object["id"], client_object["id"], realm + ) + else: + result["end_state"] = kc.update_client_scope_scope_mappings_realm( + changed_roles, client_scope_object["id"], realm + ) + result["msg"] = f"Clientscope scope mappings for {client_scope_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_client_scope_scope_mappings_client( + changed_roles, client_scope_object["id"], client_object["id"], realm + ) + else: + result["end_state"] = kc.delete_client_scope_scope_mappings_realm( + changed_roles, client_scope_object["id"], realm + ) + result["msg"] = f"Clientscope scope mappings for {client_scope_id} have been deleted" + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope_type.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope_type.py new file mode 100644 index 00000000..57169768 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_client_scope_type.py @@ -0,0 +1,317 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +module: keycloak_client_scope_type + +short_description: Set the type of a client scope in a realm or client using the Keycloak API + +# Originally added in community.general 6.6.0 +version_added: "3.0.0" + +description: + - This module allows you to set the type (optional, default) of client scopes 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: + # Originally added in community.general 10.2.0 + 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 client scope types are set as a default for the realm. + aliases: + - clientId + type: str + + default_client_scopes: + description: + - Client scopes that should be of type default. + type: list + elements: str + + optional_client_scopes: + 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_client_scope_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_client_scopes: ['profile', 'roles'] + delegate_to: localhost + + +- name: Set default and optional client scopes on client level with token auth + middleware_automation.keycloak.keycloak_client_scope_type: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + realm: "MyCustomRealm" + client_id: "MyCustomClient" + default_client_scopes: ['profile', 'roles'] + optional_client_scopes: ['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_client_scopes": [ + "profile", + "role" + ], + "optional_client_scopes": [] + } +existing: + description: + - Representation of client scopes before module execution. + returned: always + type: dict + sample: + { + "default_client_scopes": [ + "profile", + "role" + ], + "optional_client_scopes": [ + "phone" + ] + } +end_state: + description: + - Representation of client scopes after module execution. + - The sample is truncated. + returned: on success + type: dict + sample: + { + "default_client_scopes": [ + "profile", + "role" + ], + "optional_client_scopes": [] + } +""" + +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_client_scope_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_client_scopes=dict(type="list", elements="str"), + optional_client_scopes=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_client_scopes", "optional_client_scopes"], + ] + ), + 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 client_scopes_to_add(existing, proposed): + to_add = [] + existing_client_scope_ids = extract_field(existing, "id") + for client_scope in proposed: + if client_scope["id"] not in existing_client_scope_ids: + to_add.append(client_scope) + return to_add + + +def client_scopes_to_delete(existing, proposed): + to_delete = [] + proposed_client_scope_ids = extract_field(proposed, "id") + for client_scope in existing: + if client_scope["id"] not in proposed_client_scope_ids: + to_delete.append(client_scope) + 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_client_scopes"), list): + scopes_copy["default_client_scopes"] = sorted(scopes_copy["default_client_scopes"]) + if isinstance(scopes_copy.get("optional_client_scopes"), list): + scopes_copy["optional_client_scopes"] = sorted(scopes_copy["optional_client_scopes"]) + return scopes_copy + + +def main(): + """ + Module keycloak_client_scope_type + + :return: + """ + + module = keycloak_client_scope_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_client_scopes = module.params.get("default_client_scopes") + optional_client_scopes = module.params.get("optional_client_scopes") + + result = dict(changed=False, msg="", proposed={}, existing={}, end_state={}) + + all_client_scopes = kc.get_client_scopes(realm) + default_client_scopes_real = [] + optional_client_scopes_real = [] + + for client_scope in all_client_scopes: + if default_client_scopes is not None and client_scope["name"] in default_client_scopes: + default_client_scopes_real.append(client_scope) + if optional_client_scopes is not None and client_scope["name"] in optional_client_scopes: + optional_client_scopes_real.append(client_scope) + + if default_client_scopes is not None and len(default_client_scopes_real) != len(default_client_scopes): + module.fail_json(msg="At least one of the default_client_scopes does not exist!") + + if optional_client_scopes is not None and len(optional_client_scopes_real) != len(optional_client_scopes): + module.fail_json(msg="At least one of the optional_client_scopes does not exist!") + + result["proposed"].update( + { + "default_client_scopes": "no-change" if default_client_scopes is None else default_client_scopes, + "optional_client_scopes": "no-change" if optional_client_scopes is None else optional_client_scopes, + } + ) + + default_client_scopes_existing = kc.get_default_client_scopes(realm, client_id) + optional_client_scopes_existing = kc.get_optional_client_scopes(realm, client_id) + + result["existing"].update( + { + "default_client_scopes": extract_field(default_client_scopes_existing), + "optional_client_scopes": extract_field(optional_client_scopes_existing), + } + ) + + if module._diff: + result["diff"] = dict(before=normalize_scopes(result["existing"]), after=normalize_scopes(result["proposed"])) + + default_client_scopes_add = client_scopes_to_add(default_client_scopes_existing, default_client_scopes_real) + optional_client_scopes_add = client_scopes_to_add(optional_client_scopes_existing, optional_client_scopes_real) + + default_client_scopes_delete = client_scopes_to_delete(default_client_scopes_existing, default_client_scopes_real) + optional_client_scopes_delete = client_scopes_to_delete(optional_client_scopes_existing, optional_client_scopes_real) + + result["changed"] = any( + len(x) > 0 + for x in [ + default_client_scopes_add, + optional_client_scopes_add, + default_client_scopes_delete, + optional_client_scopes_delete, + ] + ) + + if module.check_mode: + module.exit_json(**result) + + # first delete so client_scopes can change type + for client_scope in default_client_scopes_delete: + kc.delete_default_client_scope(client_scope["id"], realm, client_id) + for client_scope in optional_client_scopes_delete: + kc.delete_optional_client_scope(client_scope["id"], realm, client_id) + + for client_scope in default_client_scopes_add: + kc.add_default_client_scope(client_scope["id"], realm, client_id) + for client_scope in optional_client_scopes_add: + kc.add_optional_client_scope(client_scope["id"], realm, client_id) + + result["end_state"].update( + { + "default_client_scopes": extract_field(kc.get_default_client_scopes(realm, client_id)), + "optional_client_scopes": extract_field(kc.get_optional_client_scopes(realm, client_id)), + } + ) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clientsecret_info.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clientsecret_info.py new file mode 100644 index 00000000..96194335 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clientsecret_info.py @@ -0,0 +1,170 @@ + +# 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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clientsecret_regenerate.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clientsecret_regenerate.py new file mode 100644 index 00000000..02ca51bb --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clientsecret_regenerate.py @@ -0,0 +1,180 @@ + +# 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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clienttemplate.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clienttemplate.py new file mode 100644 index 00000000..2dc67710 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_clienttemplate.py @@ -0,0 +1,474 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_component.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_component.py new file mode 100644 index 00000000..5e4d7e93 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_component.py @@ -0,0 +1,330 @@ + +# 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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_component_info.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_component_info.py new file mode 100644 index 00000000..fcde1820 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_component_info.py @@ -0,0 +1,173 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_group.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_group.py new file mode 100644 index 00000000..3cf59200 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_group.py @@ -0,0 +1,495 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_identity_provider.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_identity_provider.py new file mode 100644 index 00000000..99a5224f --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_identity_provider.py @@ -0,0 +1,795 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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 + + hide_on_login: + description: + - If hidden, login with this provider is possible only if requested explicitly, for example using the C(kc_idp_hint) + parameter. + aliases: + - hideOnLogin + - hide_on_login_page + 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: + 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"]), + hide_on_login=dict(type="bool", aliases=["hideOnLogin", "hide_on_login_page"]), + 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") + + if config is not None: + for legacy_key in ("hide_on_login_page", "hideOnLoginPage"): + if legacy_key in config: + module.deprecate( + f"Passing '{legacy_key}' inside 'config' is deprecated. " + "Use the top-level 'hide_on_login' parameter instead.", + version="4.0.0", + collection_name="middleware_automation.keycloak", + ) + if module.params.get("hide_on_login") is None: + module.params["hide_on_login"] = config.pop(legacy_key) + else: + config.pop(legacy_key) + + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_organization.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_organization.py new file mode 100644 index 00000000..067d9e14 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_organization.py @@ -0,0 +1,416 @@ + +# Copyright (c) 2025, Chris Brown +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +module: keycloak_organization + +short_description: Allows administration of Keycloak organizations using Keycloak API + +version_added: "3.1.0" + +description: + - This module allows you to add, remove or modify Keycloak organizations 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. + - Organizations are available in Keycloak 26 and later. The realm must have organizations enabled + (C(organizations_enabled=true) in the M(middleware_automation.keycloak.keycloak_realm) module) + before this module can be used. + - When updating an organization, where possible provide the organization ID to the module. This + removes a lookup to the API to translate the name into the organization ID. +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + version_added: "3.1.0" + +options: + state: + description: + - State of the organization. + - On V(present), the organization is created if it does not yet exist, or updated with the + parameters you provide. + - On V(absent), the organization is removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + type: str + description: + - The Keycloak realm under which this organization resides. + default: 'master' + + id: + type: str + description: + - The unique identifier for this organization. + - This parameter is not required for updating or deleting an organization but providing it + reduces the number of API calls required. + + name: + type: str + description: + - Name of the organization. + - This parameter is required when creating a new organization. + + alias: + type: str + description: + - URL-friendly alias for the organization. + - If not provided during creation, defaults to the organization name if URL-safe. + - The alias is immutable once set and cannot be changed on update. + + enabled: + type: bool + description: + - Whether the organization is enabled. + - Disabled organizations block member authentication. + + description: + type: str + description: + - Description of the organization. + + domains: + type: list + elements: dict + description: + - List of internet domains associated with the organization. + - A domain name can only belong to one organization within a realm. + suboptions: + name: + type: str + required: true + description: + - The domain name (e.g. V(example.com)). + verified: + type: bool + default: false + description: + - Whether the domain has been verified. + + attributes: + type: dict + description: + - A dict of key/value pairs to set as custom attributes for the organization. + - Values may be single values (for example a string) or a list of strings. + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Chris Brown (@chribro) +""" + +EXAMPLES = r""" +- name: Create a Keycloak organization + middleware_automation.keycloak.keycloak_organization: + name: my-org + description: My Organization + enabled: true + realm: MyRealm + 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 organization with domains + middleware_automation.keycloak.keycloak_organization: + name: acme-corp + description: Acme Corporation + enabled: true + domains: + - name: acme.com + - name: acme.org + verified: true + realm: MyRealm + 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: Update a Keycloak organization + middleware_automation.keycloak.keycloak_organization: + name: my-org + description: Updated description + realm: MyRealm + 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: Delete a Keycloak organization + middleware_automation.keycloak.keycloak_organization: + name: my-org + state: absent + realm: MyRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost +""" + +RETURN = r""" +msg: + description: Message as to what action was taken. + returned: always + type: str + +end_state: + description: Representation of the organization after module execution. + returned: on success + type: complex + contains: + id: + description: GUID that identifies the organization. + type: str + returned: always + sample: 23f38145-3195-462c-97e7-97041ccea73e + name: + description: Name of the organization. + type: str + returned: always + sample: my-org + alias: + description: URL-friendly alias of the organization. + type: str + returned: always + sample: my-org + enabled: + description: Whether the organization is enabled. + type: bool + returned: always + sample: true + description: + description: Description of the organization. + type: str + returned: always + sample: My Organization + domains: + description: Domains associated with the organization. + type: list + returned: always + sample: [{"name": "example.com", "verified": false}] + attributes: + description: Attributes applied to this organization. + type: dict + returned: always + sample: + attr1: ["val1", "val2"] +""" + +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_org(org): + """Normalise an organization representation for comparison. + Sorts domains by name to ensure order-independent comparison. + """ + if org and "domains" in org and org["domains"]: + org["domains"] = sorted(org["domains"], key=lambda d: d.get("name", "")) + return org + + +def main(): + 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"), + alias=dict(type="str"), + enabled=dict(type="bool"), + description=dict(type="str"), + domains=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + verified=dict(type="bool", default=False), + ), + ), + attributes=dict(type="dict"), + ) + + 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={}, end_state={}) + + 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") + oid = module.params.get("id") + name = module.params.get("name") + attributes = module.params.get("attributes") + + 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 + + # Parameters that map to the organization representation (excluding module-level params) + org_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 + ] + + if oid is None: + before_org = kc.get_organization_by_name(name, realm=realm) + else: + before_org = kc.get_organization_by_id(oid, realm=realm) + + if before_org is None: + before_org = {} + + normalise_org(before_org) + + changeset = {} + for param in org_params: + new_param_value = module.params.get(param) + if param == "alias" and before_org: + if new_param_value != before_org.get("alias"): + module.warn( + f"Organization alias is immutable and cannot be changed from '{before_org.get('alias')}' " + f"to '{new_param_value}'. The alias parameter will be ignored for this update." + ) + continue + old_value = before_org.get(camel(param)) + if new_param_value != old_value: + changeset[camel(param)] = new_param_value + + desired_org = before_org.copy() + desired_org.update(changeset) + + normalise_org(desired_org) + + if not before_org: + if state == "absent": + if module._diff: + result["diff"] = dict(before="", after="") + result["changed"] = False + result["end_state"] = {} + result["msg"] = "Organization does not exist; doing nothing." + module.exit_json(**result) + + result["changed"] = True + + if name is None: + module.fail_json(msg="name must be specified when creating a new organization") + + if module._diff: + result["diff"] = dict(before="", after=desired_org) + + if module.check_mode: + module.exit_json(**result) + + kc.create_organization(desired_org, realm=realm) + + after_org = kc.get_organization_by_name(name, realm=realm) + + result["end_state"] = normalise_org(after_org) if after_org else {} + result["msg"] = f"Organization {name} has been created with ID {after_org['id']}" + module.exit_json(**result) + + else: + if state == "present": + if desired_org == before_org: + result["changed"] = False + result["end_state"] = desired_org + result["msg"] = f"No changes required to organization {before_org['name']}." + module.exit_json(**result) + + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=before_org, after=desired_org) + + if module.check_mode: + module.exit_json(**result) + + kc.update_organization(desired_org, realm=realm) + + after_org = kc.get_organization_by_id(desired_org["id"], realm=realm) + + result["end_state"] = normalise_org(after_org) if after_org else {} + result["msg"] = f"Organization {desired_org['id']} has been updated" + module.exit_json(**result) + + else: + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=before_org, after="") + + if module.check_mode: + module.exit_json(**result) + + oid = before_org["id"] + kc.delete_organization(org_id=oid, realm=realm) + + result["end_state"] = {} + result["msg"] = f"Organization {before_org['name']} has been deleted" + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_organization_info.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_organization_info.py new file mode 100644 index 00000000..529fe123 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_organization_info.py @@ -0,0 +1,142 @@ + +# Copyright (c) 2025, Chris Brown +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +module: keycloak_organization_info + +short_description: Retrieve organization info in Keycloak + +version_added: "3.1.0" + +description: + - This module retrieves information on organizations from Keycloak. + - Organizations are available in Keycloak 26 and later. The realm must have organizations enabled + before this module can be used. +attributes: + action_group: + version_added: "3.1.0" + +options: + realm: + description: + - The name of the realm. + required: true + type: str + name: + description: + - Name of the organization to search for. + - If not provided, all organizations in the realm are returned. + type: str + exact: + description: + - Whether the search should be an exact match. + - Only relevant when O(name) is provided. + type: bool + default: true + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.attributes.info_module + +author: + - Chris Brown (@chribro) +""" + +EXAMPLES = r""" +- name: Retrieve a specific organization by name + middleware_automation.keycloak.keycloak_organization_info: + auth_keycloak_url: https://auth.example.com + auth_username: admin + auth_password: password + auth_realm: master + realm: MyRealm + name: my-org + +- name: List all organizations in a realm + middleware_automation.keycloak.keycloak_organization_info: + auth_keycloak_url: https://auth.example.com + auth_username: admin + auth_password: password + auth_realm: master + realm: MyRealm + +- name: Search organizations by partial name + middleware_automation.keycloak.keycloak_organization_info: + auth_keycloak_url: https://auth.example.com + auth_username: admin + auth_password: password + auth_realm: master + realm: MyRealm + name: acme + exact: false +""" + +RETURN = r""" +organizations: + description: JSON representation of organizations. + 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(): + argument_spec = keycloak_argument_spec() + + meta_args = dict( + name=dict(type="str"), + realm=dict(type="str", required=True), + exact=dict(type="bool", default=True), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + result = dict(changed=False, organizations=[]) + + 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") + name = module.params.get("name") + exact = module.params.get("exact") + + filters = [] + if name: + filters.append(f"search={quote(name, safe='')}") + if exact: + filters.append("exact=true") + + filter_str = "&".join(filters) if filters else None + + result["organizations"] = kc.get_organizations(search_filter=filter_str, realm=realm) + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm.py new file mode 100644 index 00000000..0418ccfe --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm.py @@ -0,0 +1,1160 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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 + action_group: + # Originally added in community.general 10.2.0 + version_added: "3.0.0" + +options: + 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_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 + prune_undefined_realm_attributes: + description: + - If True all realm attributes which are not defined in the attributes dict will be deleted + aliases: + - pruneUndefinedRealmAttributes + type: bool + default: true + 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.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Christophe Gilles (@kris2kris) +""" + +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_realm: master + auth_username: USERNAME + auth_password: PASSWORD + 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_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: unique_realm_name + state: absent +""" + +RETURN = r""" +msg: + 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: {"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" + } + } + +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" + } + } +""" + +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, +) +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak._keycloak_utils import ( + merge_settings_without_absent_nulls, +) + + +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. + + :param realmrep: the realmrep dict to be sanitized + :return: normalised realmrep dict + """ + # Avoid the dict passed in to be modified + realmrep = realmrep.copy() + + if "enabledEventTypes" in realmrep: + realmrep["enabledEventTypes"] = list(sorted(realmrep["enabledEventTypes"])) + + if "otpSupportedApplications" in realmrep: + realmrep["otpSupportedApplications"] = list(sorted(realmrep["otpSupportedApplications"])) + + if "supportedLocales" in realmrep: + realmrep["supportedLocales"] = list(sorted(realmrep["supportedLocales"])) + + return realmrep + + +def sanitize_cr(realmrep): + """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"] = "********" + return normalise_cr(result) + + +def main(): + """ + Module execution + + :return: + """ + 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_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"]), + prune_undefined_realm_attributes=dict(type="bool", aliases=["pruneUndefinedRealmAttributes"], default=True), + 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", "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") + prune_undefined_realm_attributes = module.params.get("prune_undefined_realm_attributes") + + # convert module parameters to realm representation parameters (if they belong in there) + params_to_ignore = list(keycloak_argument_spec().keys()) + ["state", "prune_undefined_realm_attributes"] + + # 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] + + # See whether the realm already exists in Keycloak + before_realm = kc.get_realm_by_id(realm=realm) + + if before_realm is None: + before_realm = {} + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for realm_param in realm_params: + new_param_value = module.params.get(realm_param) + if camel(realm_param) == "attributes" and not prune_undefined_realm_attributes: + new_param_value = merge_settings_without_absent_nulls(before_realm.get("attributes"), new_param_value) + + changeset[camel(realm_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_realm = before_realm.copy() + desired_realm.update(changeset) + + result["proposed"] = sanitize_cr(changeset) + before_realm_sanitized = sanitize_cr(before_realm) + result["existing"] = before_realm_sanitized + + # Cater for when it doesn't exist (an empty dict) + if not before_realm: + 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." + module.exit_json(**result) + + # Process a creation + result["changed"] = True + + if module._diff: + 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["realm"]) + + result["end_state"] = sanitize_cr(after_realm) + + result["msg"] = f"Realm {desired_realm['realm']} has been created." + module.exit_json(**result) + + else: + if state == "present": + # Process an update + + # doing an update + 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 + + module.exit_json(**result) + + # do the update + kc.update_realm(desired_realm, realm=realm) + + after_realm = kc.get_realm_by_id(realm=realm) + + if before_realm == after_realm: + result["changed"] = False + + result["end_state"] = sanitize_cr(after_realm) + + if module._diff: + result["diff"] = dict(before=before_realm_sanitized, after=sanitize_cr(after_realm)) + + 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 + + if module._diff: + result["diff"] = dict(before=before_realm_sanitized, after="") + + if module.check_mode: + module.exit_json(**result) + + # delete it + kc.delete_realm(realm=realm) + + result["proposed"] = {} + result["end_state"] = {} + + result["msg"] = f"Realm {before_realm['realm']} has been deleted." + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_info.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_info.py new file mode 100644 index 00000000..13ceba09 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_info.py @@ -0,0 +1,133 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_key.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_key.py new file mode 100644 index 00000000..c06a24fb --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_key.py @@ -0,0 +1,1062 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_keys_metadata_info.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_keys_metadata_info.py new file mode 100644 index 00000000..cabab29d --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_keys_metadata_info.py @@ -0,0 +1,139 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_localization.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_localization.py new file mode 100644 index 00000000..b7b771ae --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_localization.py @@ -0,0 +1,401 @@ +# !/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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_rolemapping.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_rolemapping.py new file mode 100644 index 00000000..fc03e277 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_rolemapping.py @@ -0,0 +1,394 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_users_info.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_users_info.py new file mode 100644 index 00000000..db081a3b --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_realm_users_info.py @@ -0,0 +1,123 @@ +#!/usr/bin/python + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +module: keycloak_realm_users_info + +short_description: Retrieve users from a Keycloak realm using the Keycloak API + +# Originally added in community.general 13.1.0 +version_added: "3.1.0" + +description: + - This module retrieves all users from a specified Keycloak realm using the Keycloak REST API. + - Access to the REST API is performed via OpenID Connect. The user and client used must have the necessary permissions. + - Authentication can be performed either with username/password or with a token. + - The names of module options are snake_case 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). + +options: + realm: + type: str + description: + - The Keycloak realm from which users should be retrieved. + 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: + - Felix Grzelka (@felix-grzelka) +""" + +EXAMPLES = r""" +- name: List all users in the "MyCustomRealm" realm using username/password authentication + middleware_automation.keycloak.keycloak_realm_users_info: + 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: List all users in the "MyCustomRealm" realm using a token + middleware_automation.keycloak.keycloak_realm_users_info: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com + token: TOKEN + delegate_to: localhost +""" + +RETURN = r""" +users: + description: List of users in the specified realm. + returned: always + type: list + elements: dict + sample: + - id: "1234-5678-90" + username: "user1" + email: "user1@example.com" + - id: "2345-6789-01" + username: "user2" + email: "user2@example.com" +""" + +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="", users="") + + # 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") + + result["users"] = kc.get_realm_users(realm=realm) + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_role.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_role.py new file mode 100644 index 00000000..7630da97 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_role.py @@ -0,0 +1,452 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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 + action_group: + # Originally added in community.general 10.2.0 + version_added: "3.0.0" + +options: + 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: + 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: + - 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: + - Create the composite if present, remove it if absent. + type: str + choices: + - present + - absent + default: present + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Laurent Paumier (@laurpaum) +""" + +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_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Create a Keycloak realm role, authentication with token + 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 + token: TOKEN + delegate_to: localhost + +- name: Create a Keycloak client role + middleware_automation.keycloak.keycloak_role: + name: my-new-kc-role + realm: MyCustomRealm + client_id: MyClient + 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: Delete a Keycloak role + middleware_automation.keycloak.keycloak_role: + name: my-role-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: 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_realm: master + auth_username: USERNAME + auth_password: PASSWORD + name: my-new-role + attributes: + attrib1: value1 + attrib2: value2 + attrib3: + - with + - numerous + - individual + - list + - items + delegate_to: localhost +""" + +RETURN = r""" +msg: + 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"} + +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" + } + +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" + } +""" + +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(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + composites_spec = dict( + 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), + ) + + 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") + 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 + # 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 + + # 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 + ] + + # See if it already exists in Keycloak + if clientid is None: + before_role = kc.get_realm_role(name, realm) + else: + before_role = kc.get_client_role(name, clientid, realm) + + if before_role is None: + before_role = {} + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for param in role_params: + new_param_value = module.params.get(param) + old_value = before_role[param] if param in before_role else None + if new_param_value != old_value: + changeset[camel(param)] = copy.deepcopy(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_role = copy.deepcopy(before_role) + desired_role.update(changeset) + + result["proposed"] = changeset + result["existing"] = before_role + + # Cater for when it doesn't exist (an empty dict) + if not before_role: + 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." + 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 role") + + if module._diff: + result["diff"] = dict(before="", after=desired_role) + + if module.check_mode: + module.exit_json(**result) + + # create it + if clientid is None: + kc.create_realm_role(desired_role, realm) + after_role = kc.get_realm_role(name, realm) + else: + 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) + + result["end_state"] = after_role + + result["msg"] = f"Role {name} has been created" + module.exit_json(**result) + + else: + 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"] = [] + 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"] + else: + 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") + # 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"] = f"No changes required to role {name}." + module.exit_json(**result) + + # doing an update + result["changed"] = True + + if module._diff: + result["diff"] = dict(before=before_role, after=desired_role) + + if module.check_mode: + module.exit_json(**result) + + # do the update + if clientid is None: + kc.update_realm_role(desired_role, realm) + after_role = kc.get_realm_role(name, realm) + 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) + + result["end_state"] = after_role + + result["msg"] = f"Role {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_role, after="") + + if module.check_mode: + module.exit_json(**result) + + # delete it + if clientid is None: + kc.delete_realm_role(name, realm) + else: + kc.delete_client_role(name, clientid, realm) + + result["end_state"] = {} + + result["msg"] = f"Role {name} has been deleted" + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user.py new file mode 100644 index 00000000..3557e990 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user.py @@ -0,0 +1,599 @@ + +# 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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + 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: + - 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 + 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: + - Set or reset a user's required actions. + 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 + 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 + - middleware_automation.keycloak.attributes +attributes: + check_mode: + support: full + 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. +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", 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", 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), + email_verified_behavior=dict(type="str", choices=["compatibility", "no_defaults"], default="compatibility"), + ) + 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") + + # 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: + 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) + + 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", + ] + + if state == "absent": + if not before_user: + # Do nothing and exit + result["msg"] = "User does not exist, doing nothing." + else: + # Delete user + 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: + 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: + # 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 + + result["msg"] = f"User {desired_user['username']} created" + result["user_created"] = True + changed = True + else: + # 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, user_compare_excludes, empty_list_result=False) + ): # If the new user introduces a change to the existing user + # Update the user + 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 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) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_execute_actions_email.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_execute_actions_email.py new file mode 100644 index 00000000..88cb386b --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_execute_actions_email.py @@ -0,0 +1,207 @@ + +# 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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_federation.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_federation.py new file mode 100644 index 00000000..632fd605 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_federation.py @@ -0,0 +1,1159 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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 + action_group: + # Originally added in community.general 10.2.0 + version_added: "3.0.0" + +options: + 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 + + 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 + + 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 + + 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: + 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: + - 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 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 + 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 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 + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.actiongroup_keycloak + - middleware_automation.keycloak.attributes + +author: + - Laurent Paumier (@laurpaum) +""" + +EXAMPLES = r""" +- name: Create LDAP user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-ldap + state: present + 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 + mappers: + - name: "full name" + providerId: "full-name-ldap-mapper" + providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" + config: + ldap.full.name.attribute: cn + read.only: true + write.only: false + +- name: Create Kerberos user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-kerberos + state: present + provider_id: kerberos + provider_type: org.keycloak.storage.UserStorageProvider + config: + priority: 0 + enabled: true + cachePolicy: DEFAULT + kerberosRealm: EXAMPLE.COM + serverPrincipal: HTTP/host.example.com@EXAMPLE.COM + keyTab: keytab + allowPasswordAuthentication: false + updateProfileFirstLogin: false + +- name: Create sssd user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-sssd + state: present + provider_id: sssd + provider_type: org.keycloak.storage.UserStorageProvider + config: + priority: 0 + enabled: true + cachePolicy: DEFAULT + +- name: Delete user federation + middleware_automation.keycloak.keycloak_user_federation: + 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 = 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." + +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" + } + +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" + } + +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" + } +""" + +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: + # 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"] = [""] + + # 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) + + +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: 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 + + +def main(): + """ + Module execution + + :return: + """ + 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"), + ) + + 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"), + ) + + 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), + ) + + 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") + 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 + } + + if mappers is not None: + for mapper in mappers: + 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 + ] + + # 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) + if len(found) > 1: + 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"] + else: + before_comp = kc.get_component(cid, realm) + + if before_comp is None: + before_comp = {} + + # 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 "" + ) + + normalize_kc_comp(before_comp) + + # Build a proposed changeset from parameters given to this module + changeset = {} + + 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": + 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=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 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, + ) + 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"] + ] + if len(found) > 1: + module.fail_json(msg=f"Found multiple mappers with name `{change['name']}`. Cannot continue.") + if len(found) == 1: + old_mapper = found[0] + else: + old_mapper = {} + 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 "") + + # 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] + ) + + # 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) + + # Cater for when it doesn't exist (an empty dict) + if not before_comp: + 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." + module.exit_json(**result) + + # Process a creation + result["changed"] = True + + if module.check_mode: + if module._diff: + result["diff"] = dict(before="", after=sanitize(desired_comp)) + module.exit_json(**result) + + # create it + desired_mappers = desired_comp.pop("mappers", []) + after_comp = kc.create_component(desired_comp, realm) + 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"] + ] + if len(found) > 1: + module.fail_json(msg=f"Found multiple mappers with name `{desired_mapper['name']}`. Cannot continue.") + if len(found) == 1: + old_mapper = found[0] + else: + old_mapper = {} + + new_mapper = old_mapper.copy() + new_mapper.update(desired_mapper) + + 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 + updated_mappers.append(kc.create_component(new_mapper, realm)) + + 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 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) + normalize_kc_comp(after_comp) + if module._diff: + 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": + # 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) + # no changes + if desired_copy == before_copy: + 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 + + if module._diff: + 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", []) + kc.update_component(desired_comp, realm) + + for before_mapper in before_comp.get("mappers", []): + # remove unwanted existing mappers that will not be updated + 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", []): + continue + if mapper.get("id") is not None: + kc.update_component(mapper, realm) + else: + 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 "" + ) + normalize_kc_comp(after_comp) + after_comp_sanitized = sanitize(after_comp) + before_comp_sanitized = sanitize(before_comp) + 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"] = f"User federation {cid} 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_comp), after="") + + if module.check_mode: + module.exit_json(**result) + + # delete it + kc.delete_component(cid, realm) + + result["end_state"] = {} + + result["msg"] = f"User federation {cid} has been deleted" + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_rolemapping.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_rolemapping.py new file mode 100644 index 00000000..b5a73773 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_user_rolemapping.py @@ -0,0 +1,444 @@ + +# 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 (absolute_import, division, print_function) +from __future__ import annotations +__metaclass__ = type + +DOCUMENTATION = r""" +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: + - 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: + # Originally added in community.general 10.2.0 + 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/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_userprofile.py b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_userprofile.py new file mode 100644 index 00000000..c9bcbe46 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/plugins/modules/keycloak_userprofile.py @@ -0,0 +1,826 @@ + +# 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) +from __future__ import annotations +__metaclass__ = type + +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. +# Originally added in community.general 9.4.0 +version_added: "3.0.0" + +attributes: + check_mode: + support: full + diff_mode: + support: full + action_group: + # Originally added in community.general 10.2.0 + 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() diff --git a/ansible_collections/middleware_automation/keycloak/requirements.txt b/ansible_collections/middleware_automation/keycloak/requirements.txt new file mode 100644 index 00000000..5de7845b --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/requirements.txt @@ -0,0 +1,7 @@ +################################################# +# python dependencies required to be installed +# on the controller host with: +# pip install -r requirements.txt +# +netaddr +lxml # for middleware_automation.common.maven_artifact \ No newline at end of file diff --git a/ansible_collections/middleware_automation/keycloak/requirements.yml b/ansible_collections/middleware_automation/keycloak/requirements.yml new file mode 100644 index 00000000..32087372 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/requirements.yml @@ -0,0 +1,15 @@ +--- +collections: + - name: middleware_automation.common + version: ">=1.2.4" + - name: middleware_automation.infinispan + - name: community.general + - name: ansible.posix + - name: community.docker + version: ">=3.8.0" + - name: containers.podman + version: ">=1.8.1" + +roles: + - name: elan.simple_nginx_reverse_proxy + version: "0.2.1" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/README.md b/ansible_collections/middleware_automation/keycloak/roles/keycloak/README.md new file mode 100644 index 00000000..3d3b5609 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/README.md @@ -0,0 +1,215 @@ +keycloak +======== + +Install [keycloak](https://keycloak.org/) or [Red Hat Single Sign-On](https://access.redhat.com/products/red-hat-single-sign-on) server configurations. + + +Requirements +------------ + +This role requires the `python3-netaddr` library installed on the controller node. + +* to install via yum/dnf: `dnf install python3-netaddr` +* to install via apt: `apt install python3-netaddr` +* or via pip: `pip install netaddr==0.8.0` +* or via the collection: `pip install -r requirements.txt` + + +Dependencies +------------ + +The roles depends on: + +* [middleware_automation.common](https://github.com/ansible-middleware/common) +* [ansible-posix](https://docs.ansible.com/ansible/latest/collections/ansible/posix/index.html) + +To install all the dependencies via galaxy: + + ansible-galaxy collection install -r requirements.yml + + +Versions +-------- + +| RH-SSO VERSION | Release Date | Keycloak Version | EAP Version | Notes | +|:---------------|:------------------|:-----------------|:------------|:----------------| +|`7.5.0 GA` |September 20, 2021 |`15.0.2` | `7.4.6` |[Release Notes](https://access.redhat.com/documentation/en-us/red_hat_single_sign-on/7.5/html/release_notes/index)| +|`7.6.0 GA` |June 30, 2022 |`18.0.3` | `7.4.6` |[Release Notes](https://access.redhat.com/documentation/en-us/red_hat_single_sign-on/7.6/html-single/release_notes/index)| + + +Patching +-------- + +When variable `keycloak_rhsso_apply_patches` is `true` (default: `false`), the role will automatically apply the latest cumulative patch for the selected base version. + +| RH-SSO VERSION | Release Date | RH-SSO LATEST CP | Notes | +|:---------------|:------------------|:-----------------|:----------------| +|`7.5.0 GA` |January 20, 2022 |`7.5.3 GA` |[Release Notes](https://access.redhat.com/articles/6646321)| +|`7.6.0 GA` |November 11, 2022 |`7.6.1 GA` |[Release Notes](https://access.redhat.com/articles/6982711)| + + +Role Defaults +------------- + +* Service configuration + +| Variable | Description | Default | +|:---------|:------------|:---------| +|`keycloak_ha_enabled`| Enable auto configuration for database backend, clustering and remote caches on infinispan | `False` | +|`keycloak_ha_discovery`| Discovery protocol for HA cluster members | `JDBC_PING` if `keycloak_db_enabled` else `TCPPING` | +|`keycloak_db_enabled`| Enable auto configuration for database backend | `True` if `keycloak_ha_enabled` is True, else `False` | +|`keycloak_remote_cache_enabled`| Enable remote cache store when in clustered ha configurations | `True` if `keycloak_ha_enabled` else `False` | +|`keycloak_admin_user`| Administration console user account | `admin` | +|`keycloak_bind_address`| Address for binding service ports | `0.0.0.0` | +|`keycloak_management_port_bind_address`| Address for binding management ports | `127.0.0.1` | +|`keycloak_host`| hostname | `localhost` | +|`keycloak_http_port`| HTTP port | `8080` | +|`keycloak_https_port`| TLS HTTP port | `8443` | +|`keycloak_ajp_port`| AJP port | `8009` | +|`keycloak_jgroups_port`| jgroups cluster tcp port | `7600` | +|`keycloak_management_http_port`| Management port | `9990` | +|`keycloak_management_https_port`| TLS management port | `9993` | +|`keycloak_prefer_ipv4`| Prefer IPv4 stack and addresses for port binding | `true` | +|`keycloak_config_standalone_xml`| filename for configuration | `keycloak.xml` | +|`keycloak_service_user`| posix account username | `keycloak` | +|`keycloak_service_group`| posix account group | `keycloak` | +|`keycloak_service_restart_always`| systemd restart always behavior activation | `False` | +|`keycloak_service_restart_on_failure`| systemd restart on-failure behavior activation | `False` | +|`keycloak_service_startlimitintervalsec`| systemd StartLimitIntervalSec | `300` | +|`keycloak_service_startlimitburst`| systemd StartLimitBurst | `5` | +|`keycloak_service_restartsec`| systemd RestartSec | `10s` | +|`keycloak_service_pidfile`| pid file path for service | `/run/keycloak/keycloak.pid` | +|`keycloak_features` | List of `name`/`status` pairs of features (also known as profiles on RH-SSO) to `enable` or `disable`, example: `[ { name: 'docker', status: 'enabled' } ]` | `[]` +|`keycloak_jvm_package`| RHEL java package runtime | `java-1.8.0-openjdk-headless` | +|`keycloak_java_home`| `JAVA_HOME` of installed JRE, leave empty for using RPM path at `keycloak_jvm_package` | `None` | +|`keycloak_java_opts`| Additional JVM options | `-Xms1024m -Xmx2048m` | + + +* Install options + +| Variable | Description | Default | +|:---------|:------------|:---------| +|`keycloak_offline_install` | perform an offline install | `false`| +|`keycloak_download_url`| Download URL for keycloak | `https://github.com/keycloak/keycloak/releases/download//`| +|`keycloak_version`| keycloak.org package version | `18.0.2` | +|`keycloak_dest`| Installation root path | `/opt/keycloak` | +|`keycloak_download_url` | Download URL for keycloak | `https://github.com/keycloak/keycloak/releases/download/{{ keycloak_version }}/{{ keycloak_archive }}` | +|`keycloak_configure_firewalld` | Ensure firewalld is running and configure keycloak ports | `false` | + + +* Miscellaneous configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_archive` | keycloak install archive filename | `keycloak-legacy-{{ keycloak_version }}.zip` | +|`keycloak_download_url_9x` | Download URL for keycloak (deprecated) | `https://downloads.jboss.org/keycloak/{{ keycloak_version }}/{{ keycloak_archive }}` | +|`keycloak_installdir` | Installation path | `{{ keycloak_dest }}/keycloak-{{ keycloak_version }}` | +|`keycloak_jboss_home` | Installation work directory | `{{ keycloak_rhsso_installdir }}` | +|`keycloak_jboss_port_offset` | Port offset for the JBoss socket binding | `0` | +|`keycloak_config_dir` | Path for configuration | `{{ keycloak_jboss_home }}/standalone/configuration` | +|`keycloak_config_path_to_standalone_xml` | Custom path for configuration | `{{ keycloak_jboss_home }}/standalone/configuration/{{ keycloak_config_standalone_xml }}` | +|`keycloak_config_override_template` | Path to custom template for standalone.xml configuration | `''` | +|`keycloak_auth_realm` | Name for rest authentication realm | `master` | +|`keycloak_auth_client` | Authentication client for configuration REST calls | `admin-cli` | +|`keycloak_force_install` | Remove pre-existing versions of service | `false` | +|`keycloak_url` | URL for configuration rest calls | `http://{{ keycloak_host }}:{{ keycloak_http_port + keycloak_jboss_port_offset }}` | +|`keycloak_management_url` | URL for management console rest calls | `http://{{ keycloak_host }}:{{ keycloak_management_http_port + keycloak_jboss_port_offset }}` | +|`keycloak_frontend_url_force` | Force backend requests to use the frontend URL | `false` | +|`keycloak_db_background_validation` | Enable background validation of database connection | `false` | +|`keycloak_db_background_validation_millis`| How frequenly the connection pool is validated in the background | `10000` if background validation enabled | +|`keycloak_db_background_validate_on_match` | Enable validate on match for database connections | `false` | +|`keycloak_frontend_url` | frontend URL for keycloak endpoint | `http://localhost:8080/auth/` | +|`keycloak_log_target`| Set the destination of the keycloak log folder link | `/var/log/keycloak` | + + +Role Variables +-------------- + +The following are a set of _required_ variables for the role: + +| Variable | Description | +|:---------|:------------| +|`keycloak_admin_password`| Password for the administration console user account (minimum 12 characters) | +|`keycloak_frontend_url` | frontend URL for keycloak endpoint | `http://localhost:8080/auth/` | + + +The following parameters are _required_ only when `keycloak_ha_enabled` is true: + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_modcluster_enabled`| Enable configuration for modcluster subsystem | `True` if `keycloak_ha_enabled` is True, else `False` | +|`keycloak_modcluster_url` | _deprecated_ Host for the modcluster reverse proxy | `localhost` | +|`keycloak_modcluster_port` | _deprecated_ Port for the modcluster reverse proxy | `6666` | +|`keycloak_modcluster_urls` | List of {host,port} dicts for the modcluster reverse proxies | `[ { localhost:6666 } ]` | +|`keycloak_jdbc_engine` | backend database engine when db is enabled: [ postgres, mariadb, sqlserver ] | `postgres` | +|`keycloak_infinispan_url` | URL for the infinispan remote-cache server | `localhost:11122` | +|`keycloak_infinispan_user` | username for connecting to infinispan | `supervisor` | +|`keycloak_infinispan_pass` | password for connecting to infinispan | `supervisor` | +|`keycloak_infinispan_sasl_mechanism`| Authentication type | `SCRAM-SHA-512` | +|`keycloak_infinispan_use_ssl`| Enable hotrod TLS communication | `False` | +|`keycloak_infinispan_trust_store_path`| Path to truststore with infinispan server certificate | `/etc/pki/java/cacerts` | +|`keycloak_infinispan_trust_store_password`| Password for opening truststore | `changeit` | + + +The following parameters are _required_ only when `keycloak_db_enabled` is true: + +| Variable | Description | Default | +|:---------|:------------|:---------| +|`keycloak_jdbc_url` | URL for the postgres backend database | `jdbc:postgresql://localhost:5432/keycloak` | +|`keycloak_jdbc_driver_version`| Version for the JDBC driver to download | `9.4.1212` | +|`keycloak_db_user` | username for connecting to postgres | `keycloak-user` | +|`keycloak_db_pass` | password for connecting to postgres | `keycloak-pass` | + + +The following variables are _optional_: + +| Variable | Description | +|:---------|:------------| +|`keycloak_db_valid_conn_sql` | Override the default database connection validation query sql | +|`keycloak_admin_url` | Override the default administration endpoint URL | +|`keycloak_jgroups_subnet`| Override the subnet match for jgroups cluster formation; if not defined, it will be inferred from local machine route configuration | + +Example Playbook +----------------- + +* The following is an example playbook that makes use of the role to install keycloak from remote: + +```yaml +--- +- hosts: ... + vars: + keycloak_admin_password: "remembertochangeme" + roles: + - middleware_automation.keycloak.keycloak +``` + + +* The following example playbook makes use of the role to install keycloak from the controller node: + +```yaml +--- +- hosts: ... + collections: + - middleware_automation.keycloak + tasks: + - name: Include keycloak role + include_role: + name: keycloak + vars: + keycloak_admin_password: "remembertochangeme" + keycloak_offline_install: true + # This should be the filename of keycloak archive on Ansible node: keycloak-16.1.0.zip +``` + +License +------- + +Apache License 2.0 + + +Author Information +------------------ + +* [Guido Grazioli](https://github.com/guidograzioli) +* [Romain Pelisse](https://github.com/rpelisse) +* [Pavan Kumar Motaparthi](https://github.com/motaparthipavankumar) diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/defaults/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/defaults/main.yml new file mode 100644 index 00000000..e3233c56 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/defaults/main.yml @@ -0,0 +1,128 @@ +--- +### Configuration specific to keycloak +keycloak_version: 18.0.2 +keycloak_archive: "keycloak-legacy-{{ keycloak_version }}.zip" +keycloak_download_url: "https://github.com/keycloak/keycloak/releases/download/{{ keycloak_version }}/{{ keycloak_archive }}" +keycloak_download_url_9x: "https://downloads.jboss.org/keycloak/{{ keycloak_version }}/{{ keycloak_archive }}" +keycloak_installdir: "{{ keycloak_dest }}/keycloak-{{ keycloak_version }}" +keycloak_offline_install: false + +# Authentication for Keycloak binary download (e.g. from internal artifact repository) +keycloak_binary_download_user: +keycloak_binary_download_pass: + +### Install location and service settings +keycloak_java_home: +keycloak_dest: /opt/keycloak +keycloak_jboss_home: "{{ keycloak_installdir }}" +keycloak_jboss_port_offset: 0 +keycloak_config_dir: "{{ keycloak_jboss_home }}/standalone/configuration" +keycloak_config_standalone_xml: "keycloak.xml" +keycloak_config_path_to_standalone_xml: "{{ keycloak_jboss_home }}/standalone/configuration/{{ keycloak_config_standalone_xml }}" +keycloak_config_override_template: '' +keycloak_config_path_to_properties: "{{ keycloak_jboss_home }}/standalone/configuration/profile.properties" +keycloak_service_runas: false +keycloak_service_user: keycloak +keycloak_service_group: keycloak +keycloak_service_pidfile: "/run/keycloak/keycloak.pid" +keycloak_service_name: keycloak +keycloak_service_desc: Keycloak +keycloak_service_start_delay: 10 +keycloak_service_start_retries: 25 +keycloak_service_restart_always: false +keycloak_service_restart_on_failure: false +keycloak_service_startlimitintervalsec: "300" +keycloak_service_startlimitburst: "5" +keycloak_service_restartsec: "10s" + +keycloak_configure_firewalld: false +keycloak_configure_iptables: false + +### administrator console password +keycloak_admin_password: '' + +### Common configuration settings +keycloak_bind_address: 0.0.0.0 +keycloak_host: localhost +keycloak_http_port: 8080 +keycloak_https_port: 8443 +keycloak_ajp_port: 8009 +keycloak_jgroups_port: 7600 +keycloak_jgroups_subnet: +keycloak_management_port_bind_address: 127.0.0.1 +keycloak_management_http_port: 9990 +keycloak_management_https_port: 9993 +keycloak_java_opts: "-Xms1024m -Xmx2048m" +keycloak_prefer_ipv4: true +keycloak_features: [] + +### Enable configuration for database backend, clustering and remote caches on infinispan +keycloak_ha_enabled: false +### Enable database configuration, must be enabled when HA is configured +keycloak_db_enabled: "{{ True if keycloak_ha_enabled else False }}" +### Discovery protocol for ha cluster members, valus [ 'JDBC_PING', 'TCPPING' ] +keycloak_ha_discovery: "{{ 'JDBC_PING' if keycloak_db_enabled else 'TCPPING' }}" +### Remote cache store on infinispan cluster +keycloak_remote_cache_enabled: "{{ True if keycloak_ha_enabled else False }}" + +### Keycloak administration console user +keycloak_admin_user: admin +keycloak_auth_realm: master +keycloak_auth_client: admin-cli + +keycloak_force_install: false + +### mod_cluster reverse proxy list +keycloak_modcluster_enabled: "{{ True if keycloak_ha_enabled else False }}" +keycloak_modcluster_url: localhost +keycloak_modcluster_port: 6666 +keycloak_modcluster_urls: + - host: "{{ keycloak_modcluster_url }}" + port: "{{ keycloak_modcluster_port }}" + +### keycloak frontend url +keycloak_frontend_url: http://localhost:8080/auth/ +keycloak_frontend_url_force: false +keycloak_admin_url: + +### infinispan remote caches access (hotrod) +keycloak_infinispan_user: supervisor +keycloak_infinispan_pass: supervisor +keycloak_infinispan_url: localhost +keycloak_infinispan_sasl_mechanism: SCRAM-SHA-512 +keycloak_infinispan_use_ssl: false +# if ssl is enabled, import ispn server certificate here +keycloak_infinispan_trust_store_path: /etc/pki/java/cacerts +keycloak_infinispan_trust_store_password: changeit + +### database backend engine: values [ 'postgres', 'mariadb', 'sqlserver' ] +keycloak_jdbc_engine: postgres +### database backend credentials +keycloak_db_user: keycloak-user +keycloak_db_pass: keycloak-pass +## connection validation +keycloak_db_background_validation: false +keycloak_db_background_validation_millis: "{{ 10000 if keycloak_db_background_validation else 0 }}" +keycloak_db_background_validate_on_match: false +keycloak_jdbc_url: "{{ keycloak_default_jdbc[keycloak_jdbc_engine].url }}" +keycloak_jdbc_driver_version: "{{ keycloak_default_jdbc[keycloak_jdbc_engine].version }}" +# override the variables above, following defaults show minimum supported versions +keycloak_default_jdbc: + postgres: + url: 'jdbc:postgresql://localhost:5432/keycloak' + version: 9.4.1212 + mariadb: + url: 'jdbc:mariadb://localhost:3306/keycloak' + version: 2.7.4 + sqlserver: + url: 'jdbc:sqlserver://localhost:1433;databaseName=keycloak;' + version: 12.2.0 +# role specific vars +keycloak_no_log: true + +### logging configuration +keycloak_log_target: /var/log/keycloak + +# locations +keycloak_url: "http://{{ keycloak_host }}:{{ keycloak_http_port + keycloak_jboss_port_offset }}" +keycloak_management_url: "http://{{ keycloak_host }}:{{ keycloak_management_http_port + keycloak_jboss_port_offset }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/handlers/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/handlers/main.yml new file mode 100644 index 00000000..4dd6ba2a --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: "Restart handler" + ansible.builtin.include_tasks: restart_keycloak.yml + listen: "restart keycloak" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/meta/argument_specs.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/meta/argument_specs.yml new file mode 100644 index 00000000..4f9829a8 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/meta/argument_specs.yml @@ -0,0 +1,393 @@ +argument_specs: + main: + options: + keycloak_version: + default: "18.0.2" + description: "keycloak.org package version" + type: "str" + keycloak_archive: + default: "keycloak-legacy-{{ keycloak_version }}.zip" + description: "keycloak install archive filename" + type: "str" + keycloak_configure_iptables: + default: false + description: "Ensure iptables is running and configure keycloak ports" + type: "bool" + keycloak_configure_firewalld: + default: false + description: "Ensure firewalld is running and configure keycloak ports" + type: "bool" + keycloak_download_url: + default: "https://github.com/keycloak/keycloak/releases/download/{{ keycloak_version }}/{{ keycloak_archive }}" + description: "Download URL for keycloak" + type: "str" + keycloak_download_url_9x: + default: "https://downloads.jboss.org/keycloak/{{ keycloak_version }}/{{ keycloak_archive }}" + description: "Download URL for keycloak (deprecated)" + type: "str" + keycloak_installdir: + default: "{{ keycloak_dest }}/keycloak-{{ keycloak_version }}" + description: "Installation path" + type: "str" + keycloak_offline_install: + default: false + description: "Perform an offline install" + type: "bool" + keycloak_jvm_package: + default: "java-1.8.0-openjdk-headless" + description: "RHEL java package runtime rpm" + type: "str" + keycloak_java_home: + description: "JAVA_HOME of installed JRE, leave empty for using specified keycloak_jvm_package RPM path" + type: "str" + keycloak_dest: + default: "/opt/keycloak" + description: "Root installation directory" + type: "str" + keycloak_jboss_home: + default: "{{ keycloak_installdir }}" + description: "Installation work directory" + type: "str" + keycloak_jboss_port_offset: + default: 0 + description: "Port offset for the JBoss socket binding" + type: "int" + keycloak_config_dir: + default: "{{ keycloak_jboss_home }}/standalone/configuration" + description: "Path for configuration" + type: "str" + keycloak_config_standalone_xml: + default: "keycloak.xml" + description: "Service configuration filename" + type: "str" + keycloak_config_path_to_standalone_xml: + default: "{{ keycloak_jboss_home }}/standalone/configuration/{{ keycloak_config_standalone_xml }}" + description: "Custom path for configuration" + type: "str" + keycloak_config_override_template: + default: "" + description: "Path to custom template for standalone.xml configuration" + type: "str" + keycloak_service_runas: + default: false + description: "Enable execution of service as `keycloak_service_user`" + type: "bool" + keycloak_service_user: + default: "keycloak" + description: "posix account username" + type: "str" + keycloak_service_group: + default: "keycloak" + description: "posix account group" + type: "str" + keycloak_service_pidfile: + default: "/run/keycloak/keycloak.pid" + description: "PID file path for service" + type: "str" + keycloak_features: + default: "[]" + description: > + List of `name`/`status` pairs of features (also known as profiles on RH-SSO) to `enable` or `disable`, + example: `[ { name: 'docker', status: 'enabled' } ]` + type: "list" + keycloak_bind_address: + default: "0.0.0.0" + description: "Address for binding service ports" + type: "str" + keycloak_management_port_bind_address: + default: "127.0.0.1" + description: "Address for binding the management ports" + type: "str" + keycloak_host: + default: "localhost" + description: "Hostname for service" + type: "str" + keycloak_http_port: + default: 8080 + description: "Listening HTTP port" + type: "int" + keycloak_https_port: + default: 8443 + description: "Listening HTTPS port" + type: "int" + keycloak_ajp_port: + default: 8009 + description: "Listening AJP port" + type: "int" + keycloak_jgroups_port: + default: 7600 + description: "jgroups cluster tcp port" + type: "int" + keycloak_management_http_port: + default: 9990 + description: "Management port (http)" + type: "int" + keycloak_management_https_port: + default: 9993 + description: "Management port (https)" + type: "int" + keycloak_java_opts: + default: "-Xms1024m -Xmx2048m" + description: "Additional JVM options" + type: "str" + keycloak_prefer_ipv4: + default: true + description: "Prefer IPv4 stack and addresses for port binding" + type: "bool" + keycloak_ha_enabled: + default: false + description: "Enable auto configuration for database backend, clustering and remote caches on infinispan" + type: "bool" + keycloak_ha_discovery: + default: "{{ 'JDBC_PING' if keycloak_db_enabled else 'TCPPING' }}" + description: "Discovery protocol for HA cluster members" + type: "str" + keycloak_db_enabled: + default: "{{ True if keycloak_ha_enabled else False }}" + description: "Enable auto configuration for database backend" + type: "bool" + keycloak_admin_user: + default: "admin" + description: "Administration console user account" + type: "str" + keycloak_auth_realm: + default: "master" + description: "Name for rest authentication realm" + type: "str" + keycloak_auth_client: + default: "admin-cli" + description: "Authentication client for configuration REST calls" + type: "str" + keycloak_force_install: + default: false + description: "Remove pre-existing versions of service" + type: "bool" + keycloak_modcluster_enabled: + default: "{{ True if keycloak_ha_enabled else False }}" + description: "Enable configuration for modcluster subsystem" + type: "bool" + keycloak_modcluster_url: + default: "localhost" + description: "URL for the modcluster reverse proxy" + type: "str" + keycloak_modcluster_port: + default: 6666 + description: "Port for the modcluster reverse proxy" + type: "int" + keycloak_modcluster_urls: + default: "[ { host: 'localhost', port: 6666 } ]" + description: "List of modproxy node URLs in the format { host, port } for the modcluster reverse proxy" + type: "list" + keycloak_frontend_url: + default: "http://localhost" + description: "Frontend URL for keycloak endpoints when a reverse proxy is used" + type: "str" + keycloak_frontend_url_force: + default: false + description: "Force backend requests to use the frontend URL" + type: "bool" + keycloak_infinispan_user: + default: "supervisor" + description: "Username for connecting to infinispan" + type: "str" + keycloak_infinispan_pass: + default: "supervisor" + description: "Password for connecting to infinispan" + type: "str" + keycloak_infinispan_url: + default: "localhost" + description: "URL for the infinispan remote-cache server" + type: "str" + keycloak_infinispan_sasl_mechanism: + default: "SCRAM-SHA-512" + description: "Authentication type to infinispan server" + type: "str" + keycloak_infinispan_use_ssl: + default: false + description: "Enable hotrod client TLS communication" + type: "bool" + keycloak_infinispan_trust_store_path: + default: "/etc/pki/java/cacerts" + description: "TODO document argument" + type: "str" + keycloak_infinispan_trust_store_password: + default: "changeit" + description: "Path to truststore containing infinispan server certificate" + type: "str" + keycloak_jdbc_engine: + default: "postgres" + description: "Backend database flavour when db is enabled: [ postgres, mariadb, sqlserver ]" + type: "str" + keycloak_db_user: + default: "keycloak-user" + description: "Username for connecting to database" + type: "str" + keycloak_db_pass: + default: "keycloak-pass" + description: "Password for connecting to database" + type: "str" + keycloak_jdbc_url: + default: "{{ keycloak_default_jdbc[keycloak_jdbc_engine].url }}" + description: "URL for connecting to backend database" + type: "str" + keycloak_jdbc_driver_version: + default: "{{ keycloak_default_jdbc[keycloak_jdbc_engine].version }}" + description: "Version for the JDBC driver to download" + type: "str" + keycloak_admin_password: + required: true + description: "Password for the administration console user account" + type: "str" + keycloak_url: + default: "http://{{ keycloak_host }}:{{ keycloak_http_port + keycloak_jboss_port_offset }}" + description: "URL for configuration rest calls" + type: "str" + keycloak_management_url: + default: "http://{{ keycloak_host }}:{{ keycloak_management_http_port + keycloak_jboss_port_offset }}" + description: "URL for management console rest calls" + type: "str" + keycloak_service_name: + default: "keycloak" + description: "systemd service name for keycloak" + type: "str" + keycloak_service_desc: + default: "Keycloak" + description: "systemd description for keycloak" + type: "str" + keycloak_service_start_delay: + default: "10" + description: "Expected delay in ms before the service is expected to be available after start." + type: "int" + keycloak_service_start_retries: + default: "25" + description: "How many time should Ansible retry to connect to the service after it was started, before failing." + type: "int" + keycloak_service_restart_always: + default: false + description: "systemd restart always behavior activation for keycloak" + type: "bool" + keycloak_service_restart_on_failure: + default: false + description: "systemd restart on-failure behavior activation for keycloak" + type: "bool" + keycloak_service_startlimitintervalsec: + default: 300 + description: "systemd StartLimitIntervalSec for keycloak" + type: "int" + keycloak_service_startlimitburst: + default: 5 + description: "systemd StartLimitBurst for keycloak" + type: "int" + keycloak_service_restartsec: + default: "5s" + description: "systemd RestartSec for keycloak" + type: "str" + keycloak_no_log: + default: true + type: "bool" + description: "Changes default behavior for no_log for debugging purpose, do not change for production system." + keycloak_remote_cache_enabled: + default: "{{ True if keycloak_ha_enabled else False }}" + description: "Enable remote cache store when in clustered ha configurations" + type: "bool" + keycloak_db_background_validation: + default: false + description: "Enable background validation of database connection" + type: "bool" + keycloak_db_background_validation_millis: + default: "{{ 10000 if keycloak_db_background_validation else 0 }}" + description: "How frequenly the connection pool is validated in the background" + type: 'int' + keycloak_db_background_validate_on_match: + default: false + description: "Enable validate on match for database connections" + type: "bool" + keycloak_db_valid_conn_sql: + required: false + description: "Override the default database connection validation query sql" + type: "str" + keycloak_admin_url: + required: false + description: "Override the default administration endpoint URL" + type: "str" + keycloak_jgroups_subnet: + required: false + description: > + Override the subnet match for jgroups cluster formation; if not defined, it will be inferred from local machine route configuration + type: "str" + keycloak_log_target: + default: '/var/log/keycloak' + type: "str" + description: "Set the destination of the keycloak log folder link" + keycloak_jdbc_download_url: + description: "Override the default Maven Central download URL for the JDBC driver" + type: "str" + keycloak_jdbc_download_user: + description: "Set a username with which to authenticate when downloading JDBC drivers from an alternative location" + type: "str" + keycloak_jdbc_download_pass: + description: > + Set a password with which to authenticate when downloading JDBC drivers from an alternative location (requires keycloak_jdbc_download_user) + type: "str" + keycloak_jdbc_download_validate_certs: + default: true + description: "Allow the option to ignore invalid certificates when downloading JDBC drivers from a custom URL" + type: "bool" + keycloak_binary_download_user: + description: "Username for HTTP Basic Auth when downloading Keycloak binary" + type: "str" + required: false + keycloak_binary_download_pass: + description: "Password for HTTP Basic Auth when downloading Keycloak binary" + type: "str" + required: false + downstream: + options: + sso_version: + default: "7.6.0" + description: "Red Hat Single Sign-On version" + type: "str" + sso_archive: + default: "rh-sso-{{ sso_version }}-server-dist.zip" + description: "Red Hat SSO install archive filename" + type: "str" + sso_dest: + default: "/opt/sso" + description: "Root installation directory" + type: "str" + sso_installdir: + default: "{{ sso_dest }}/rh-sso-{{ sso_version.split('.')[0] }}.{{ sso_version.split('.')[1] }}" + description: "Installation path for Red Hat SSO" + type: "str" + sso_apply_patches: + default: false + description: "Install Red Hat SSO most recent cumulative patch" + type: "bool" + sso_enable: + default: true + description: "Enable Red Hat Single Sign-on installation" + type: "str" + sso_offline_install: + default: false + description: "Perform an offline install" + type: "bool" + sso_service_name: + default: "sso" + description: "systemd service name for Single Sign-On" + type: "str" + sso_service_desc: + default: "Red Hat Single Sign-On" + description: "systemd description for Red Hat Single Sign-On" + type: "str" + sso_patch_version: + required: false + description: "Red Hat Single Sign-On latest cumulative patch version to apply; defaults to latest version when sso_apply_patches is True" + type: "str" + sso_patch_bundle: + default: "rh-sso-{{ sso_patch_version | default('[0-9]+[.][0-9]+[.][0-9]+') }}-patch.zip" + description: "Red Hat SSO patch archive filename" + type: "str" + sso_product_category: + default: "CORE.SERVICE.RHSSO" + description: "Unified Downloads API category for Single Sign-On" + type: "str" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/meta/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/meta/main.yml new file mode 100644 index 00000000..59499e6c --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/meta/main.yml @@ -0,0 +1,29 @@ +--- +collections: + - middleware_automation.common + - ansible.posix + +galaxy_info: + role_name: keycloak + namespace: middleware_automation + author: Romain Pelisse, Guido Grazioli, Pavan Kumar Motaparthi + description: Install keycloak or Red Hat Single Sign-On server configurations + company: Red Hat, Inc. + + license: Apache License 2.0 + + min_ansible_version: "2.16" + + platforms: + - name: EL + versions: + - "8" + + galaxy_tags: + - keycloak + - redhat + - rhel + - sso + - authentication + - identity + - security diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/debian.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/debian.yml new file mode 100644 index 00000000..acfadcc3 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/debian.yml @@ -0,0 +1,10 @@ +--- +- name: Include firewall config tasks + ansible.builtin.include_tasks: + file: iptables.yml + apply: + tags: + - firewall + when: keycloak_configure_iptables + tags: + - firewall diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/fastpackages.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/fastpackages.yml new file mode 100644 index 00000000..c880875b --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/fastpackages.yml @@ -0,0 +1,31 @@ +--- +- name: "Check if packages are already installed" # noqa command-instead-of-module this runs faster + ansible.builtin.command: "rpm -q {{ packages_list | join(' ') }}" + register: keycloak_rpm_info + changed_when: false + failed_when: false + when: ansible_facts.os_family == "RedHat" + +- name: "Add missing packages to the yum install list" + ansible.builtin.set_fact: + keycloak_packages_to_install: "{{ keycloak_packages_to_install | default([]) + keycloak_rpm_info.stdout_lines | \ + map('regex_findall', 'package (.+) is not installed$') | default([]) | flatten }}" + when: ansible_facts.os_family == "RedHat" + +- name: "Install packages: {{ keycloak_packages_to_install }}" + become: "{{ keycloak_fastpackages_require_privilege_escalation | default(true) }}" + ansible.builtin.dnf: + name: "{{ keycloak_packages_to_install }}" + state: present + when: + - keycloak_packages_to_install | default([]) | length > 0 + - ansible_facts.os_family == "RedHat" + +- name: "Install packages: {{ packages_list }}" + become: "{{ keycloak_fastpackages_require_privilege_escalation | default(true) }}" + ansible.builtin.package: + name: "{{ packages_list }}" + state: present + when: + - packages_list | default([]) | length > 0 + - ansible_facts.os_family == "Debian" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/firewalld.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/firewalld.yml new file mode 100644 index 00000000..c8afd85d --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/firewalld.yml @@ -0,0 +1,28 @@ +--- +- name: Ensure required package firewalld are installed + ansible.builtin.include_tasks: fastpackages.yml + vars: + packages_list: + - firewalld + +- name: Enable and start the firewalld service + become: "{{ keycloak_firewalld_require_privilege_escalation | default(true) }}" + ansible.builtin.systemd: + name: firewalld + enabled: true + state: started + +- name: "Configure firewall ports for {{ keycloak_config.service_name }}" + become: "{{ keycloak_firewalld_require_privilege_escalation | default(true) }}" + ansible.posix.firewalld: + port: "{{ item }}" + permanent: true + state: enabled + immediate: true + loop: + - "{{ keycloak_http_port }}/tcp" + - "{{ keycloak_https_port }}/tcp" + - "{{ keycloak_management_http_port }}/tcp" + - "{{ keycloak_management_https_port }}/tcp" + - "{{ keycloak_jgroups_port }}/tcp" + - "{{ keycloak_ajp_port }}/tcp" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/install.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/install.yml new file mode 100644 index 00000000..139d001c --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/install.yml @@ -0,0 +1,306 @@ +--- +- name: Validate parameters + ansible.builtin.assert: + that: + - keycloak_jboss_home is defined + - keycloak_service_user is defined + - keycloak_dest is defined + - keycloak_archive is defined + - keycloak_download_url is defined + - keycloak_version is defined + quiet: true + +- name: Check for an existing deployment + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.stat: + path: "{{ keycloak_jboss_home }}" + register: keycloak_existing_deploy + +- name: Stop and restart if existing deployment exists and install forced + when: keycloak_existing_deploy.stat.exists and keycloak_force_install | bool + block: + - name: "Stop the old service: {{ keycloak_config.service_name }}" + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + failed_when: false + ansible.builtin.systemd: + name: keycloak + state: stopped + - name: "Remove the old deployment: {{ keycloak_config.service_name }}" + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.file: + path: "{{ keycloak_jboss_home }}" + state: absent + +- name: Check for an existing deployment after possible forced removal + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.stat: + path: "{{ keycloak_jboss_home }}" + +- name: "Create service user/group for {{ keycloak_config.service_name }}" + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.user: + name: "{{ keycloak_service_user }}" + home: /opt/keycloak + system: true + create_home: false + +- name: "Create install location for {{ keycloak_config.service_name }}" + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.file: + dest: "{{ keycloak_dest }}" + state: directory + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0750' + +- name: Create pidfile folder + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.file: + dest: "{{ keycloak_service_pidfile | dirname }}" + state: directory + owner: "{{ keycloak_service_user if keycloak_service_runas else omit }}" + group: "{{ keycloak_service_group if keycloak_service_runas else omit }}" + mode: '0750' + +## check remote archive +- name: Set download archive path + ansible.builtin.set_fact: + keycloak_archive_full_path: "{{ keycloak_dest }}/{{ keycloak_archive }}" + +- name: Check download archive path + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.stat: + path: "{{ keycloak_archive_full_path }}" + register: keycloak_archive_path + +## download to controller +- name: Check local download archive path + ansible.builtin.stat: + path: "{{ lookup('env', 'PWD') }}" + register: keycloak_local_path + delegate_to: localhost + +- name: Download keycloak archive + ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user + url: "{{ keycloak_download_url }}" + dest: "{{ keycloak_local_path.stat.path }}/{{ keycloak_archive }}" + mode: '0644' + url_username: "{{ keycloak_binary_download_user | default(omit) }}" + url_password: "{{ keycloak_binary_download_pass | default(omit) }}" + validate_certs: "{{ keycloak_download_validate_certs | default(omit) }}" + delegate_to: localhost + run_once: true + when: + - keycloak_archive_path is defined + - keycloak_archive_path.stat is defined + - not keycloak_archive_path.stat.exists + - not sso_enable is defined or not sso_enable + - not keycloak_offline_install + +- name: Perform download from RHN using Unified Downloads API + delegate_to: localhost + run_once: true + when: + - keycloak_archive_path is defined + - keycloak_archive_path.stat is defined + - not keycloak_archive_path.stat.exists + - sso_enable is defined and sso_enable + - not keycloak_offline_install + block: + - name: Retrieve product download using Unified Downloads API + middleware_automation.common.product_search: + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_type: DISTRIBUTION + product_version: "{{ sso_version.split('.')[:2] | join('.') }}" + product_category: "{{ sso_product_category }}" + validate_certs: "{{ keycloak_download_validate_certs | default(omit) }}" + register: keycloak_rhn_products + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + + - name: Determine install zipfile from search results + ansible.builtin.set_fact: + keycloak_rhn_filtered_products: >- + {{ keycloak_rhn_products.results + | selectattr('file_name', 'search', 'rh-sso-' + sso_version + '-server-dist.zip$') + | list }} + delegate_to: localhost + run_once: true + + - name: Download Red Hat Single Sign-On + middleware_automation.common.product_download: # noqa risky-file-permissions delegated, uses controller host user + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_id: "{{ (keycloak_rhn_filtered_products | first).id }}" + dest: "{{ keycloak_local_path.stat.path }}/{{ keycloak_archive }}" + validate_certs: "{{ keycloak_download_validate_certs | default(omit) }}" + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + +- name: Download rhsso archive from alternate location + ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user + url: "{{ keycloak_rhsso_download_url }}" + dest: "{{ keycloak_local_path.stat.path }}/{{ keycloak_archive }}" + mode: '0644' + validate_certs: "{{ keycloak_download_validate_certs | default(omit) }}" + delegate_to: localhost + run_once: true + when: + - keycloak_archive_path is defined + - keycloak_archive_path.stat is defined + - not keycloak_archive_path.stat.exists + - sso_enable is defined and sso_enable + - not keycloak_offline_install + - keycloak_rhsso_download_url is defined + +- name: Check downloaded archive + ansible.builtin.stat: + path: "{{ keycloak_local_path.stat.path }}/{{ keycloak_archive }}" + register: keycloak_local_archive_path + delegate_to: localhost + +## copy and unpack +- name: Copy archive to target nodes + ansible.builtin.copy: + src: "{{ keycloak_local_path.stat.path }}/{{ keycloak_archive }}" + dest: "{{ keycloak_archive_full_path }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0640' + register: keycloak_new_version_downloaded + when: + - not keycloak_archive_path.stat.exists + - keycloak_local_archive_path.stat is defined + - keycloak_local_archive_path.stat.exists + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + +- name: "Check target directory: {{ keycloak_config.home }}" + ansible.builtin.stat: + path: "{{ keycloak_config.home }}" + register: keycloak_path_to_workdir + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + +- name: "Extract archive on target: {{ keycloak_service_desc }}" + ansible.builtin.unarchive: + remote_src: true + src: "{{ keycloak_archive_full_path }}" + dest: "{{ keycloak_dest }}" + creates: "{{ keycloak_config.home }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + when: + - keycloak_new_version_downloaded.changed or not keycloak_path_to_workdir.stat.exists + notify: + - restart keycloak + +- name: Inform decompression was not executed + ansible.builtin.debug: + msg: "{{ keycloak_config.home }} already exists and version unchanged, skipping decompression" + when: + - not keycloak_new_version_downloaded.changed and keycloak_path_to_workdir.stat.exists + +- name: "Reown installation directory to {{ keycloak_service_user }}" + ansible.builtin.file: + path: "{{ keycloak_config.home }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + recurse: true + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + changed_when: false + +- name: Ensure permissions are correct on existing deploy + ansible.builtin.command: chown -R "{{ keycloak_service_user }}:{{ keycloak_service_group }}" "{{ keycloak_config.home }}" + when: keycloak_service_runas + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + changed_when: false + +# driver and configuration +- name: "Install database driver: {{ keycloak_jdbc_engine }}" + ansible.builtin.include_tasks: jdbc_driver.yml + when: keycloak_jdbc[keycloak_jdbc_engine].enabled + +- name: "Deploy custom config: {{ keycloak_config.service_name }}" + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.template: + src: "templates/{{ keycloak_config_override_template }}" + dest: "{{ keycloak_config_path_to_standalone_xml }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0640' + notify: + - restart keycloak + when: (keycloak_config_override_template | default('', true)) | length > 0 + +- name: "Deploy standalone config: {{ keycloak_config.service_name }}" + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.template: + src: templates/standalone.xml.j2 + dest: "{{ keycloak_config_path_to_standalone_xml }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0640' + notify: + - restart keycloak + when: + - not keycloak_ha_enabled + - (keycloak_config_override_template | default('', true)) | length == 0 + +- name: Create tcpping cluster node list + ansible.builtin.set_fact: + keycloak_cluster_nodes: > + {{ keycloak_cluster_nodes | default([]) + [ + { + "name": item, + "address": 'jgroups-' + item, + "inventory_host": hostvars[item].ansible_default_ipv4.address | default(item) + '[' + (keycloak_jgroups_port | string) + ']', + "value": hostvars[item].ansible_default_ipv4.address | default(item) + } + ] }} + loop: "{{ ansible_play_batch }}" + when: keycloak_ha_enabled and keycloak_ha_discovery == 'TCPPING' + +- name: "Deploy HA config: {{ keycloak_config.service_name }}" + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.template: + src: templates/standalone-ha.xml.j2 + dest: "{{ keycloak_config_path_to_standalone_xml }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0640' + notify: + - restart keycloak + when: + - keycloak_ha_enabled + - not keycloak_remote_cache_enabled + - (keycloak_config_override_template | default('', true)) | length == 0 + +- name: "Deploy HA config with infinispan: {{ keycloak_config.service_name }}" + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.template: + src: templates/standalone-infinispan.xml.j2 + dest: "{{ keycloak_config_path_to_standalone_xml }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0640' + notify: + - restart keycloak + when: + - keycloak_ha_enabled + - keycloak_remote_cache_enabled + - (keycloak_config_override_template | default('', true)) | length == 0 + +- name: "Deploy profile.properties file to {{ keycloak_config_path_to_properties }}" + become: "{{ keycloak_install_require_privilege_escalation | default(true) }}" + ansible.builtin.template: + src: keycloak-profile.properties.j2 + dest: "{{ keycloak_config_path_to_properties }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0640' + notify: + - restart keycloak + when: (keycloak_features | default([], true)) | length > 0 diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/iptables.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/iptables.yml new file mode 100644 index 00000000..d9519411 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/iptables.yml @@ -0,0 +1,23 @@ +--- +- name: Ensure required package iptables are installed + ansible.builtin.include_tasks: fastpackages.yml + vars: + packages_list: + - iptables + +- name: "Configure firewall ports for {{ keycloak_config.service_name }}" + become: "{{ keycloak_iptables_require_privilege_escalation | default(true) }}" + ansible.builtin.iptables: + destination_port: "{{ item }}" + action: "insert" + rule_num: 6 # magic number I forget why + chain: "INPUT" + policy: "ACCEPT" + protocol: tcp + loop: + - "{{ keycloak_http_port }}" + - "{{ keycloak_https_port }}" + - "{{ keycloak_management_http_port }}" + - "{{ keycloak_management_https_port }}" + - "{{ keycloak_jgroups_port }}" + - "{{ keycloak_ajp_port }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/jdbc_driver.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/jdbc_driver.yml new file mode 100644 index 00000000..9b67892f --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/jdbc_driver.yml @@ -0,0 +1,46 @@ +--- +- name: "Check module directory: {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}" + ansible.builtin.stat: + path: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}" + register: keycloak_dest_path + become: "{{ keycloak_jdbc_driver_require_privilege_escalation | default(true) }}" + +- name: "Set up module dir for JDBC Driver {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }}" + ansible.builtin.file: + path: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}" + state: directory + recurse: true + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0750' + become: "{{ keycloak_jdbc_driver_require_privilege_escalation | default(true) }}" + when: + - not dest_path.stat.exists +- name: "Verify valid parameters for download credentials when specified" + ansible.builtin.fail: + msg: >- + When JDBC driver download credentials are set, both the username and the password MUST be set + when: > + (keycloak_jdbc_download_user is undefined and keycloak_jdbc_download_pass is not undefined) or + (keycloak_jdbc_download_pass is undefined and keycloak_jdbc_download_user is not undefined) + +- name: "Retrieve JDBC Driver from {{ keycloak_jdbc[keycloak_jdbc_engine].driver_jar_url }}" + ansible.builtin.get_url: + url: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_jar_url }}" + dest: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}/{{ keycloak_jdbc[keycloak_jdbc_engine].driver_jar_filename }}" + group: "{{ keycloak_service_group }}" + owner: "{{ keycloak_service_user }}" + url_username: "{{ keycloak_jdbc_download_user | default(omit) }}" + url_password: "{{ keycloak_jdbc_download_pass | default(omit) }}" + validate_certs: "{{ keycloak_jdbc_download_validate_certs | default(omit) }}" + mode: '0640' + become: "{{ keycloak_jdbc_driver_require_privilege_escalation | default(true) }}" + +- name: "Deploy module.xml for JDBC Driver" + ansible.builtin.template: + src: "templates/jdbc_driver_module.xml.j2" + dest: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}/module.xml" + group: "{{ keycloak_service_group }}" + owner: "{{ keycloak_service_user }}" + mode: '0640' + become: "{{ keycloak_jdbc_driver_require_privilege_escalation | default(true) }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/main.yml new file mode 100644 index 00000000..c840a1be --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/main.yml @@ -0,0 +1,88 @@ +--- +# tasks file for keycloak +- name: Check prerequisites + ansible.builtin.include_tasks: + file: prereqs.yml + apply: + tags: + - prereqs + tags: + - prereqs + +- name: Distro specific tasks + ansible.builtin.include_tasks: + file: "{{ ansible_os_family | lower }}.yml" + apply: + tags: + - unbound + tags: + - unbound + +- name: Include install tasks + ansible.builtin.include_tasks: + file: install.yml + apply: + tags: + - install + tags: + - install + +- name: Include systemd tasks + ansible.builtin.include_tasks: + file: systemd.yml + apply: + tags: + - systemd + tags: + - systemd + +- name: Include patch install tasks + ansible.builtin.include_tasks: rhsso_patch.yml + when: + - sso_apply_patches is defined and sso_apply_patches + - sso_enable is defined and sso_enable + - ansible_facts.os_family == "RedHat" + tags: + - install + - patch + +- name: Link default logs directory + ansible.builtin.file: + state: link + src: "{{ keycloak_jboss_home }}/standalone/log" + dest: "{{ keycloak_log_target }}" + become: "{{ keycloak_require_privilege_escalation | default(true) }}" + +- name: Set admin credentials and restart if not already created + block: + - name: Check admin credentials by generating a token (supposed to fail on first installation) + ansible.builtin.uri: + url: "{{ keycloak_url }}/auth/realms/master/protocol/openid-connect/token" + method: POST + body: "client_id={{ keycloak_auth_client }}&username={{ keycloak_admin_user }}&password={{ keycloak_admin_password }}&grant_type=password" + validate_certs: "{{ keycloak_validate_certs | default(omit) }}" + register: keycloak_auth_response + until: keycloak_auth_response.status == 200 + retries: 2 + delay: 2 + rescue: + - name: "Create admin user: {{ keycloak_config.service_name }}" + ansible.builtin.command: + args: + argv: + - "{{ keycloak_jboss_home }}/bin/add-user-keycloak.sh" + - "-rmaster" + - "-u{{ keycloak_admin_user }}" + - "-p{{ keycloak_admin_password }}" + changed_when: true + become: "{{ keycloak_require_privilege_escalation | default(true) }}" + - name: "Restart service: {{ keycloak_config.service_name }}" + ansible.builtin.include_tasks: tasks/restart_keycloak.yml + - name: "Wait until service becomes active: {{ keycloak_config.service_name }}" + ansible.builtin.uri: + url: "{{ keycloak_config.health_url }}" + validate_certs: "{{ keycloak_validate_certs | default(omit) }}" + register: keycloak_status + until: keycloak_status.status == 200 + retries: 25 + delay: 10 diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/prereqs.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/prereqs.yml new file mode 100644 index 00000000..7e2e8e3b --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/prereqs.yml @@ -0,0 +1,58 @@ +--- +- name: Validate admin console password + ansible.builtin.assert: + that: + - (keycloak_admin_password | default('', true)) | length > 12 + quiet: true + fail_msg: > + The console administrator password is empty or invalid. Please set the keycloak_admin_password variable to a 12+ char long string + success_msg: "{{ 'Console administrator password OK' }}" + +- name: Validate configuration + ansible.builtin.assert: + that: > + (keycloak_ha_enabled and keycloak_db_enabled) or + (not keycloak_ha_enabled and keycloak_db_enabled) or + (not keycloak_ha_enabled and not keycloak_db_enabled) + quiet: true + fail_msg: "Cannot install HA setup without a backend database service. Check keycloak_ha_enabled and keycloak_db_enabled" + success_msg: "{{ 'Configuring HA' if keycloak_ha_enabled else 'Configuring standalone' }}" + +- name: Validate credentials + ansible.builtin.assert: + that: + - (rhn_username is defined and sso_enable is defined and sso_enable) or not sso_enable is defined or not sso_enable or keycloak_offline_install + - (rhn_password is defined and sso_enable is defined and sso_enable) or not sso_enable is defined or not sso_enable or keycloak_offline_install + quiet: true + fail_msg: "Cannot install Red Hat SSO without RHN credentials. Check rhn_username and rhn_password are defined" + success_msg: "Installing {{ keycloak_service_desc }}" + +- name: Validate persistence configuration + ansible.builtin.assert: + that: + - keycloak_jdbc_engine is defined and keycloak_jdbc_engine in [ 'postgres', 'mariadb', 'sqlserver' ] + - (keycloak_jdbc_url | default('', true)) | length > 0 + - (keycloak_db_user | default('', true)) | length > 0 + - (keycloak_db_pass | default('', true)) | length > 0 + quiet: true + fail_msg: "Configuration for the JDBC persistence is invalid or incomplete" + success_msg: "Configuring JDBC persistence using {{ keycloak_jdbc_engine }} database" + when: keycloak_db_enabled + +- name: Validate OS family + ansible.builtin.assert: + that: + - ansible_os_family in ["RedHat", "Debian"] + quiet: true + fail_msg: "Can only install on RedHat or Debian OS families; found {{ ansible_os_family }}" + success_msg: "Installing on {{ ansible_os_family }}" + +- name: Load OS specific variables + ansible.builtin.include_vars: "vars/{{ ansible_os_family | lower }}.yml" + tags: + - always + +- name: Ensure required packages are installed + ansible.builtin.include_tasks: fastpackages.yml + vars: + packages_list: "{{ keycloak_prereq_package_list }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/redhat.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/redhat.yml new file mode 100644 index 00000000..ece5772b --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/redhat.yml @@ -0,0 +1,10 @@ +--- +- name: Include firewall config tasks + ansible.builtin.include_tasks: + file: firewalld.yml + apply: + tags: + - firewall + when: keycloak_configure_firewalld + tags: + - firewall diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/restart_keycloak.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/restart_keycloak.yml new file mode 100644 index 00000000..335cb923 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/restart_keycloak.yml @@ -0,0 +1,29 @@ +--- +- name: "Restart and enable service: {{ keycloak_config.service_name }}" + ansible.builtin.systemd: + name: keycloak + enabled: true + state: restarted + daemon_reload: true + become: "{{ keycloak_restart_require_privilege_escalation | default(true) }}" + delegate_to: "{{ ansible_play_hosts | first }}" + run_once: true + +- name: "Wait until service becomes active: {{ keycloak_config.service_name }}" + ansible.builtin.uri: + url: "{{ keycloak_config.health_url }}" + validate_certs: "{{ keycloak_validate_certs | default(omit) }}" + register: keycloak_status + until: keycloak_status.status == 200 + delegate_to: "{{ ansible_play_hosts | first }}" + run_once: true + retries: "{{ keycloak_service_start_retries }}" + delay: "{{ keycloak_service_start_delay }}" + +- name: "Restart and enable service (remaining nodes): {{ keycloak_config.service_name }}" + ansible.builtin.systemd: + name: keycloak + enabled: true + state: restarted + become: "{{ keycloak_restart_require_privilege_escalation | default(true) }}" + when: inventory_hostname != ansible_play_hosts | first diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/rhsso_cli.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/rhsso_cli.yml new file mode 100644 index 00000000..b6bfc560 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/rhsso_cli.yml @@ -0,0 +1,13 @@ +--- +- name: Ensure required params for CLI have been provided + ansible.builtin.assert: + that: + - cli_query is defined + fail_msg: "Missing required parameters to execute CLI." + quiet: true + +- name: "Execute CLI query: {{ cli_query }}" + ansible.builtin.command: > + {{ keycloak_config.cli_path }} --connect --command='{{ cli_query }}' --controller={{ keycloak_host }}:{{ keycloak_management_http_port }} + changed_when: false + register: keycloak_cli_result diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/rhsso_patch.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/rhsso_patch.yml new file mode 100644 index 00000000..32c500da --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/rhsso_patch.yml @@ -0,0 +1,174 @@ +--- +## check remote patch archive +- name: Set download patch archive path + ansible.builtin.set_fact: + keycloak_patch_archive: "{{ keycloak_dest }}/{{ sso_patch_bundle }}" + keycloak_patch_bundle: "{{ sso_patch_bundle }}" + keycloak_patch_version: "{{ sso_patch_version }}" + when: sso_patch_version is defined + +- name: Check download patch archive path + ansible.builtin.stat: + path: "{{ keycloak_patch_archive }}" + register: keycloak_patch_archive_path + when: sso_patch_version is defined + become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}" + +- name: Perform patch download from RHN via Unified Downloads API + delegate_to: localhost + run_once: true + when: + - sso_enable is defined and sso_enable + - not keycloak_offline_install + - sso_apply_patches + block: + - name: Retrieve product download using Unified Downloads API + middleware_automation.common.product_search: + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_type: BUGFIX + product_version: "{{ sso_version.split('.')[:2] | join('.') }}" + product_category: "{{ sso_product_category }}" + validate_certs: "{{ keycloak_download_validate_certs | default(omit) }}" + register: keycloak_rhn_products + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + + - name: Determine patch versions list + ansible.builtin.set_fact: + keycloak_filtered_versions: "{{ keycloak_rhn_products.results | map(attribute='file_name') | \ + select('match', '^[^/]*/rh-sso-.*[0-9]*[.][0-9]*[.][0-9]*.*$') | \ + map('regex_replace', '[^/]*/rh-sso-([0-9]*[.][0-9]*[.][0-9]*(-[0-9])?)-.*', '\\1') | list | unique }}" + when: (sso_patch_version | default('', true)) | length == 0 + delegate_to: localhost + run_once: true + + - name: Determine latest version + ansible.builtin.set_fact: + keycloak_sso_latest_version: "{{ keycloak_filtered_versions | middleware_automation.common.version_sort | last }}" + when: (sso_patch_version | default('', true)) | length == 0 + delegate_to: localhost + run_once: true + + - name: Determine install zipfile from search results + ansible.builtin.set_fact: + keycloak_rhn_filtered_products: "{{ keycloak_rhn_products.results | selectattr('file_name', 'match', '[^/]*/rh-sso-' + keycloak_sso_latest_version + '-patch.zip$') }}" + keycloak_patch_bundle: "rh-sso-{{ keycloak_sso_latest_version }}-patch.zip" + keycloak_patch_version: "{{ keycloak_sso_latest_version }}" + when: (sso_patch_version | default('', true)) | length == 0 + delegate_to: localhost + run_once: true + + - name: "Determine selected patch from supplied version: {{ sso_patch_version }}" + ansible.builtin.set_fact: + keycloak_rhn_filtered_products: "{{ keycloak_rhn_products.results | selectattr('file_name', 'match', '[^/]*/' + sso_patch_bundle + '$') }}" + keycloak_patch_bundle: "{{ sso_patch_bundle }}" + keycloak_patch_version: "{{ sso_patch_version }}" + when: sso_patch_version is defined + delegate_to: localhost + run_once: true + + - name: Download Red Hat Single Sign-On patch + middleware_automation.common.product_download: # noqa risky-file-permissions delegated, uses controller host user + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_id: "{{ (keycloak_rhn_filtered_products | sort | last).id }}" + dest: "{{ keycloak_local_path.stat.path }}/{{ keycloak_patch_bundle }}" + validate_certs: "{{ keycloak_download_validate_certs | default(omit) }}" + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + +- name: Set download patch archive path + ansible.builtin.set_fact: + keycloak_patch_archive: "{{ keycloak_dest }}/{{ keycloak_patch_bundle }}" + +- name: Check download patch archive path + ansible.builtin.stat: + path: "{{ keycloak_patch_archive }}" + register: keycloak_patch_archive_path + become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}" + +## copy and unpack +- name: Copy patch archive to target nodes + ansible.builtin.copy: + src: "{{ keycloak_local_path.stat.path }}/{{ keycloak_patch_bundle }}" + dest: "{{ keycloak_patch_archive }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0640' + register: keycloak_new_version_downloaded + when: + - not keycloak_patch_archive_path.stat.exists + - keycloak_local_archive_path.stat is defined + - keycloak_local_archive_path.stat.exists + become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}" + +- name: "Check installed patches" + ansible.builtin.include_tasks: rhsso_cli.yml + vars: + cli_query: "patch info" + args: + apply: + become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}" + become_user: "{{ keycloak_service_user }}" + +- name: "Perform patching" + when: + - keycloak_cli_result is defined + - keycloak_cli_result.stdout is defined + - keycloak_patch_version | regex_replace('-[0-9]$', '') not in keycloak_cli_result.stdout + block: + - name: "Apply patch to server: {{ keycloak_patch_version }}" + ansible.builtin.include_tasks: rhsso_cli.yml + vars: + cli_query: "patch apply {{ keycloak_patch_archive }}" + args: + apply: + become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}" + become_user: "{{ keycloak_service_user }}" + + - name: "Restart server to ensure patch content is running" + ansible.builtin.include_tasks: rhsso_cli.yml + vars: + cli_query: "shutdown --restart" + when: + - keycloak_cli_result.rc == 0 + args: + apply: + become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}" + become_user: "{{ keycloak_service_user }}" + + - name: "Wait until service becomes active: {{ keycloak_config.service_name }}" + ansible.builtin.uri: + url: "{{ keycloak_config.health_url }}" + validate_certs: "{{ keycloak_validate_certs | default(omit) }}" + register: keycloak_status + until: keycloak_status.status == 200 + retries: 25 + delay: 10 + + - name: "Query installed patch after restart" + ansible.builtin.include_tasks: rhsso_cli.yml + vars: + cli_query: "patch info" + args: + apply: + become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}" + become_user: "{{ keycloak_service_user }}" + + - name: "Verify installed patch version" + ansible.builtin.assert: + that: + - keycloak_patch_version not in keycloak_cli_result.stdout + fail_msg: "Patch installation failed" + success_msg: "Patch installation successful" + +- name: "Skipping patch" + ansible.builtin.debug: + msg: "Cumulative patch {{ keycloak_patch_version }} already installed, skipping patch installation." + when: + - keycloak_cli_result is defined + - keycloak_cli_result.stdout is defined + - keycloak_patch_version in keycloak_cli_result.stdout diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/start_keycloak.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/start_keycloak.yml new file mode 100644 index 00000000..256bedf9 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/start_keycloak.yml @@ -0,0 +1,17 @@ +--- +- name: "Start service: {{ keycloak_config.service_name }}" + ansible.builtin.systemd: + name: keycloak + enabled: true + state: started + daemon_reload: true + become: "{{ keycloak_start_require_privilege_escalation | default(true) }}" + +- name: "Wait until service becomes active: {{ keycloak_config.service_name }}" + ansible.builtin.uri: + url: "{{ keycloak_config.health_url }}" + validate_certs: "{{ keycloak_validate_certs | default(omit) }}" + register: keycloak_status + until: keycloak_status.status == 200 + retries: "{{ keycloak_service_start_retries }}" + delay: "{{ keycloak_service_start_delay }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/stop_keycloak.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/stop_keycloak.yml new file mode 100644 index 00000000..1e8b9eb5 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/stop_keycloak.yml @@ -0,0 +1,7 @@ +--- +- name: "Stop {{ keycloak_config.service_name }}" + ansible.builtin.systemd: + name: keycloak + enabled: true + state: stopped + become: "{{ keycloak_stop_require_privilege_escalation | default(true) }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/systemd.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/systemd.yml new file mode 100644 index 00000000..f4cd46be --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/tasks/systemd.yml @@ -0,0 +1,56 @@ +--- +- name: "Configure service script wrapper: {{ keycloak_config.service_name }}" + become: "{{ keycloak_systemd_require_privilege_escalation | default(true) }}" + ansible.builtin.template: + src: keycloak-service.sh.j2 + dest: "{{ keycloak_dest }}/keycloak-service.sh" + owner: root + group: root + mode: '0755' + notify: + - restart keycloak + +- name: "Configure sysconfig file for service: {{ keycloak_config.service_name }}" + become: "{{ keycloak_systemd_require_privilege_escalation | default(true) }}" + ansible.builtin.template: + src: keycloak-sysconfig.j2 + dest: "{{ keycloak_sysconf_file }}" + owner: root + group: root + mode: '0644' + notify: + - restart keycloak + +- name: "Configure systemd unit file for service: {{ keycloak_config.service_name }}" + ansible.builtin.template: + src: keycloak.service.j2 + dest: /etc/systemd/system/keycloak.service + owner: root + group: root + mode: '0644' + become: "{{ keycloak_systemd_require_privilege_escalation | default(true) }}" + register: keycloak_systemdunit + notify: + - restart keycloak + +- name: "Start and wait for service (first node db): {{ keycloak_config.service_name }}" + ansible.builtin.include_tasks: start_keycloak.yml + run_once: true + when: keycloak_db_enabled + +- name: "Start and wait for service (remaining nodes): {{ keycloak_config.service_name }}" + ansible.builtin.include_tasks: start_keycloak.yml + +- name: Check service status + ansible.builtin.command: "systemctl status keycloak" + register: keycloak_service_status + changed_when: false + +- name: Verify service status + ansible.builtin.assert: + that: + - keycloak_service_status is defined + - keycloak_service_status.stdout is defined + +- name: Flush handlers + ansible.builtin.meta: flush_handlers diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/15.0.8/standalone-infinispan.xml.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/15.0.8/standalone-infinispan.xml.j2 new file mode 100644 index 00000000..25d6cb0f --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/15.0.8/standalone-infinispan.xml.j2 @@ -0,0 +1,761 @@ + +{{ ansible_managed | comment('xml') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + {{ keycloak_jdbc[keycloak_jdbc_engine].connection_url }} + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }} + + 20 + + + {{ keycloak_jdbc[keycloak_jdbc_engine].db_user }} + {{ keycloak_jdbc[keycloak_jdbc_engine].db_password }} + +{% else %} + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + +{% endif %} + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_class }} + {{ keycloak_jdbc[keycloak_jdbc_engine].xa_datasource_class }} + +{% endif %} + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% for cachename in [ "sessions", "offlineSessions", "clientSessions", "offlineClientSessions", "loginFailures", "actionTokens", "authenticationSessions" ] %} + + + true + org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory + false + {{ keycloak_remotecache.username }} + {{ keycloak_remotecache.password }} + {{ keycloak_remotecache.realm | default('default') }} + {{ keycloak_remotecache.server_name }} + {{ keycloak_remotecache.sasl_mechanism }} + {{ keycloak_remotecache.use_ssl }} + {{ keycloak_remotecache.trust_store_path }} + JKS + {{ keycloak_remotecache.trust_store_password }} + TOPOLOGY_AWARE + + +{% endfor %} + + + true + org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory + false + {{ keycloak_remotecache.username }} + {{ keycloak_remotecache.password }} + {{ keycloak_remotecache.realm | default('default') }} + {{ keycloak_remotecache.server_name }} + {{ keycloak_remotecache.sasl_mechanism }} + {{ keycloak_remotecache.use_ssl }} + {{ keycloak_remotecache.trust_store_path }} + JKS + {{ keycloak_remotecache.trust_store_password }} + TOPOLOGY_AWARE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + java:jboss/datasources/KeycloakDS + {{ keycloak_jdbc[keycloak_jdbc_engine].initialize_db }} + INSERT INTO JGROUPSPING (own_addr, cluster_name, ping_data) values (?, ?, ?) + DELETE FROM JGROUPSPING WHERE own_addr=? AND cluster_name=? + SELECT ping_data FROM JGROUPSPING WHERE cluster_name=? + +{% endif %} + + + + + + + + + 30000 + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + +{% if keycloak_ha_enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + default + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if ansible_default_ipv4 is defined %} + +{% else %} + +{% endif %} + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + +{% endif %} + + + + + diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/15.0.8/standalone.xml.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/15.0.8/standalone.xml.j2 new file mode 100644 index 00000000..01c317b6 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/15.0.8/standalone.xml.j2 @@ -0,0 +1,658 @@ + +{{ ansible_managed | comment('xml') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + {{ keycloak_jdbc[keycloak_jdbc_engine].connection_url }} + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }} + + 20 + + + {{ keycloak_jdbc[keycloak_jdbc_engine].db_user }} + {{ keycloak_jdbc[keycloak_jdbc_engine].db_password }} + +{% else %} + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + +{% endif %} + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_class }} + {{ keycloak_jdbc[keycloak_jdbc_engine].xa_datasource_class }} + +{% endif %} + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + +{% if keycloak_ha_enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + default + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + +{% endif %} + + diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/9.0.2/standalone-infinispan.xml.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/9.0.2/standalone-infinispan.xml.j2 new file mode 100644 index 00000000..4f90ad8e --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/9.0.2/standalone-infinispan.xml.j2 @@ -0,0 +1,759 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + +{% if keycloak_jdbc.postgres.enabled %} + {{ keycloak_jdbc.postgres.connection_url }} + {{ keycloak_jdbc.postgres.driver_module_name }} + + 20 + + + {{ keycloak_jdbc.postgres.db_user }} + {{ keycloak_jdbc.postgres.db_password }} + +{% else %} + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + +{% endif %} + + +{% if keycloak_jdbc.postgres.enabled %} + + org.postgresql.Driver + org.postgresql.xa.PGXADataSource + +{% endif %} + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for cachename in [ "sessions", "offlineSessions", "clientSessions", "offlineClientSessions", "loginFailures", "actionTokens" ] %} + + + true + org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory + false + {{ keycloak_remotecache.username }} + {{ keycloak_remotecache.password }} + {{ keycloak_remotecache.realm | default('default') }} + {{ keycloak_remotecache.server_name }} + {{ keycloak_remotecache.sasl_mechanism | default('SCRAM-SHA-512') }} + false + {{ keycloak_remotecache.trust_store_path | default('/etc/truststore/truststore.jks') }} + JKS + {{ keycloak_remotecache.trust_store_password | default("changeme") }} + TOPOLOGY_AWARE + + + {% endfor %} + + + true + org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory + false + {{ keycloak_remotecache.username }} + {{ keycloak_remotecache.password }} + {{ keycloak_remotecache.realm | default('default') }} + {{ keycloak_remotecache.server_name }} + {{ keycloak_remotecache.sasl_mechanism | default('SCRAM-SHA-512') }} + false + {{ keycloak_remotecache.trust_store_path | default('/etc/truststore/truststore.jks') }} + JKS + {{ keycloak_remotecache.trust_store_password | default("changeme") }} + TOPOLOGY_AWARE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + java:jboss/datasources/KeycloakDS + {{ keycloak_jdbc[keycloak_jdbc_engine].initialize_db }} + INSERT INTO JGROUPSPING (own_addr, cluster_name, ping_data) values (?, ?, ?) + DELETE FROM JGROUPSPING WHERE own_addr=? AND cluster_name=? + SELECT ping_data FROM JGROUPSPING WHERE cluster_name=? + +{% endif %} + + + + + + + + + 30000 + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + default + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if ansible_default_ipv4 is defined %} + +{% else %} + +{% endif %} + + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + +{% endif %} + + + + + diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/9.0.2/standalone.xml.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/9.0.2/standalone.xml.j2 new file mode 100644 index 00000000..4188e922 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/9.0.2/standalone.xml.j2 @@ -0,0 +1,619 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + + + + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auth + + classpath:${jboss.home.dir}/providers/* + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + default + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + +{% endif %} + + diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/jdbc_driver_module.xml.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/jdbc_driver_module.xml.j2 new file mode 100644 index 00000000..c513693f --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/jdbc_driver_module.xml.j2 @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-profile.properties.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-profile.properties.j2 new file mode 100644 index 00000000..6e6fc0c8 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-profile.properties.j2 @@ -0,0 +1,3 @@ +{% for feature in keycloak_config.features %} +feature.{{ feature.name }}={{ feature.status | default('enabled') }} +{% endfor %} \ No newline at end of file diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-service.sh.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-service.sh.j2 new file mode 100755 index 00000000..5325c432 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-service.sh.j2 @@ -0,0 +1,127 @@ +#!/bin/bash -eu +{{ ansible_managed | comment }} + +set +u -o pipefail + +checkEnvVar() { + local envVar=${1} + local msg=${2} + local exitCode=${3} + + if [ -z "${envVar}" ]; then + echo "${msg}" + exit "${exitCode}" + fi +} +# SystemD will do for the unit, decomment +# for testing outside systemd +. /etc/sysconfig/keycloak + +readonly KEYCLOAK_HOME={{ keycloak_config.home }} +readonly KEYCLOAK_BIND_ADDRESS=${KEYCLOAK_BIND_ADDRESS} +readonly KEYCLOAK_HTTP_PORT=${KEYCLOAK_HTTP_PORT} +readonly KEYCLOAK_HTTPS_PORT=${KEYCLOAK_HTTPS_PORT} +readonly KEYCLOAK_MANAGEMENT_HTTP_PORT=${KEYCLOAK_MANAGEMENT_HTTP_PORT} +readonly KEYCLOAK_MANAGEMENT_HTTPS_PORT=${KEYCLOAK_MANAGEMENT_HTTPS_PORT} +readonly KEYCLOAK_PIDFILE={{ keycloak_service_pidfile }} + +set -u +if [ ! -d "${KEYCLOAK_HOME}" ]; then + echo "KEYCLOAK_HOME (${KEYCLOAK_HOME}) is not a directory or does not exists." + exit 1 +fi + +checkEnvVar "${KEYCLOAK_PIDFILE}" 'KEYCLOAK_PIDFILE not provided' 2 + +getKeycloakPID() { + + local pid + if [ -e "${KEYCLOAK_PIDFILE}" ]; then + cat "${KEYCLOAK_PIDFILE}" + fi +} + +statusKeycloak() { + + if [ "$(isKeyCloakRunning)" -eq 1 ]; then + echo "Keycloak is running (PID:$(getKeycloakPID ))" + else + echo "Keycloak is NOT running." + fi +} + +isKeyCloakRunning() { + set +e + local statusKeycloak=0 + local pid=$(getKeycloakPID ) + if [ -n "${pid}" ]; then + kill -0 "${pid}" 2> /dev/null + if [ "${?}" -eq 0 ]; then + statusKeycloak=1 + fi + fi + set -e + echo "${statusKeycloak}" +} + +startKeycloak() { + checkEnvVar "${KEYCLOAK_BIND_ADDRESS}" 'KEYCLOAK_BIND_ADDRESS not provided' 2 + checkEnvVar "${KEYCLOAK_HTTP_PORT}" 'KEYCLOAK_HTTP_PORT not provided' 4 + checkEnvVar "${KEYCLOAK_HTTPS_PORT}" 'KEYCLOAK_HTTPS_PORT not provided' 5 + checkEnvVar "${KEYCLOAK_MANAGEMENT_HTTP_PORT}" 'KEYCLOAK_MANAGEMENT_HTTP_PORT not provided' 6 + checkEnvVar "${KEYCLOAK_MANAGEMENT_HTTPS_PORT}" 'KEYCLOAK_MANAGEMENT_HTTPS_PORT not provided' 7 + + if [ "$(isKeyCloakRunning)" -eq 1 ]; then + statusKeycloak + else + LAUNCH_JBOSS_IN_BACKGROUND=1 JBOSS_PIDFILE=${KEYCLOAK_PIDFILE} ${KEYCLOAK_HOME}/bin/standalone.sh \ + -Djboss.bind.address=${KEYCLOAK_BIND_ADDRESS} \ + -Djboss.http.port=${KEYCLOAK_HTTP_PORT} \ + -Djboss.https.port=${KEYCLOAK_HTTPS_PORT} \ + -Djboss.management.http.port=${KEYCLOAK_MANAGEMENT_HTTP_PORT} \ + -Djboss.management.https.port=${KEYCLOAK_MANAGEMENT_HTTPS_PORT} \ + -Djboss.node.name={{ inventory_hostname }} \ + {% if keycloak_prefer_ipv4 %}-Djava.net.preferIPv4Stack=true -Djava.net.preferIPv4Addresses=true {% endif %}\ + {% if keycloak_config_standalone_xml is defined %}--server-config={{ keycloak_config_standalone_xml }}{% endif %} & + while [ ! -f ${KEYCLOAK_PIDFILE} ]; do sleep 1; done + fi +} + +stopKeycloak() { + local pid=$(getKeycloakPID) + if [ -n "${pid}" ]; then + set +e + kill ${pid} 2> /dev/null + kill -0 "${pid}" 2> /dev/null +# if [ "${?}" -eq 0 ]; then +# sleep 5 +# kill -9 "${pid}" > /dev/null +# fi + set -e + deletePidFile + fi +} + +deletePidFile() { + rm -f "${KEYCLOAK_PIDFILE}" +} + +case "$1" in + start) + startKeycloak + ;; + stop) + stopKeycloak + ;; + restart) + stopKeycloak + startKeycloak + ;; + status) + statusKeycloak + ;; + *) + echo "usage: $0 start|stop|restart" >&2 + exit 1 + ;; +esac diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-sysconfig.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-sysconfig.j2 new file mode 100644 index 00000000..024f39b8 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak-sysconfig.j2 @@ -0,0 +1,19 @@ +{{ ansible_managed | comment }} +JAVA_OPTS='{{ keycloak_java_opts }}' +JAVA_HOME={{ keycloak_java_home | default(keycloak_pkg_java_home, true) }} +JBOSS_HOME={{ keycloak_config.home }} +KEYCLOAK_BIND_ADDRESS={{ keycloak_bind_address }} +KEYCLOAK_HTTP_PORT={{ keycloak_http_port }} +KEYCLOAK_HTTPS_PORT={{ keycloak_https_port }} +KEYCLOAK_MANAGEMENT_HTTP_PORT={{ keycloak_management_http_port }} +KEYCLOAK_MANAGEMENT_HTTPS_PORT={{ keycloak_management_https_port }} +JBOSS_PIDFILE='{{ keycloak_service_pidfile }}' + +WILDFLY_OPTS=-Djboss.bind.address=${KEYCLOAK_BIND_ADDRESS} \ + -Djboss.http.port=${KEYCLOAK_HTTP_PORT} \ + -Djboss.https.port=${KEYCLOAK_HTTPS_PORT} \ + -Djboss.management.http.port=${KEYCLOAK_MANAGEMENT_HTTP_PORT} \ + -Djboss.management.https.port=${KEYCLOAK_MANAGEMENT_HTTPS_PORT} \ + -Djboss.node.name={{ inventory_hostname }} \ + {% if keycloak_prefer_ipv4 %}-Djava.net.preferIPv4Stack=true -Djava.net.preferIPv4Addresses=true {% endif %}\ + {% if keycloak_config_standalone_xml is defined %}--server-config={{ keycloak_config_standalone_xml }}{% endif %} diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak.service.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak.service.j2 new file mode 100644 index 00000000..016a0998 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/keycloak.service.j2 @@ -0,0 +1,29 @@ +{{ ansible_managed | comment }} +[Unit] +Description={{ keycloak_config.service_name }} Server +After=network.target +StartLimitIntervalSec={{ keycloak_service_startlimitintervalsec }} +StartLimitBurst={{ keycloak_service_startlimitburst }} + + +[Service] +{% if keycloak_service_runas %} +User={{ keycloak_service_user }} +Group={{ keycloak_service_group }} +{% endif -%} +EnvironmentFile=-{{ keycloak_sysconf_file }} +PIDFile={{ keycloak_service_pidfile }} +ExecStart={{ keycloak_config.home }}/bin/standalone.sh $WILDFLY_OPTS +WorkingDirectory={{ keycloak_config.home }} +TimeoutStartSec=30 +TimeoutStopSec=30 +LimitNOFILE=102642 +{% if keycloak_service_restart_always %} +Restart=always +{% elif keycloak_service_restart_on_failure %} +Restart=on-failure +{% endif %} +RestartSec={{ keycloak_service_restartsec }} + +[Install] +WantedBy=multi-user.target diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone-ha.xml.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone-ha.xml.j2 new file mode 100644 index 00000000..d027c352 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone-ha.xml.j2 @@ -0,0 +1,707 @@ + +{{ ansible_managed | comment('xml') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + {{ keycloak_jdbc[keycloak_jdbc_engine].connection_url }} + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }} + + 20 + + + {{ keycloak_jdbc[keycloak_jdbc_engine].db_user }} + {{ keycloak_jdbc[keycloak_jdbc_engine].db_password }} + + + {{ keycloak_jdbc[keycloak_jdbc_engine].validate_query }} + {{ keycloak_db_background_validate_on_match }} +{% if keycloak_db_background_validation_millis | int > 0 or keycloak_db_background_validation %} + {{ keycloak_db_background_validation }} + {{ keycloak_db_background_validation_millis }} +{% endif %} + +{% else %} + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + +{% endif %} + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_class }} + {{ keycloak_jdbc[keycloak_jdbc_engine].xa_datasource_class }} + +{% endif %} + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_ha_discovery == 'JDBC_PING' and keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + java:jboss/datasources/KeycloakDS + {{ keycloak_jdbc[keycloak_jdbc_engine].initialize_db }} + INSERT INTO JGROUPSPING (own_addr, cluster_name, ping_data) values (?, ?, ?) + DELETE FROM JGROUPSPING WHERE own_addr=? AND cluster_name=? + SELECT ping_data FROM JGROUPSPING WHERE cluster_name=? + +{% elif keycloak_ha_discovery == 'TCPPING' %} + + {{ keycloak_cluster_nodes | map(attribute='inventory_host') | join (',') }} + 0 + 3000 + 2 + +{% endif %} + + + + + + + + + 30000 + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + +{% if keycloak_ha_enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + default + + + + +{% if keycloak_modcluster.admin_url | length > 0 %} + +{% endif %} + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_jgroups_subnet is defined and keycloak_jgroups_subnet is not none and keycloak_jgroups_subnet | string | length > 0 %} + +{% elif ansible_default_ipv4 is defined and (ansible_default_ipv4.network + '/' + ansible_default_ipv4.netmask) | ansible.utils.ipaddr('net') | length > 0 %} + +{% else %} + +{% endif %} + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} +{% for modcluster in keycloak_modcluster.reverse_proxy_urls %} + + + +{% endfor %} +{% endif %} +{% if keycloak_ha_discovery == 'TCPPING' %} +{% for node in keycloak_cluster_nodes %} + + + +{% endfor %} +{% endif %} + + + + + diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone-infinispan.xml.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone-infinispan.xml.j2 new file mode 100644 index 00000000..18e5a7c4 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone-infinispan.xml.j2 @@ -0,0 +1,745 @@ + +{{ ansible_managed | comment('xml') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + {{ keycloak_jdbc[keycloak_jdbc_engine].connection_url }} + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }} + + 20 + + + {{ keycloak_jdbc[keycloak_jdbc_engine].db_user }} + {{ keycloak_jdbc[keycloak_jdbc_engine].db_password }} + + + {{ keycloak_jdbc[keycloak_jdbc_engine].validate_query }} + {{ keycloak_db_background_validate_on_match }} +{% if keycloak_db_background_validation_millis | int > 0 or keycloak_db_background_validation %} + {{ keycloak_db_background_validation }} + {{ keycloak_db_background_validation_millis }} +{% endif %} + +{% else %} + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + +{% endif %} + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_class }} + {{ keycloak_jdbc[keycloak_jdbc_engine].xa_datasource_class }} + +{% endif %} + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% for cachename in [ "sessions", "offlineSessions", "clientSessions", "offlineClientSessions", "loginFailures", "actionTokens", "authenticationSessions" ] %} + + + true + org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory + false + {{ keycloak_remotecache.username }} + {{ keycloak_remotecache.password }} + {{ keycloak_remotecache.realm | default('default') }} + {{ keycloak_remotecache.server_name }} + {{ keycloak_remotecache.sasl_mechanism }} + {{ keycloak_remotecache.use_ssl }} + {{ keycloak_remotecache.trust_store_path }} + JKS + {{ keycloak_remotecache.trust_store_password }} + TOPOLOGY_AWARE + + +{% endfor %} + + + true + org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory + false + {{ keycloak_remotecache.username }} + {{ keycloak_remotecache.password }} + {{ keycloak_remotecache.realm | default('default') }} + {{ keycloak_remotecache.server_name }} + {{ keycloak_remotecache.sasl_mechanism }} + {{ keycloak_remotecache.use_ssl }} + {{ keycloak_remotecache.trust_store_path }} + JKS + {{ keycloak_remotecache.trust_store_password }} + TOPOLOGY_AWARE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_ha_discovery == 'JDBC_PING' and keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + java:jboss/datasources/KeycloakDS + {{ keycloak_jdbc[keycloak_jdbc_engine].initialize_db }} + INSERT INTO JGROUPSPING (own_addr, cluster_name, ping_data) values (?, ?, ?) + DELETE FROM JGROUPSPING WHERE own_addr=? AND cluster_name=? + SELECT ping_data FROM JGROUPSPING WHERE cluster_name=? + +{% elif keycloak_ha_discovery == 'TCPPING' %} + + {{ keycloak_cluster_nodes | map(attribute='inventory_host') | join (',') }} + 0 + 3000 + 2 + +{% endif %} + + + + + + + + + 30000 + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + +{% if keycloak_ha_enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + default + + + + +{% if keycloak_modcluster.admin_url | length > 0 %} + +{% endif %} + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_jgroups_subnet is defined and keycloak_jgroups_subnet is not none and keycloak_jgroups_subnet | string | length > 0 %} + +{% elif ansible_default_ipv4 is defined and (ansible_default_ipv4.network + '/' + ansible_default_ipv4.netmask) | ansible.utils.ipaddr('net') | length > 0 %} + +{% else %} + +{% endif %} + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} +{% for modcluster in keycloak_modcluster.reverse_proxy_urls %} + + + +{% endfor %} +{% endif %} +{% if keycloak_ha_discovery == 'TCPPING' %} +{% for node in keycloak_cluster_nodes %} + + + +{% endfor %} +{% endif %} + + + + + diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone.xml.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone.xml.j2 new file mode 100644 index 00000000..39b76146 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/templates/standalone.xml.j2 @@ -0,0 +1,626 @@ + +{{ ansible_managed | comment('xml') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + {{ keycloak_jdbc[keycloak_jdbc_engine].connection_url }} + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }} + + 20 + + + {{ keycloak_jdbc[keycloak_jdbc_engine].db_user }} + {{ keycloak_jdbc[keycloak_jdbc_engine].db_password }} + + + {{ keycloak_jdbc[keycloak_jdbc_engine].validate_query }} + {{ keycloak_db_background_validate_on_match }} +{% if keycloak_db_background_validation_millis | int > 0 or keycloak_db_background_validation %} + {{ keycloak_db_background_validation }} + {{ keycloak_db_background_validation_millis }} +{% endif %} + +{% else %} + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + +{% endif %} + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_class }} + {{ keycloak_jdbc[keycloak_jdbc_engine].xa_datasource_class }} + +{% endif %} + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + +{% if keycloak_ha_enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + default + + + + +{% if (keycloak_modcluster.admin_url | default('', true)) | length > 0 %} + +{% endif %} + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} +{% for modcluster in keycloak_modcluster.reverse_proxy_urls %} + + + +{% endfor %} +{% endif %} + + diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/debian.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/debian.yml new file mode 100644 index 00000000..b005b0a1 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/debian.yml @@ -0,0 +1,12 @@ +--- +keycloak_varjvm_package: "{{ keycloak_jvm_package | default('openjdk-11-jdk-headless') }}" +keycloak_prereq_package_list: + - "{{ keycloak_varjvm_package }}" + - unzip + - procps + - apt + - tzdata +keycloak_configure_iptables: true +keycloak_sysconf_file: /etc/default/keycloak +keycloak_pkg_java_home: "/usr/lib/jvm/java-{{ keycloak_varjvm_package | \ + regex_search('(?!:openjdk-)[0-9.]+') }}-openjdk-{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/main.yml new file mode 100644 index 00000000..bcfe24fd --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/main.yml @@ -0,0 +1,105 @@ +--- +# internal variables below + +keycloak_config: + home: "{{ keycloak_jboss_home }}" + config_dir: "{{ keycloak_config_dir }}" + bundle: "{{ keycloak_archive }}" + service_name: "{{ keycloak_service_name }}" + health_url: "{{ keycloak_management_url }}/health" + cli_path: "{{ keycloak_jboss_home }}/bin/jboss-cli.sh" + config_template_source: "{{ keycloak_config_override_template if keycloak_config_override_template | length > 0 \ + else 'standalone-ha.xml.j2' if keycloak_remote_cache_enabled else 'standalone.xml.j2' }}" + features: "{{ keycloak_features }}" + +# database +keycloak_jdbc: + postgres: + enabled: "{{ (keycloak_ha_enabled or keycloak_db_enabled) and keycloak_jdbc_engine == 'postgres' }}" + driver_class: org.postgresql.Driver + xa_datasource_class: org.postgresql.xa.PGXADataSource + driver_module_name: "org.postgresql" + driver_module_dir: "{{ keycloak_jboss_home }}/modules/org/postgresql/main" + driver_version: "{{ keycloak_jdbc_driver_version }}" + driver_jar_filename: "postgresql-{{ keycloak_jdbc_driver_version }}.jar" + driver_jar_url: > + {{ keycloak_maven_central }}org/postgresql/postgresql/{{ keycloak_jdbc_driver_version }}/postgresql-{{ keycloak_jdbc_driver_version }}.jar + connection_url: "{{ keycloak_jdbc_url }}" + db_user: "{{ keycloak_db_user }}" + db_password: "{{ keycloak_db_pass }}" + validate_query: "{{ keycloak_db_valid_conn_sql | default('select 1') }}" + initialize_db: > + CREATE TABLE IF NOT EXISTS JGROUPSPING ( + own_addr varchar(200) NOT NULL, + cluster_name varchar(200) NOT NULL, + updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ping_data BYTEA, + constraint PK_JGROUPSPING PRIMARY KEY (own_addr, cluster_name)) + mariadb: + enabled: "{{ (keycloak_ha_enabled or keycloak_db_enabled) and keycloak_jdbc_engine == 'mariadb' }}" + driver_class: org.mariadb.jdbc.Driver + xa_datasource_class: org.mariadb.jdbc.MariaDbDataSource + driver_module_name: "org.mariadb" + driver_module_dir: "{{ keycloak_jboss_home }}/modules/org/mariadb/main" + driver_version: "{{ keycloak_jdbc_driver_version }}" + driver_jar_filename: "mariadb-java-client-{{ keycloak_jdbc_driver_version }}.jar" + driver_jar_url: > + {{ keycloak_maven_central }}org/mariadb/jdbc/mariadb-java-client/{{ keycloak_jdbc_driver_version }}/mariadb-java-client-{{ keycloak_jdbc_driver_version }}.jar + connection_url: "{{ keycloak_jdbc_url }}" + db_user: "{{ keycloak_db_user }}" + db_password: "{{ keycloak_db_pass }}" + validate_query: "{{ keycloak_db_valid_conn_sql | default('select 1') }}" + initialize_db: > + CREATE TABLE IF NOT EXISTS JGROUPSPING ( + own_addr varchar(200) NOT NULL, + cluster_name varchar(200) NOT NULL, + updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + ping_data varbinary(5000) DEFAULT NULL, + PRIMARY KEY (own_addr, cluster_name)) + ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin + sqlserver: + enabled: "{{ (keycloak_ha_enabled or keycloak_db_enabled) and keycloak_jdbc_engine == 'sqlserver' }}" + driver_class: com.microsoft.sqlserver.jdbc.SQLServerDriver + xa_datasource_class: com.microsoft.sqlserver.jdbc.SQLServerXADataSource + driver_module_name: "com.microsoft.sqlserver" + driver_module_dir: "{{ keycloak_jboss_home }}/modules/com/microsoft/sqlserver/main" + driver_version: "{{ keycloak_jdbc_driver_version }}" + driver_jar_filename: "mssql-java-client-{{ keycloak_jdbc_driver_version }}.jar" + driver_jar_url: > + {{ keycloak_maven_central }}com/microsoft/sqlserver/mssql-jdbc/{{ keycloak_jdbc_driver_version }}.jre11/mssql-jdbc-{{ keycloak_jdbc_driver_version }}.jre11.jar + connection_url: "{{ keycloak_jdbc_url }}" + db_user: "{{ keycloak_db_user }}" + db_password: "{{ keycloak_db_pass }}" + validate_query: "{{ keycloak_db_valid_conn_sql | default('select 1') }}" + initialize_db: > + IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[JGROUPSPING]') AND type in (N'U')) + BEGIN + CREATE TABLE JGROUPSPING ( + own_addr varchar(200) NOT NULL, + cluster_name varchar(200) NOT NULL, + updated DATETIME2 DEFAULT SYSUTCDATETIME(), + ping_data varbinary(5000) DEFAULT NULL, + PRIMARY KEY (own_addr, cluster_name)) + END + +# reverse proxy mod_cluster +keycloak_modcluster: + enabled: "{{ keycloak_ha_enabled or keycloak_modcluster_enabled }}" + reverse_proxy_urls: "{{ keycloak_modcluster_urls }}" + frontend_url: "{{ keycloak_frontend_url }}" + force_frontend_url: "{{ keycloak_frontend_url_force }}" + admin_url: "{{ keycloak_admin_url | default('') }}" + +# infinispan +keycloak_remotecache: + enabled: "{{ keycloak_ha_enabled }}" + username: "{{ keycloak_infinispan_user }}" + password: "{{ keycloak_infinispan_pass }}" + realm: default + sasl_mechanism: "{{ keycloak_infinispan_sasl_mechanism }}" + server_name: "{{ keycloak_infinispan_url }}" + use_ssl: "{{ keycloak_infinispan_use_ssl }}" + trust_store_path: "{{ keycloak_infinispan_trust_store_path }}" + trust_store_password: "{{ keycloak_infinispan_trust_store_password }}" + +keycloak_maven_central: https://repo1.maven.org/maven2/ diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/redhat.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/redhat.yml new file mode 100644 index 00000000..6c368474 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak/vars/redhat.yml @@ -0,0 +1,10 @@ +--- +keycloak_varjvm_package: "{{ keycloak_jvm_package | default('java-1.8.0-openjdk-headless') }}" +keycloak_prereq_package_list: + - "{{ keycloak_varjvm_package }}" + - unzip + - procps-ng + - initscripts + - tzdata-java +keycloak_sysconf_file: /etc/sysconfig/keycloak +keycloak_pkg_java_home: "/etc/alternatives/jre_{{ keycloak_varjvm_package | regex_search('(?<=java-)[0-9.]+') }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/README.md b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/README.md new file mode 100644 index 00000000..93e8efad --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/README.md @@ -0,0 +1,314 @@ +keycloak_quarkus +================ + +Install [keycloak](https://keycloak.org/) >= 20.0.0 (quarkus) server configurations. + + +Requirements +------------ + +This role requires the `python3-netaddr` and `lxml` library installed on the controller node. + +* to install via yum/dnf: `dnf install python3-netaddr python3-lxml` +* to install via apt: `apt install python3-netaddr python3-lxml` +* or via the collection: `pip install -r requirements.txt` + + +Dependencies +------------ + +The roles depends on: + +* [middleware_automation.common](https://github.com/ansible-middleware/common) +* [ansible-posix](https://docs.ansible.com/ansible/latest/collections/ansible/posix/index.html) + +To install all the dependencies via galaxy: + + ansible-galaxy collection install -r requirements.yml + +Role Defaults +------------- + +#### Installation options + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_version`| keycloak.org package version | `26.6.2` | +|`keycloak_quarkus_offline_install` | Perform an offline install | `False`| +|`keycloak_quarkus_dest`| Installation root path | `/opt/keycloak` | +|`keycloak_quarkus_download_url` | Download URL for keycloak | `https://github.com/keycloak/keycloak/releases/download/{{ keycloak_quarkus_version }}/{{ keycloak_quarkus_archive }}` | +|`keycloak_quarkus_download_path`| Path local to controller for offline/download of install archives | `{{ lookup('env', 'PWD') }}` | + + +#### Service configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_bootstrap_admin_user`| Administration console user account | `admin` | +|`keycloak_quarkus_admin_user`| Deprecated, use `keycloak_quarkus_bootstrap_admin_user` instead. | | +|`keycloak_quarkus_bind_address`| Deprecated, use `keycloak_quarkus_http_host` instead | `0.0.0.0` | +|`keycloak_quarkus_host`| Deprecated, use `keycloak_quarkus_hostname` instead. | | +|`keycloak_quarkus_port`| Deprecated, use `keycloak_quarkus_hostname` instead. | | +|`keycloak_quarkus_path`| Deprecated, use `keycloak_quarkus_hostname` instead. | | +|`keycloak_quarkus_service_user`| Posix account username | `keycloak` | +|`keycloak_quarkus_service_group`| Posix account group | `keycloak` | +|`keycloak_quarkus_service_restart_always`| systemd restart always behavior activation | `False` | +|`keycloak_quarkus_service_restart_on_failure`| systemd restart on-failure behavior activation | `False` | +|`keycloak_quarkus_service_restartsec`| systemd RestartSec | `10s` | +|`keycloak_quarkus_jvm_package`| RHEL java package runtime | `java-21-openjdk-headless` | +|`keycloak_quarkus_java_home`| JAVA_HOME of installed JRE, leave empty for using specified keycloak_quarkus_jvm_package RPM path | `None` | +|`keycloak_quarkus_java_heap_opts`| Heap memory JVM setting | `-Xms1024m -Xmx2048m` | +|`keycloak_quarkus_java_jvm_opts`| Other JVM settings | same as keycloak | +|`keycloak_quarkus_java_opts`| JVM arguments; if overridden, it takes precedence over `keycloak_quarkus_java_*` | `{{ keycloak_quarkus_java_heap_opts + ' ' + keycloak_quarkus_java_jvm_opts }}` | +|`keycloak_quarkus_additional_env_vars` | List of additional env variables of { key: str, value: str} to be put in sysconfig file, see https://www.keycloak.org/server/all-config | `[]` | +|`keycloak_quarkus_frontend_url`| Deprecated, use `keycloak_quarkus_hostname` instead. | | +|`keycloak_quarkus_admin_url`| Deprecated, use `keycloak_quarkus_hostname_admin` instead. | | +|`keycloak_quarkus_health_check_url`| Full URL (including scheme, host, path, fragment etc.) used for health check endpoint; keycloak_quarkus_hostname will NOT be prepended; helpful when health checks should happen against http port, but keycloak_quarkus_hostname uses https scheme per default | `{{ keycloak_quarkus_hostname ~ keycloak_quarkus_http_relative_path.rstrip('/') ~ '/' ~ keycloak_quarkus_health_check_url_path }}` | +|`keycloak_quarkus_health_check_url_path`| Path to the health check endpoint; keycloak_quarkus_hostname will be prepended automatically; Note that keycloak_quarkus_health_check_url takes precedence over this property | `realms/{{ keycloak_quarkus_master_realm }}/.well-known/openid-configuration` | +|`keycloak_quarkus_proxy_headers`| Parse reverse proxy headers (`forwarded` or `xforwarded`) | `""` | +|`keycloak_quarkus_config_key_store_file`| Path to the configuration key store; only used if `keycloak_quarkus_config_key_store_password` is not empty | `{{ keycloak.home }}/conf/conf_store.p12` if `keycloak_quarkus_config_key_store_password != ''`, else `''` | +|`keycloak_quarkus_config_key_store_password`| Password of the configuration keystore; if non-empty, `keycloak_quarkus_db_pass` will be saved to the keystore at `keycloak_quarkus_config_key_store_file` instead of being written to the configuration file in clear text | `""` | +|`keycloak_quarkus_configure_firewalld` | Ensure firewalld is running and configure keycloak ports | `False` | +|`keycloak_quarkus_configure_iptables` | Ensure iptables is configured for keycloak ports | `False` | + + +#### High-availability + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_ha_enabled`| Enable auto configuration for database backend, clustering and remote caches on infinispan | `False` | +|`keycloak_quarkus_ha_discovery`| Discovery protocol for HA cluster members | `JDBCPING` | +|`keycloak_quarkus_db_enabled`| Enable auto configuration for database backend | `True` if `keycloak_quarkus_ha_enabled` is True, else `False` | +|`keycloak_quarkus_jgroups_ip`| Host jgroups IP. If changing this variable you must make sure it is always set for all hosts in your cluster. | `{{ ansible_default_ipv4.address }}` | +|`keycloak_quarkus_jgroups_port`| jgroups cluster tcp port | `7800` | +|`keycloak_quarkus_systemd_wait_for_port` | Whether systemd unit should wait for keycloak port before returning | `{{ keycloak_quarkus_ha_enabled }}` | +|`keycloak_quarkus_systemd_wait_for_port_number`| Which port the systemd unit should wait for | `{{ keycloak_quarkus_https_port }}` | +|`keycloak_quarkus_systemd_wait_for_log` | Whether systemd unit should wait for service to be up in logs | `false` | +|`keycloak_quarkus_systemd_wait_for_timeout`| How long to wait for service to be alive (seconds) | `60` | +|`keycloak_quarkus_systemd_wait_for_delay`| Activation delay for service systemd unit (seconds) | `10` | +|`keycloak_quarkus_restart_strategy`| Strategy task file for restarting in HA (one of provided restart/['serial.yml','none.yml','serial_then_parallel.yml']) or path to file when providing custom strategy | `restart/serial.yml` | +|`keycloak_quarkus_restart_health_check`| Whether to wait for successful health check after restart | `true` | +|`keycloak_quarkus_restart_health_check_delay`| Seconds to let pass before starting healch checks | `10` | +|`keycloak_quarkus_restart_health_check_retries`| Number of attempts for successful health check before failing | `25` | +|`keycloak_quarkus_validate_certs`| Whether to validate SSL certificates when performing health checks | `true` | +|`keycloak_quarkus_restart_pause`| Seconds to wait between restarts in HA strategy | `15` | + + +#### Hostname configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_hostname`| Address at which is the server exposed. Can be a full URL, or just a hostname. When only hostname is provided, scheme, port and context path are resolved from the request. | | +|`keycloak_quarkus_hostname_admin`| Set the base URL for accessing the administration console, including scheme, host, port and path | `` | +|`keycloak_quarkus_hostname_strict`| Disables dynamically resolving the hostname from request headers | `true` | +|`keycloak_quarkus_hostname_backchannel_dynamic`| Enables dynamic resolving of backchannel URLs, including hostname, scheme, port and context path. Set to true if your application accesses Keycloak via a private network. If set to true, hostname option needs to be specified as a full URL. | `false` | +|`keycloak_quarkus_hostname_strict_backchannel`| Deprecated, use (the inverted!)`keycloak_quarkus_hostname_backchannel_dynamic` instead. | | + + +#### HTTP(S) configuration +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_http_relative_path`| Set the path relative to / for serving resources. The path must start with a / | `/` | +|`keycloak_quarkus_http_host`| The http host, ie. the address used to bind the service | `0.0.0.0` | +|`keycloak_quarkus_http_port`| HTTP listening port | `8080` | +|`keycloak_quarkus_https_port`| TLS HTTP listening port | `8443` | +|`keycloak_quarkus_http_management_port`| Port of the management interface. Relevant only when something is exposed on the management interface - see the guide for details. | `9000` | +|`keycloak_quarkus_https_key_store_file`| The file path to the key store | `{{ keycloak.home }}/conf/key_store.p12` | +|`keycloak_quarkus_https_key_store_password`| Password for the key store | `""` | +|`keycloak_quarkus_https_trust_store_enabled`| Enable configuration of the https trust store | `False` | +|`keycloak_quarkus_https_trust_store_file`| The file path to the trust store | `{{ keycloak.home }}/conf/trust_store.p12` | +|`keycloak_quarkus_https_trust_store_password`| Password for the trust store | `""` | +|`keycloak_quarkus_https_key_file_enabled`| Enable listener on HTTPS port | `False` | +|`keycloak_quarkus_key_file_copy_enabled`| Enable copy of key file to target host | `False` | +|`keycloak_quarkus_key_content`| Content of the TLS private key. Use `"{{ lookup('file', 'server.key.pem') }}"` to lookup a file. | `""` | +|`keycloak_quarkus_key_file`| The file path to a private key in PEM format | `/etc/pki/tls/private/server.key.pem` | +|`keycloak_quarkus_cert_file_copy_enabled`| Enable copy of cert file to target host | `False`| +|`keycloak_quarkus_cert_file_src`| Set the source file path | `""` | +|`keycloak_quarkus_cert_file`| The file path to a server certificate or certificate chain in PEM format | `/etc/pki/tls/certs/server.crt.pem` | +|`keycloak_quarkus_https_key_store_enabled`| Enable configuration of HTTPS via a key store | `False` | +|`keycloak_quarkus_key_store_file`| Deprecated, use `keycloak_quarkus_https_key_store_file` instead. || +|`keycloak_quarkus_key_store_password`| Deprecated, use `keycloak_quarkus_https_key_store_password` instead.|| +|`keycloak_quarkus_http_relative_path` | Set the path relative to / for serving resources. The path must start with a / | `/` | +|`keycloak_quarkus_http_management_relative_path` | Set the path relative to / for serving resources from management interface. The path must start with a /. If not given, the value is inherited from HTTP options. Relevant only when something is exposed on the management interface - see the guide for details. | `/` | +|`keycloak_quarkus_http_enabled`| Enable listener on HTTP port | `True` | + + +#### Infinispan configuration + +| Variable | Description | Default | +| :------------------------------------------------- | :------------------------------ | :----------------------------------------------------------- | +| `keycloak_quarkus_cache_managed_infinispan_config` | Manage infinispan configuration | `"{{ keycloak_quarkus_version is version('26.4.0', '<') }}"` | +| `keycloak_quarkus_cache_infinispan_template` | Infinispan cache template file | `cache-ispn.xml` | + +As explained in the [official documentation](https://www.keycloak.org/server/caching#_modifying_cache_configuration_defaults), since version 26.4, it is recommended not to modify the XML configuration file but rather to configure the cache via the keycloak.properties file. By default, the role will no longer automatically deploy this file for versions higher than 26.4. + +For earlier versions, it is possible to override the given template to customize the cache using the `keycloak_quarkus_cache_infinispan_template` variable. + +#### Database configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_db_engine` | Database engine [mariadb,postres,mssql] | `postgres` | +|`keycloak_quarkus_db_user` | User for database connection | `keycloak-user` | +|`keycloak_quarkus_db_pass` | Password for database connection | `keycloak-pass` | +|`keycloak_quarkus_db_url` | JDBC URL for connecting to database | `jdbc:postgresql://localhost:5432/keycloak` | +|`keycloak_quarkus_db_driver_version` | Version for JDBC engine driver | `9.4.1212` | + + +#### Cache configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_cache_remote` | Whether to connect to remote cache infinispan server | `false` | +|`keycloak_quarkus_cache_remote_username` | Username for connecting to infinispan | `supervisor` | +|`keycloak_quarkus_cache_remote_password` | Password for connecting to infinispan | `supervisor` | +|`keycloak_quarkus_cache_remote_host` | Hostname for connecting to infinispan | `localhost` | +|`keycloak_quarkus_cache_remote_port`| Port for connecting to infinispan | `11222` | +|`keycloak_quarkus_cache_remote_sasl_mechanism` | Infinispan auth mechanism | `SCRAM-SHA-512` | +|`keycloak_quarkus_cache_remote_tls_enabled` | Whether infinispan uses TLS connection | `false` | +|`keycloak_quarkus_cache_embedded_properties` | Embedded cache properties | `` | + + +#### Logging configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_log`| Enable one or more log handlers in a comma-separated list | `file` | +|`keycloak_quarkus_log_level`| The log level of the root category or a comma-separated list of individual categories and their levels | `info` | +|`keycloak_quarkus_log_file`| Set the log file path and filename relative to keycloak home | `data/log/keycloak.log` | +|`keycloak_quarkus_log_format`| Set a format specific to file log entries | `%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n` | +|`keycloak_quarkus_log_target`| Set the destination of the keycloak log folder link | `/var/log/keycloak` | +|`keycloak_quarkus_log_max_file_size`| Set the maximum log file size before a log rotation happens; A size configuration option recognises string in this format (shown as a regular expression): `[0-9]+[KkMmGgTtPpEeZzYy]?`. If no suffix is given, assume bytes. | `10M` | +|`keycloak_quarkus_log_max_backup_index`| Set the maximum number of archived log files to keep | `10` | +|`keycloak_quarkus_log_file_suffix`| Set the log file handler rotation file suffix. When used, the file will be rotated based on its suffix; Note: If the suffix ends with `.zip` or `.gz`, the rotation file will also be compressed. | `.yyyy-MM-dd.zip` | + + +#### Miscellaneous configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_metrics_enabled`| Whether to enable metrics | `False` | +|`keycloak_quarkus_health_enabled`| If the server should expose health check endpoints on the management interface | `True` | +|`keycloak_quarkus_archive` | keycloak install archive filename | `keycloak-{{ keycloak_quarkus_version }}.zip` | +|`keycloak_quarkus_installdir` | Installation path | `{{ keycloak_quarkus_dest }}/keycloak-{{ keycloak_quarkus_version }}` | +|`keycloak_quarkus_home` | Installation work directory | `{{ keycloak_quarkus_installdir }}` | +|`keycloak_quarkus_config_dir` | Path for configuration | `{{ keycloak_quarkus_home }}/conf` | +|`keycloak_quarkus_master_realm` | Name for rest authentication realm | `master` | +|`keycloak_auth_client` | Authentication client for configuration REST calls | `admin-cli` | +|`keycloak_quarkus_force_install` | Remove pre-existing versions of service | `False` | +|`keycloak_quarkus_proxy_mode`| The proxy address forwarding mode if the server is behind a reverse proxy (deprecated) | `none` | +|`keycloak_quarkus_start_dev`| Whether to start the service in development mode (start-dev) | `False` | +|`keycloak_quarkus_transaction_xa_enabled`| Whether to use XA transactions | `True` | +|`keycloak_quarkus_spi_sticky_session_encoder_infinispan_should_attach_route`| If the route should be attached to cookies to reflect the node that owns a particular session. If false, route is not attached to cookies and we rely on the session affinity capabilities from reverse proxy | `True` | +|`keycloak_quarkus_show_deprecation_warnings`| Whether deprecation warnings should be shown | `True` | + + +#### Vault configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_ks_vault_enabled`| Whether to enable the vault SPI | `false` | +|`keycloak_quarkus_ks_vault_file`| The keystore path for the vault SPI | `{{ keycloak_quarkus_config_dir }}/keystore.p12` | +|`keycloak_quarkus_ks_vault_type`| Type of the keystore used for the vault SPI | `PKCS12` | + + +#### Configuring providers + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_providers`| List of provider definitions; see below | `[]` | + +Providers support different sources: + +* `url`: http download for providers not requiring authentication +* `maven`: maven download for providers hosted publicly on Apache Maven Central or private Maven repositories like Github Maven requiring authentication +* `local_path`: static providers to be uploaded + +Provider definition: + +```yaml +keycloak_quarkus_providers: + - id: http-client # required; "{{ id }}.jar" identifies the file name on RHBK + spi: connections # required if neither url, local_path nor maven are specified; required for setting properties + default: true # optional, whether to set default for spi, default false + restart: true # optional, whether to rebuild config and restart the service after deploying, default true + url: https://.../.../custom_spi.jar # optional, url for download via http + local_path: my_theme_spi.jar # optional, path on local controller for SPI to be uploaded + remote: true # optional, whether to copy from localhost or remotely, see https://docs.ansible.com/ansible/latest/collections/ansible/builtin/copy_module.html#parameter-remote_src, default false + maven: # optional, for download using maven + repository_url: https://maven.pkg.github.com/OWNER/REPOSITORY # optional, maven repo url + group_id: my.group # optional, maven group id + artifact_id: artifact # optional, maven artifact id + version: 24.0.5 # optional, defaults to latest + username: user # optional, cf. https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-apache-maven-registry#authenticating-to-github-packages + password: pat # optional, provide a PAT for accessing Github's Apache Maven registry + properties: # optional, list of key-values + - key: default-connection-pool-size + value: 10 + checksum: sha256:D98291AC[...]B6DC7B97 # optional, checksum used to verify integrity: + # for `url` SPIs, use format: :, cf. ; + # for `local_path` SPIs, use SHA1 format + # for `maven` SPIs, this field is ignored since maven has integrity verification methods enabled by default +``` + +the definition above will generate the following build command: + +``` +bin/kc.sh build --spi-connections-provider=http-client --spi-connections-http-client-default-connection-pool-size=10 +``` + + +#### Configuring policies + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_policies`| List of policy definitions; see below | `[]` | + +Provider definition: + +```yaml +keycloak_quarkus_policies: + - name: john-the-ripper.txt # required, resulting file name + url: https://github.com/danielmiessler/SecLists/raw/master/Passwords/Software/john-the-ripper.txt # required, url for download + type: password-blacklists # optional, defaults to `password-blacklists`; supported values: [`password-blacklists`] +``` + + +Role Variables +-------------- + +| Variable | Description | Required | +|:---------|:------------|----------| +|`keycloak_quarkus_bootstrap_admin_password`| Password of console admin account | `yes` | +|`keycloak_quarkus_admin_pass`| Deprecated, use `keycloak_quarkus_bootstrap_admin_password` instead. | | +|`keycloak_quarkus_ks_vault_pass`| The password for accessing the keystore vault SPI | `no` | +|`keycloak_quarkus_alternate_download_url`| Alternate location with optional authentication for downloading RHBK | `no` | +|`keycloak_quarkus_download_user`| Optional username for http authentication | `no*` | +|`keycloak_quarkus_download_pass`| Optional password for http authentication | `no*` | +|`keycloak_quarkus_download_validate_certs`| Whether to validate certs for URL `keycloak_quarkus_alternate_download_url` | `no` | +|`keycloak_quarkus_jdbc_download_user`| Optional username for http authentication | `no*` | +|`keycloak_quarkus_jdbc_download_pass`| Optional password for http authentication | `no*` | +|`keycloak_quarkus_jdbc_download_validate_certs`| Whether to validate certs for URL `keycloak_quarkus_download_validate_certs` | `no` | + +`*` username/password authentication credentials must be both declared or both undefined + + +Role custom facts +----------------- + +The role uses the following [custom facts](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_vars_facts.html#adding-custom-facts) found in `/etc/ansible/facts.d/keycloak.fact` (and thus identified by the `ansible_local.keycloak.` prefix): + +| Variable | Description | +|:---------|:------------| +|`general.bootstrapped` | A custom fact indicating whether this role has been used for bootstrapping keycloak on the respective host before; set to `false` (e.g., when starting off with a new, empty database) ensures that the initial admin user as defined by `keycloak_quarkus_bootstrap_admin_user[_password]` gets created | + +License +------- + +Apache License 2.0 + + +Author Information +------------------ + +* [Guido Grazioli](https://github.com/guidograzioli) diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/defaults/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/defaults/main.yml new file mode 100644 index 00000000..d3879056 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/defaults/main.yml @@ -0,0 +1,194 @@ +--- +### Configuration specific to keycloak +keycloak_quarkus_version: 26.6.2 +keycloak_quarkus_archive: "keycloak-{{ keycloak_quarkus_version }}.zip" +keycloak_quarkus_download_url: "https://github.com/keycloak/keycloak/releases/download/{{ keycloak_quarkus_version }}/{{ keycloak_quarkus_archive }}" +keycloak_quarkus_installdir: "{{ keycloak_quarkus_dest }}/keycloak-{{ keycloak_quarkus_version }}" + +# Authentication for Keycloak binary download (e.g. from internal artifact repository) +keycloak_quarkus_binary_download_user: +keycloak_quarkus_binary_download_pass: + +# whether to install from local archive +keycloak_quarkus_offline_install: false + +keycloak_quarkus_show_deprecation_warnings: true + +### Install location and service settings +keycloak_quarkus_java_home: +keycloak_quarkus_dest: /opt/keycloak +keycloak_quarkus_home: "{{ keycloak_quarkus_installdir }}" +keycloak_quarkus_config_dir: "{{ keycloak_quarkus_home }}/conf" +keycloak_quarkus_download_path: "{{ lookup('env', 'PWD') }}" +keycloak_quarkus_start_dev: false +keycloak_quarkus_service_user: keycloak +keycloak_quarkus_service_group: keycloak +keycloak_quarkus_service_restart_always: false +keycloak_quarkus_service_restart_on_failure: false +keycloak_quarkus_service_restartsec: "10s" + +keycloak_quarkus_configure_firewalld: false +keycloak_quarkus_configure_iptables: false + +### administrator console password +keycloak_quarkus_bootstrap_admin_user: admin +keycloak_quarkus_bootstrap_admin_password: +keycloak_quarkus_master_realm: master + +### Configuration settings +keycloak_quarkus_bind_address: 0.0.0.0 # deprecated use keycloak_quarkus_http_host +keycloak_quarkus_http_host: 0.0.0.0 +keycloak_quarkus_http_enabled: true +keycloak_quarkus_http_port: 8080 +keycloak_quarkus_https_port: 8443 +keycloak_quarkus_http_management_port: 9000 +keycloak_quarkus_jgroups_port: 7800 +keycloak_quarkus_jgroups_bind_address: "{{ ansible_default_ipv4.address }}" +keycloak_quarkus_jgroups_external_addr: "{{ keycloak_quarkus_jgroups_bind_address }}" +keycloak_quarkus_jgroups_external_port: "{{ keycloak_quarkus_jgroups_port }}" +keycloak_quarkus_java_heap_opts: "-Xms1024m -Xmx2048m" +keycloak_quarkus_java_jvm_opts: > + -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 + -Dsun.err.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -XX:+ExitOnOutOfMemoryError + -Djava.security.egd=file:/dev/urandom -XX:+UseParallelGC -XX:GCTimeRatio=4 + -XX:AdaptiveSizePolicyWeight=90 -XX:FlightRecorderOptions=stackdepth=512 +keycloak_quarkus_jgroups_opts: > + -Djgroups.bind.address={{ keycloak_quarkus_jgroups_bind_address }} + -Djgroups.external_port={{ keycloak_quarkus_jgroups_external_port }} + -Djgroups.external_addr={{ keycloak_quarkus_jgroups_external_addr }} +keycloak_quarkus_java_opts: "{{ ' '.join((keycloak_quarkus_jgroups_opts, keycloak_quarkus_java_heap_opts, keycloak_quarkus_java_jvm_opts)) }}" +keycloak_quarkus_additional_env_vars: [] + +### TLS/HTTPS configuration +keycloak_quarkus_https_key_file_enabled: false +keycloak_quarkus_key_file_copy_enabled: false +keycloak_quarkus_key_content: "" +keycloak_quarkus_key_file: "/etc/pki/tls/private/server.key.pem" +keycloak_quarkus_cert_file_copy_enabled: false +keycloak_quarkus_cert_file_src: "" +keycloak_quarkus_cert_file: "/etc/pki/tls/certs/server.crt.pem" +#### key store configuration +keycloak_quarkus_https_key_store_enabled: false +keycloak_quarkus_https_key_store_file: "{{ keycloak.home }}/conf/key_store.p12" +keycloak_quarkus_https_key_store_password: '' +##### trust store configuration +keycloak_quarkus_https_trust_store_enabled: false +keycloak_quarkus_https_trust_store_file: "{{ keycloak.home }}/conf/trust_store.p12" +keycloak_quarkus_https_trust_store_password: '' +### configuration key store configuration +keycloak_quarkus_config_key_store_file: "{{ keycloak.home }}/conf/conf_store.p12" +keycloak_quarkus_config_key_store_password: '' + +### Enable configuration for database backend, clustering and remote caches on infinispan +keycloak_quarkus_ha_enabled: false +keycloak_quarkus_ha_discovery: "JDBCPING" +### Enable database configuration, must be enabled when HA is configured +keycloak_quarkus_db_enabled: "{{ keycloak_quarkus_ha_enabled }}" +keycloak_quarkus_systemd_wait_for_port: "{{ keycloak_quarkus_ha_enabled }}" +keycloak_quarkus_systemd_wait_for_port_number: "{{ keycloak_quarkus_https_port }}" +keycloak_quarkus_systemd_wait_for_log: false +keycloak_quarkus_systemd_wait_for_timeout: 60 +keycloak_quarkus_systemd_wait_for_delay: 10 + +### keycloak frontend url +keycloak_quarkus_hostname: +keycloak_quarkus_hostname_admin: "" + +### Set the path relative to / for serving resources. The path must start with a / +### (set to `/auth` for retrocompatibility with pre-quarkus releases) +keycloak_quarkus_http_relative_path: / + +# Disables dynamically resolving the hostname from request headers. +# Should always be set to true in production, unless proxy verifies the Host header. +keycloak_quarkus_hostname_strict: true +# Enables dynamic resolving of backchannel URLs, including hostname, scheme, port and context path. +# Set to true if your application accesses Keycloak via a private network. If set to true, keycloak_quarkus_hostname option needs to be specified as a full URL. +keycloak_quarkus_hostname_backchannel_dynamic: false + +# The proxy headers that should be accepted by the server. ['', 'forwarded', 'xforwarded'] +keycloak_quarkus_proxy_headers: "" + +# deprecated: proxy address forwarding mode if the server is behind a reverse proxy. [none, edge, reencrypt, passthrough] +keycloak_quarkus_proxy_mode: none + +# disable xa transactions +keycloak_quarkus_transaction_xa_enabled: true + +# If the route should be attached to cookies to reflect the node that owns a particular session. +# If false, route is not attached to cookies and we rely on the session affinity capabilities from reverse proxy +keycloak_quarkus_spi_sticky_session_encoder_infinispan_should_attach_route: true + +keycloak_quarkus_metrics_enabled: false +keycloak_quarkus_health_enabled: true + +### infinispan; must read: https://forum.keycloak.org/t/keycloak-26-4-7-ha/31202 +keycloak_quarkus_cache_managed_infinispan_config: "{{ keycloak_quarkus_version is version('26.4.0', '<') }}" +keycloak_quarkus_cache_infinispan_template: cache-ispn.xml + +### caches; must read: https://www.keycloak.org/2024/12/storing-sessions-in-kc26 +### embedded caches +# https://www.keycloak.org/server/caching +keycloak_quarkus_cache_embedded_properties: "" + +### infinispan remote caches access (hotrod) +# https://www.keycloak.org/server/caching#_remote_cache +keycloak_quarkus_cache_remote: false +keycloak_quarkus_cache_remote_username: supervisor +keycloak_quarkus_cache_remote_password: supervisor +keycloak_quarkus_cache_remote_host: localhost +keycloak_quarkus_cache_remote_port: 11222 +keycloak_quarkus_cache_remote_tls_enabled: false +keycloak_quarkus_cache_remote_sasl_mechanism: SCRAM-SHA-512 + + +### database backend engine: values [ 'postgres', 'mariadb' ] +keycloak_quarkus_db_engine: postgres +### database backend credentials +keycloak_quarkus_db_user: keycloak-user +keycloak_quarkus_db_pass: keycloak-pass +keycloak_quarkus_db_url: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].url }}" +keycloak_quarkus_db_driver_version: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].version }}" +# override the variables above, following defaults show recommended version as per +# https://access.redhat.com/articles/7033107 +keycloak_quarkus_default_jdbc: + postgres: + url: 'jdbc:postgresql://localhost:5432/keycloak' + version: 42.7.7 + mariadb: + url: 'jdbc:mariadb://localhost:3306/keycloak' + version: 3.5.2 + mssql: + url: 'jdbc:sqlserver://localhost:1433;databaseName=keycloak;' + version: 13.2.0 + driver_jar_url: "https://repo1.maven.org/maven2/com/microsoft/sqlserver/mssql-jdbc/13.2.0.jre11/mssql-jdbc-13.2.0.jre11.jar" +### logging configuration +keycloak_quarkus_log: file +keycloak_quarkus_log_level: info +keycloak_quarkus_log_file: data/log/keycloak.log +keycloak_quarkus_log_format: '%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n' +keycloak_quarkus_log_target: /var/log/keycloak +keycloak_quarkus_log_max_file_size: 10M +keycloak_quarkus_log_max_backup_index: 10 +keycloak_quarkus_log_file_suffix: '.yyyy-MM-dd.zip' + +# keystore-based vault +keycloak_quarkus_ks_vault_enabled: false +keycloak_quarkus_ks_vault_file: "{{ keycloak_quarkus_config_dir }}/keystore.p12" +keycloak_quarkus_ks_vault_type: PKCS12 +keycloak_quarkus_ks_vault_pass: + +keycloak_quarkus_providers: [] +keycloak_quarkus_policies: [] +keycloak_quarkus_supported_policy_types: ['password-blacklists'] + +# files in restart directory (one of [ 'serial', 'none', 'serial_then_parallel' ]), or path to file when providing custom strategy +keycloak_quarkus_restart_strategy: restart/serial.yml +keycloak_quarkus_restart_health_check: true +keycloak_quarkus_restart_health_check_delay: 10 +keycloak_quarkus_restart_health_check_retries: 25 +keycloak_quarkus_validate_certs: true +keycloak_quarkus_health_check_url_path: "realms/{{ keycloak_quarkus_master_realm }}/.well-known/openid-configuration" +keycloak_quarkus_health_check_url: "{{ keycloak_quarkus_hostname ~ keycloak_quarkus_http_relative_path.rstrip('/') ~ '/' ~ keycloak_quarkus_health_check_url_path }}" +keycloak_quarkus_restart_pause: 15 + +keycloak_quarkus_force_install: false diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/handlers/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/handlers/main.yml new file mode 100644 index 00000000..c929f717 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/handlers/main.yml @@ -0,0 +1,21 @@ +--- +- name: "Invalidate theme cache: {{ keycloak.service_name }}" + ansible.builtin.include_tasks: invalidate_theme_cache.yml + listen: "invalidate keycloak theme cache" +# handler should be invoked anytime a [build configuration](https://www.keycloak.org/server/all-config?f=build) changes +- name: "Rebuild config: {{ keycloak.service_name }}" + ansible.builtin.include_tasks: rebuild_config.yml + listen: "rebuild keycloak config" +- name: "Bootstrapped" + ansible.builtin.include_tasks: bootstrapped.yml + listen: bootstrapped +- name: "Restart {{ keycloak.service_name }}" + ansible.builtin.include_tasks: + file: "{{ keycloak_quarkus_restart_strategy if keycloak_quarkus_ha_enabled else 'restart.yml' }}" + listen: "restart keycloak" +- name: "Display deprecation warning" + ansible.builtin.fail: + msg: "Deprecation warning: you are using the deprecated variable '{{ keycloak_quarkus_deprecated_variable | d('NotSet') }}', check docs on how to upgrade." + failed_when: false + changed_when: true + listen: "print deprecation warning" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/meta/argument_specs.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/meta/argument_specs.yml new file mode 100644 index 00000000..91db699c --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/meta/argument_specs.yml @@ -0,0 +1,573 @@ +argument_specs: + main: + options: + keycloak_quarkus_version: + default: "26.6.2" + description: "keycloak.org package version" + type: "str" + keycloak_quarkus_archive: + default: "keycloak-{{ keycloak_quarkus_version }}.zip" + description: "keycloak install archive filename" + type: "str" + keycloak_quarkus_download_url: + default: "https://github.com/keycloak/keycloak/releases/download/{{ keycloak_quarkus_version }}/{{ keycloak_quarkus_archive }}" + description: "Download URL for keycloak" + type: "str" + keycloak_quarkus_installdir: + default: "{{ keycloak_quarkus_dest }}/keycloak-{{ keycloak_quarkus_version }}" + description: "Installation path" + type: "str" + keycloak_quarkus_offline_install: + default: false + description: "Perform an offline install" + type: "bool" + keycloak_quarkus_jvm_package: + default: "java-21-openjdk-headless" + description: "RHEL java package runtime" + type: "str" + keycloak_quarkus_java_home: + description: "JAVA_HOME of installed JRE, leave empty for using specified keycloak_jvm_package RPM path" + type: "str" + keycloak_quarkus_dest: + default: "/opt/keycloak" + description: "Installation root path" + type: "str" + keycloak_quarkus_home: + default: "{{ keycloak_quarkus_installdir }}" + description: "Installation work directory" + type: "str" + keycloak_quarkus_config_dir: + default: "{{ keycloak_quarkus_home }}/conf" + description: "Path for configuration" + type: "str" + keycloak_quarkus_service_user: + default: "keycloak" + description: "Posix account username" + type: "str" + keycloak_quarkus_service_group: + default: "keycloak" + description: "Posix account group" + type: "str" + keycloak_quarkus_configure_firewalld: + default: false + description: "Ensure firewalld is running and configure keycloak ports" + type: "bool" + keycloak_quarkus_configure_iptables: + default: false + description: "Ensure firewalld is running and configure keycloak ports" + type: "bool" + keycloak_quarkus_service_restart_always: + default: false + description: "systemd restart always behavior of service; takes precedence over keycloak_service_restart_on_failure if true" + type: "bool" + keycloak_quarkus_service_restart_on_failure: + default: false + description: "systemd restart on-failure behavior of service" + type: "bool" + keycloak_quarkus_service_restartsec: + default: "10s" + description: "systemd RestartSec for service" + type: "str" + keycloak_quarkus_bootstrap_admin_user: + default: "admin" + description: "Administration user account, only for bootstrapping" + type: "str" + keycloak_quarkus_force_install: + default: false + description: "Remove pre-existing versions of service" + type: "bool" + keycloak_quarkus_bootstrap_admin_password: + required: true + description: "Password of admin account, only for bootstrapping" + type: "str" + keycloak_quarkus_master_realm: + default: "master" + description: "Name for rest authentication realm" + type: "str" + keycloak_quarkus_bind_address: + default: "0.0.0.0" + description: "Deprecated, use `keycloak_quarkus_http_host`" + type: "str" + keycloak_quarkus_hostname: + description: >- + Address at which is the server exposed. + Can be a full URL, or just a hostname. When only hostname is provided, scheme, port and context path are resolved from the request. + type: "str" + keycloak_quarkus_host: + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." + type: "str" + keycloak_quarkus_port: + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." + type: "int" + keycloak_quarkus_path: + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." + type: "str" + keycloak_quarkus_http_enabled: + default: true + description: "Enable listener on HTTP port" + type: "bool" + keycloak_quarkus_http_host: + default: '0.0.0.0' + description: "HTTP host, address for binding service ports" + type: "str" + keycloak_quarkus_http_port: + default: 8080 + description: "HTTP port" + type: "int" + keycloak_quarkus_health_check_url: + default: "{{ keycloak_quarkus_hostname ~ keycloak_quarkus_http_relative_path.rstrip('/') ~ '/' ~ keycloak_quarkus_health_check_url_path }}" + description: "Full URL (including scheme, host, path, fragment etc.) used for health check endpoint; keycloak_quarkus_hostname will NOT be prepended; helpful when health checks should happen against http port, but keycloak_quarkus_hostname uses https scheme per default" + type: "str" + keycloak_quarkus_health_check_url_path: + default: "realms/{{ keycloak_quarkus_master_realm }}/.well-known/openid-configuration" + description: "Path to the health check endpoint; keycloak_quarkus_hostname will be prepended automatically; Note that keycloak_quarkus_health_check_url takes precedence over this property" + type: "str" + keycloak_quarkus_https_key_file_enabled: + default: false + description: "Enable configuration of HTTPS via files in PEM format" + type: "bool" + keycloak_quarkus_key_file_copy_enabled: + default: false + description: "Enable copy of key file to target host" + type: "bool" + keycloak_quarkus_key_content: + default: "" + description: "Content of the TLS private key" + type: "str" + keycloak_quarkus_key_file: + default: "/etc/pki/tls/private/server.key.pem" + description: "The file path to a private key in PEM format" + type: "str" + keycloak_quarkus_cert_file_copy_enabled: + default: false + description: "Enable copy of cert file to target host" + type: "bool" + keycloak_quarkus_cert_file_src: + default: "" + description: "Set the source file path" + type: "str" + keycloak_quarkus_cert_file: + default: "/etc/pki/tls/certs/server.crt.pem" + description: "The file path to a server certificate or certificate chain in PEM format" + type: "str" + keycloak_quarkus_https_key_store_enabled: + default: false + description: "Enable configuration of HTTPS via a key store" + type: "bool" + keycloak_quarkus_key_store_file: + default: "" + description: "Deprecated, use `keycloak_quarkus_https_key_store_file` instead." + type: "str" + keycloak_quarkus_key_store_password: + default: "" + description: "Deprecated, use `keycloak_quarkus_https_key_store_password` instead." + type: "str" + keycloak_quarkus_https_key_store_file: + default: "{{ keycloak.home }}/conf/key_store.p12" + description: "The file path to the key store" + type: "str" + keycloak_quarkus_https_key_store_password: + default: "" + description: "Password for the key store" + type: "str" + keycloak_quarkus_https_trust_store_enabled: + default: false + description: "Enable configuration of the https trust store" + type: "bool" + keycloak_quarkus_https_trust_store_file: + default: "{{ keycloak.home }}/conf/trust_store.p12" + description: "The file path to the trust store" + type: "str" + keycloak_quarkus_https_trust_store_password: + default: "" + description: "Password for the trust store" + type: "str" + keycloak_quarkus_config_key_store_file: + default: "{{ keycloak.home }}/conf/conf_store.p12" + description: "Path to the configuration key store; only used if `keycloak_quarkus_config_key_store_password` is not empty" + type: "str" + keycloak_quarkus_config_key_store_password: + default: "" + description: > + Password of the configuration key store; if non-empty, `keycloak_quarkus_db_pass` will be saved to the key store + at `keycloak_quarkus_config_key_store_file` (instead of being written to the configuration file in clear text) + type: "str" + keycloak_quarkus_https_port: + default: 8443 + description: "HTTPS port" + type: "int" + keycloak_quarkus_http_management_port: + default: 9000 + description: "Port of the management interface. Relevant only when something is exposed on the management interface - see the guide for details." + type: "int" + keycloak_quarkus_jgroups_port: + description: 'jgroups bind port' + default: 7800 + type: "int" + keycloak_quarkus_jgroups_bind_address: + description: 'jgroups bind address' + default: "{{ ansible_default_ipv4.address }}" + type: "str" + keycloak_quarkus_jgroups_external_addr: + description: 'IP address that other instances in the Keycloak should use to contact this node' + default: "{{ keycloak_quarkus_jgroups_bind_address }}" + type: "str" + keycloak_quarkus_jgroups_external_port: + description: 'Port that other instances in the Keycloak cluster should use to contact this node' + default: "{{ keycloak_quarkus_jgroups_port }}" + type: "int" + keycloak_quarkus_jgroups_opts: + description: "JVM arguments for jgroups configuration" + default: "-Djgroups.bind.address={{ keycloak_quarkus_jgroups_bind_address }} -Djgroups.external_port={{ keycloak_quarkus_jgroups_external_port }} -Djgroups.external_addr={{ keycloak_quarkus_jgroups_external_addr }}" + type: "str" + keycloak_quarkus_java_heap_opts: + default: "-Xms1024m -Xmx2048m" + description: "Heap memory JVM setting" + type: "str" + keycloak_quarkus_java_jvm_opts: + default: > + -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.err.encoding=UTF-8 + -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -XX:+ExitOnOutOfMemoryError -Djava.security.egd=file:/dev/urandom -XX:+UseParallelGC + -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -XX:FlightRecorderOptions=stackdepth=512 + description: "Other JVM settings" + type: "str" + keycloak_quarkus_java_opts: + default: "{{ ' '.join((keycloak_quarkus_jgroups_opts, keycloak_quarkus_java_heap_opts, keycloak_quarkus_java_jvm_opts)) }}" + description: "JVM arguments, by default heap_opts + jvm_opts, if overriden it takes precedence over them" + type: "str" + keycloak_quarkus_additional_env_vars: + default: "[]" + description: "List of additional env variables of { key: str, value: str} to be put in sysconfig file" + type: "list" + keycloak_quarkus_ha_enabled: + default: false + description: "Enable auto configuration for database backend, clustering and remote caches on infinispan" + type: "bool" + keycloak_quarkus_ha_discovery: + default: "TCPPING" + description: "Discovery protocol for HA cluster members" + type: "str" + keycloak_quarkus_db_enabled: + default: "{{ True if keycloak_quarkus_ha_enabled else False }}" + description: "Enable auto configuration for database backend" + type: "str" + keycloak_quarkus_http_relative_path: + required: false + default: / + description: "Set the path relative to / for serving resources. The path must start with a /" + type: "str" + keycloak_quarkus_http_management_relative_path: + required: false + description: "Set the path relative to / for serving resources from management interface. The path must start with a /. If not given, the value is inherited from HTTP options. Relevant only when something is exposed on the management interface - see the guide for details." + type: "str" + keycloak_quarkus_frontend_url: + required: false + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." + type: "str" + keycloak_quarkus_hostname_admin: + required: false + description: "Service URL for the admin console" + type: "str" + keycloak_quarkus_admin_url: + required: false + description: "Deprecated in v26, use keycloak_quarkus_hostname_admin instead." + type: "str" + keycloak_quarkus_metrics_enabled: + default: false + description: "Whether to enable metrics" + type: "bool" + keycloak_quarkus_health_enabled: + default: true + description: "If the server should expose health check endpoints on the management interface" + type: "bool" + keycloak_quarkus_cache_remote: + description: "Whether to connect to remote cache infinispan server" + default: false + type: 'bool' + keycloak_quarkus_cache_remote_username: + default: "supervisor" + description: "Username for connecting to infinispan" + type: "str" + keycloak_quarkus_cache_remote_password: + default: "supervisor" + description: "Password for connecting to infinispan" + type: "str" + keycloak_quarkus_cache_remote_host: + default: "localhost" + description: "Hostname for connecting to infinispan" + type: "str" + keycloak_quarkus_cache_remote_port: + default: "11222" + description: "Port for connecting to infinispan" + type: "str" + keycloak_quarkus_cache_remote_sasl_mechanism: + default: "SCRAM-SHA-512" + description: "Infinispan auth mechanism" + type: "str" + keycloak_quarkus_cache_remote_tls_enabled: + default: false + description: "Whether infinispan uses TLS connection" + type: "bool" + keycloak_quarkus_db_engine: + default: "postgres" + description: "Database engine [mariadb,postres,mssql]" + type: "str" + keycloak_quarkus_db_user: + default: "keycloak-user" + description: "User for database connection" + type: "str" + keycloak_quarkus_db_pass: + default: "keycloak-pass" + description: "Password for database connection" + type: "str" + keycloak_quarkus_db_url: + default: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].url }}" + description: "JDBC URL for connecting to database" + type: "str" + keycloak_quarkus_db_driver_version: + default: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].version }}" + description: "Version for JDBC driver" + type: "str" + keycloak_quarkus_log: + default: "file" + type: "str" + description: "Enable one or more log handlers in a comma-separated list" + keycloak_quarkus_log_level: + default: "info" + type: "str" + description: "The log level of the root category or a comma-separated list of individual categories and their levels" + keycloak_quarkus_log_file: + default: "data/log/keycloak.log" + type: "str" + description: "Set the log file path and filename relative to keycloak home" + keycloak_quarkus_log_format: + default: '%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n' + type: "str" + description: "Set a format specific to file log entries" + keycloak_quarkus_log_target: + default: '/var/log/keycloak' + type: "str" + description: "Set the destination of the keycloak log folder link" + keycloak_quarkus_log_max_file_size: + default: 10M + type: "str" + description: > + Set the maximum log file size before a log rotation happens; A size configuration option recognises string in this format (shown as a regular + expression): [0-9]+[KkMmGgTtPpEeZzYy]?. If no suffix is given, assume bytes. + keycloak_quarkus_log_max_backup_index: + default: 10 + type: "str" + description: "Set the maximum number of archived log files to keep" + keycloak_quarkus_log_file_suffix: + default: '.yyyy-MM-dd.zip' + type: "str" + description: > + Set the log file handler rotation file suffix. When used, the file will be rotated based on its suffix. Note: If the suffix ends + with .zip or .gz, the rotation file will also be compressed. + keycloak_quarkus_proxy_mode: + default: 'none' + type: "str" + description: "The proxy address forwarding mode if the server is behind a reverse proxy. Set to 'none' as it is deprecated according to Keycloak documentation" + keycloak_quarkus_proxy_headers: + default: "" + type: "str" + description: "Parse reverse proxy headers (`forwarded` or `xforwarded`), overrides the deprecated keycloak_quarkus_proxy_mode argument" + keycloak_quarkus_start_dev: + default: false + type: "bool" + description: "Whether to start the service in development mode (start-dev)" + keycloak_quarkus_transaction_xa_enabled: + default: true + type: "bool" + description: "Enable or disable XA transactions which may not be supported by some DBMS" + keycloak_quarkus_hostname_strict: + default: true + type: "bool" + description: > + Disables dynamically resolving the hostname from request headers. Should always be set to true in production, unless + proxy verifies the Host header. + keycloak_quarkus_hostname_backchannel_dynamic: + default: false + type: "bool" + description: > + Enables dynamic resolving of backchannel URLs, including hostname, scheme, port and context path. + Set to true if your application accesses Keycloak via a private network. If set to true, hostname option needs to be specified as a full URL. + keycloak_quarkus_spi_sticky_session_encoder_infinispan_should_attach_route: + default: true + type: "bool" + description: > + If the route should be attached to cookies to reflect the node that owns a particular session. If false, route is not attached to cookies + and we rely on the session affinity capabilities from reverse proxy + keycloak_quarkus_ks_vault_enabled: + default: false + type: "bool" + description: "Whether to enable vault SPI" + keycloak_quarkus_ks_vault_file: + default: "{{ keycloak_quarkus_config_dir }}/keystore.p12" + type: "str" + description: "The keystore path for the vault SPI" + keycloak_quarkus_ks_vault_type: + default: "PKCS12" + type: "str" + description: "Type of the keystore used for the vault SPI" + keycloak_quarkus_ks_vault_pass: + required: false + type: "str" + description: "The password for accessing the keystore vault SPI" + keycloak_quarkus_systemd_wait_for_port: + description: 'Whether systemd unit should wait for keycloak port before returning' + default: "{{ keycloak_quarkus_ha_enabled }}" + type: "bool" + keycloak_quarkus_systemd_wait_for_port_number: + default: "{{ keycloak_quarkus_https_port }}" + description: "The port the systemd unit should wait for, by default the https port" + type: "int" + keycloak_quarkus_systemd_wait_for_log: + description: 'Whether systemd unit should wait for service to be up in logs' + default: false + type: "bool" + keycloak_quarkus_systemd_wait_for_timeout: + description: "How long to wait for service to be alive (seconds)" + default: 60 + type: 'int' + keycloak_quarkus_systemd_wait_for_delay: + description: "Activation delay for service systemd unit (seconds)" + default: 10 + type: 'int' + keycloak_quarkus_providers: + description: > + List of provider definition dicts: { 'id': str, 'spi': str, 'url': str, 'local_path': str, + 'maven': { + 'repository_url': str, 'group_id': str, 'artifact_id': str, 'version': str, 'username': str, optional, 'password': str, optional + }, + 'default': bool, + 'properties': list of key/value } + default: [] + type: "list" + keycloak_quarkus_supported_policy_types: + description: "List of str of supported policy types" + default: ['password-blacklists'] + type: "list" + keycloak_quarkus_policies: + description: "List of policy definition dicts: { 'name': str, 'url': str, 'type': str }" + default: [] + type: "list" + keycloak_quarkus_jdbc_download_url: + description: "Override the default Maven Central download URL for the JDBC driver" + type: "str" + keycloak_quarkus_jdbc_download_user: + description: "Set a username with which to authenticate when downloading JDBC drivers from an alternative location" + type: "str" + keycloak_quarkus_jdbc_download_pass: + description: > + Set a password with which to authenticate when downloading JDBC drivers from an alternative location + (requires `keycloak_quarkus_jdbc_download_user``) + type: "str" + keycloak_quarkus_jdbc_download_validate_certs: + default: true + description: "Allow the option to ignore invalid certificates when downloading JDBC drivers from a custom URL" + type: "bool" + keycloak_quarkus_restart_health_check: + default: true + description: "Whether to wait for successful health check after restart" + type: "bool" + keycloak_quarkus_restart_strategy: + description: > + Strategy task file for restarting in HA, one of restart/[ 'serial', 'none', 'serial_then_parallel' ].yml, or path to + file when providing custom strategy; when keycloak_quarkus_ha_enabled and keycloak_quarkus_restart_health_check == true + default: "restart/serial.yml" + type: "str" + keycloak_quarkus_restart_pause: + description: "Seconds to wait between restarts in HA strategy" + default: 15 + type: int + keycloak_quarkus_restart_health_check_delay: + description: "Seconds to let pass before starting healch checks" + default: 10 + type: 'int' + keycloak_quarkus_restart_health_check_retries: + description: "Number of attempts for successful health check before failing" + default: 25 + type: 'int' + keycloak_quarkus_validate_certs: + description: "Whether to validate SSL certificates when performing health checks" + default: true + type: "bool" + keycloak_quarkus_show_deprecation_warnings: + default: true + description: "Whether or not deprecation warnings should be shown" + type: "bool" + keycloak_quarkus_download_path: + description: "Path local to controller for offline/download of install archives" + default: "{{ lookup('env', 'PWD') }}" + type: "str" + keycloak_quarkus_cache_managed_infinispan_config: + description: "Manage infinispan configuration" + default: "{{ keycloak_quarkus_version is version('26.4.0', '<') }}" + type: bool + keycloak_quarkus_cache_infinispan_template: + description: "Infinispan cache template file" + default: "cache-ispn.xml" + type: str + keycloak_quarkus_cache_embedded_properties: + description: Embedded cache properties + default: "" + type: str + keycloak_quarkus_binary_download_user: + description: "Username for HTTP Basic Auth when downloading Keycloak binary" + type: "str" + required: false + keycloak_quarkus_binary_download_pass: + description: "Password for HTTP Basic Auth when downloading Keycloak binary" + type: "str" + required: false + downstream: + options: + rhbk_version: + default: "26.4.11" + description: "Red Hat Build of Keycloak version" + type: "str" + rhbk_archive: + default: "rhbk-{{ rhbk_version }}.zip" + description: "Red Hat Build of Keycloak install archive filename" + type: "str" + rhbk_dest: + default: "/opt/rhbk" + description: "Root installation directory" + type: "str" + rhbk_installdir: + default: "{{ rhbk_dest }}/rhbk-{{ rhbk_version }}" + description: "Installation path for Red Hat Build of Keycloak" + type: "str" + rhbk_apply_patches: + default: false + description: "Install Red Hat Build of Keycloak most recent cumulative patch" + type: "bool" + rhbk_enable: + default: true + description: "Enable Red Hat Build of Keycloak installation" + type: "bool" + rhbk_offline_install: + default: false + description: "Perform an offline install" + type: "bool" + rhbk_service_name: + default: "rhbk" + description: "systemd service name for Red Hat Build of Keycloak" + type: "str" + rhbk_service_desc: + default: "Red Hat Build of Keycloak" + description: "systemd description for Red Hat Build of Keycloak" + type: "str" + rhbk_patch_version: + required: false + description: "Red Hat Build of Keycloak latest cumulative patch version to apply; defaults to latest version when rhbk_apply_patches is True" + type: "str" + rhbk_patch_bundle: + default: "rhbk-{{ rhbk_patch_version | default('[0-9]+[.][0-9]+[.][0-9]+') }}-patch.zip" + description: "Red Hat Build of Keycloak patch archive filename" + type: "str" + rhbk_product_category: + default: "RHBK" + description: "Unified Downloads API category for Red Hat Build of Keycloak" + type: "str" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/meta/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/meta/main.yml new file mode 100644 index 00000000..65b5e506 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/meta/main.yml @@ -0,0 +1,33 @@ +--- +galaxy_info: + role_name: keycloak_quarkus + namespace: middleware_automation + author: Guido Grazioli + description: Install keycloak on quarkus server configurations + company: Red Hat, Inc. + + license: Apache License 2.0 + + min_ansible_version: "2.16" + + platforms: + - name: EL + versions: + - "8" + - "9" + - name: Fedora + - name: Debian + - name: Ubuntu + + + galaxy_tags: + - keycloak + - quarkus + - redhat + - rhel + - sso + - authentication + - identity + - security + - rhbk + - debian diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/bootstrapped.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/bootstrapped.yml new file mode 100644 index 00000000..c77c33d8 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/bootstrapped.yml @@ -0,0 +1,16 @@ +--- +- name: Save ansible custom facts + become: "{{ keycloak_quarkus_bootstrapped_require_privilege_escalation | default(true) }}" + ansible.builtin.template: + src: keycloak.fact.j2 + dest: /etc/ansible/facts.d/keycloak.fact + mode: '0644' + vars: + bootstrapped: true + +- name: Refresh custom facts + ansible.builtin.setup: + filter: ansible_local + +- name: Ensure that `KEYCLOAK_ADMIN[_PASSWORD]` get purged + ansible.builtin.include_tasks: systemd.yml diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/config_store.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/config_store.yml new file mode 100644 index 00000000..707c9ae1 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/config_store.yml @@ -0,0 +1,52 @@ +--- +- name: "Initialize configuration key store variables to be written" + ansible.builtin.set_fact: + keycloak_quarkus_store_items: + - key: "kc.db-password" + value: "{{ keycloak_quarkus_db_pass }}" + +- name: "Initialize empty configuration key store" + become: "{{ keycloak_quarkus_config_store_require_privilege_escalation | default(true) }}" + # keytool doesn't allow creating an empty key store, so this is a hacky way around it + ansible.builtin.shell: | # noqa blocked_modules shell is necessary here + set -o nounset # abort on unbound variable + set -o pipefail # do not hide errors within pipes + set -o errexit # abort on nonzero exit status + + echo dummy | keytool -noprompt -importpass -alias dummy -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} -storetype PKCS12 + keytool -delete -alias dummy -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} + args: + creates: "{{ keycloak_quarkus_config_key_store_file }}" + +- name: "Set configuration key store using keytool" + ansible.builtin.shell: | # noqa blocked_modules shell is necessary here + set -o nounset # abort on unbound variable + set -o pipefail # do not hide errors within pipes + + keytool -list -alias {{ item.key | quote }} -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} + retVal=$? + + set -o errexit # abort on nonzero exit status + + if [ $retVal -eq 0 ]; then + # value is already in keystore, but keytool has no replace function: delete and re-create instead + # note that we can not read whether the value has changed either[^1], so we need to override it + # [^1]: https://stackoverflow.com/a/37491400 + keytool -delete -alias {{ item.key | quote }} -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} + fi + + echo {{ item.value | quote }} | keytool -noprompt -importpass -alias {{ item.key | quote }} -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} -storetype PKCS12 + loop: "{{ keycloak_quarkus_store_items }}" + no_log: true + become: "{{ keycloak_quarkus_config_store_require_privilege_escalation | default(true) }}" + changed_when: true + notify: + - restart keycloak + +- name: "Set owner of configuration key store {{ keycloak_quarkus_config_key_store_file }}" + ansible.builtin.file: + path: "{{ keycloak_quarkus_config_key_store_file }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0400' + become: "{{ keycloak_quarkus_config_store_require_privilege_escalation | default(true) }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/debian.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/debian.yml new file mode 100644 index 00000000..7e592046 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/debian.yml @@ -0,0 +1,10 @@ +--- +- name: Include firewall config tasks + ansible.builtin.include_tasks: + file: iptables.yml + apply: + tags: + - firewall + when: keycloak_quarkus_configure_iptables + tags: + - firewall diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/deprecations.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/deprecations.yml new file mode 100644 index 00000000..fe125c48 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/deprecations.yml @@ -0,0 +1,162 @@ +--- +- name: Check deprecation keycloak_quarkus_key_store -> keycloak_quarkus_http_key_store + delegate_to: localhost + run_once: true + when: + - keycloak_quarkus_https_key_store_enabled + block: + - name: Ensure backward compatibility for `keycloak_quarkus_key_store_file`, superseded by `keycloak_quarkus_https_key_store_file` + when: + - keycloak_quarkus_key_store_file is defined + - keycloak_quarkus_key_store_file != '' + - keycloak_quarkus_https_key_store_file == keycloak.home + "/conf/key_store.p12" # default value + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_https_key_store_file: "{{ keycloak_quarkus_key_store_file }}" + keycloak_quarkus_deprecated_variable: "keycloak_quarkus_key_store_file" # read in deprecation handler + notify: + - print deprecation warning + + - name: Flush handlers + ansible.builtin.meta: flush_handlers + + - name: Ensure backward compatibility for `keycloak_quarkus_key_store_password`, superseded by `keycloak_quarkus_https_key_store_password` + when: + - keycloak_quarkus_key_store_password is defined + - keycloak_quarkus_key_store_password != '' + - keycloak_quarkus_https_key_store_password == "" # default value + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_https_key_store_password: "{{ keycloak_quarkus_key_store_password }}" + keycloak_quarkus_deprecated_variable: "keycloak_quarkus_key_store_password" # read in deprecation handler + notify: + - print deprecation warning + + - name: Flush handlers + ansible.builtin.meta: flush_handlers + +# https://access.redhat.com/documentation/en-us/red_hat_build_of_keycloak/24.0/html-single/upgrading_guide/index#deprecated_literal_proxy_literal_option +- name: Check deprecation of keycloak_quarkus_proxy_mode + when: + - keycloak_quarkus_proxy_mode is defined + - keycloak_quarkus_proxy_headers is defined and keycloak_quarkus_proxy_headers | length == 0 + - keycloak_quarkus_version.split('.') | first | int >= 24 + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_deprecated_variable: "keycloak_quarkus_proxy_mode" # read in deprecation handler + notify: + - print deprecation warning + +# https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html-single/upgrading_guide/index#new_hostname_options +- name: Check deprecation of keycloak_quarkus_frontend_url -> keycloak_quarkus_hostname + when: + - keycloak_quarkus_hostname is not defined + - keycloak_quarkus_frontend_url is defined + - keycloak_quarkus_frontend_url != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_hostname: "{{ keycloak_quarkus_frontend_url }}" + keycloak_quarkus_deprecated_variable: "keycloak_quarkus_frontend_url" # read in deprecation handler + notify: + - print deprecation warning + +# https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html-single/upgrading_guide/index#new_hostname_options +- name: Check deprecation of keycloak_quarkus_hostname_strict_https + keycloak_quarkus_host + keycloak_quarkus_port + keycloak_quarkus_path -> keycloak_quarkus_hostname + when: + - keycloak_quarkus_hostname is not defined + - keycloak_quarkus_hostname_strict_https is defined or keycloak_quarkus_frontend_url is defined or keycloak_quarkus_port is defined or keycloak_quarkus_path is defined + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_hostname: >- + {% set protocol = '' %} + {% if keycloak_quarkus_hostname_strict_https %} + {% set protocol = 'https://' %} + {% elif keycloak_quarkus_hostname_strict_https is defined and keycloak_quarkus_hostname_strict_https is False %} + {% set protocol = 'http://' %} + {% endif %} + {{ protocol }}{{ keycloak_quarkus_host }}:{{ keycloak_quarkus_port }}/{{ keycloak_quarkus_path }} + keycloak_quarkus_deprecated_variable: "keycloak_quarkus_hostname_strict_https or keycloak_quarkus_frontend_url or keycloak_quarkus_frontend_url or keycloak_quarkus_hostname" # read in deprecation handler + notify: + - print deprecation warning + +# https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html-single/upgrading_guide/index#new_hostname_options +- name: Check deprecation of keycloak_quarkus_admin_url -> keycloak_quarkus_hostname_admin + when: + - keycloak_quarkus_hostname_admin is not defined + - keycloak_quarkus_admin_url is defined + - keycloak_quarkus_admin_url != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_hostname_admin: "{{ keycloak_quarkus_admin_url }}" + keycloak_quarkus_deprecated_variable: "keycloak_quarkus_admin_url" # read in deprecation handler + notify: + - print deprecation warning + +# https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html-single/upgrading_guide/index#new_hostname_options +- name: Check deprecation of keycloak_quarkus_hostname_strict_backchannel -> keycloak_quarkus_hostname_backchannel_dynamic + when: + - keycloak_quarkus_hostname_backchannel_dynamic is not defined + - keycloak_quarkus_hostname_strict_backchannel is defined + - keycloak_quarkus_hostname_strict_backchannel != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_hostname_backchannel_dynamic: "{{ keycloak_quarkus_hostname_strict_backchannel == False }}" + keycloak_quarkus_deprecated_variable: "keycloak_quarkus_hostname_backchannel_dynamic" # read in deprecation handler + notify: + - print deprecation warning + +# https://github.com/keycloak/keycloak/issues/30009 +- name: Check deprecation of keycloak_quarkus_admin_user -> keycloak_quarkus_bootstrap_admin_user + when: + - keycloak_quarkus_bootstrap_admin_user is not defined + - keycloak_quarkus_admin_user is defined + - keycloak_quarkus_admin_user != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_bootstrap_admin_user: "{{ keycloak_quarkus_admin_user }}" + keycloak_quarkus_deprecated_variable: "keycloak_quarkus_admin_user" # read in deprecation handler + notify: + - print deprecation warning + +# https://github.com/keycloak/keycloak/issues/30009 +- name: Check deprecation of keycloak_quarkus_admin_pass -> keycloak_quarkus_bootstrap_admin_password + when: + - keycloak_quarkus_bootstrap_admin_password is not defined + - keycloak_quarkus_admin_pass is defined + - keycloak_quarkus_admin_pass != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_bootstrap_admin_user: "{{ keycloak_quarkus_admin_pass }}" + keycloak_quarkus_deprecated_variable: "keycloak_quarkus_admin_pass" # read in deprecation handler + notify: + - print deprecation warning + +- name: Check deprecation of keycloak_quarkus_bind_address -> keycloak_quarkus_http_host + when: + - keycloak_quarkus_bind_address is defined + - keycloak_quarkus_bind_address != '0.0.0.0' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_http_host: "{{ keycloak_quarkus_bind_address }}" + keycloak_quarkus_deprecated_variable: "keycloak_quarkus_bind_address" # read in deprecation handler + notify: + - print deprecation warning + +- name: Flush handlers + ansible.builtin.meta: flush_handlers diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/fastpackages.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/fastpackages.yml new file mode 100644 index 00000000..45e0deb4 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/fastpackages.yml @@ -0,0 +1,31 @@ +--- +- name: "Check if packages are already installed" # noqa command-instead-of-module this runs faster + ansible.builtin.command: "rpm -q {{ packages_list | join(' ') }}" + register: keycloak_quarkus_rpm_info + changed_when: false + failed_when: false + when: ansible_facts.os_family == "RedHat" + +- name: "Add missing packages to the yum install list" + ansible.builtin.set_fact: + keycloak_quarkus_packages_to_install: "{{ keycloak_quarkus_packages_to_install | default([]) + keycloak_quarkus_rpm_info.stdout_lines | \ + map('regex_findall', 'package (.+) is not installed$') | default([]) | flatten }}" + when: ansible_facts.os_family == "RedHat" + +- name: "Install packages: {{ keycloak_quarkus_packages_to_install }}" + become: "{{ keycloak_quarkus_fastpackages_require_privilege_escalation | default(true) }}" + ansible.builtin.dnf: + name: "{{ keycloak_quarkus_packages_to_install }}" + state: present + when: + - keycloak_quarkus_packages_to_install | default([]) | length > 0 + - ansible_facts.os_family == "RedHat" + +- name: "Install packages: {{ packages_list }}" + become: "{{ keycloak_quarkus_fastpackages_require_privilege_escalation | default(true) }}" + ansible.builtin.package: + name: "{{ packages_list }}" + state: present + when: + - packages_list | default([]) | length > 0 + - ansible_facts.os_family == "Debian" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/firewalld.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/firewalld.yml new file mode 100644 index 00000000..9cf46216 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/firewalld.yml @@ -0,0 +1,36 @@ +--- +- name: Ensure required package firewalld are installed + ansible.builtin.include_tasks: fastpackages.yml + vars: + packages_list: + - firewalld + +- name: Enable and start the firewalld service + become: "{{ keycloak_quarkus_firewalld_require_privilege_escalation | default(true) }}" + ansible.builtin.systemd: + name: firewalld + enabled: true + state: started + +- name: "Configure firewall for http port: {{ keycloak.service_name }}" + become: "{{ keycloak_quarkus_firewalld_require_privilege_escalation | default(true) }}" + ansible.posix.firewalld: + port: "{{ item }}" + permanent: true + state: enabled + immediate: true + loop: + - "{{ keycloak_quarkus_http_port }}/tcp" + when: keycloak_quarkus_http_enabled | bool + +- name: "Configure firewall for ports: {{ keycloak.service_name }}" + become: "{{ keycloak_quarkus_firewalld_require_privilege_escalation | default(true) }}" + ansible.posix.firewalld: + port: "{{ item }}" + permanent: true + state: enabled + immediate: true + loop: + - "{{ keycloak_quarkus_https_port }}/tcp" + - "{{ keycloak_quarkus_http_management_port }}/tcp" + - "{{ keycloak_quarkus_jgroups_port }}/tcp" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/install.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/install.yml new file mode 100644 index 00000000..6e7fdbc1 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/install.yml @@ -0,0 +1,319 @@ +--- +- name: Validate parameters + ansible.builtin.assert: + that: + - keycloak.home is defined + - keycloak_quarkus_service_user is defined + - keycloak_quarkus_dest is defined + - keycloak_quarkus_archive is defined + - keycloak_quarkus_download_url is defined + - keycloak_quarkus_version is defined + - keycloak_quarkus_local_path is defined + quiet: true + +- name: Check for an existing deployment + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + ansible.builtin.stat: + path: "{{ keycloak.home }}" + register: keycloak_quarkus_existing_deploy + +- name: Stop and restart if existing deployment exists and install forced + when: keycloak_quarkus_existing_deploy.stat.exists and keycloak_quarkus_force_install | bool + block: + - name: "Stop the old service: {{ keycloak.service_name }}" + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + failed_when: false + ansible.builtin.systemd: + name: keycloak + state: stopped + - name: "Remove the old deployment: {{ keycloak.service_name }}" + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + ansible.builtin.file: + path: "{{ keycloak_quarkus_home }}" + state: absent + +- name: Check for an existing deployment after possible forced removal + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + ansible.builtin.stat: + path: "{{ keycloak_quarkus_home }}" + register: keycloak_quarkus_existing_deploy + +- name: "Create service user/group: {{ keycloak.service_name }}" + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + ansible.builtin.user: + name: "{{ keycloak.service_user }}" + home: /opt/keycloak + system: true + create_home: false + +- name: "Create install location: {{ keycloak.service_name }}" + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + ansible.builtin.file: + dest: "{{ keycloak_quarkus_dest }}" + state: directory + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0750' + +- name: Create directory for ansible custom facts + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + ansible.builtin.file: + state: directory + recurse: true + path: /etc/ansible/facts.d + +## check remote archive +- name: Set download archive path + ansible.builtin.set_fact: + keycloak_quarkus_archive_full_path: "{{ keycloak_quarkus_dest }}/{{ keycloak.bundle }}" + +- name: Check download archive path + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + ansible.builtin.stat: + path: "{{ keycloak_quarkus_archive_full_path }}" + register: keycloak_quarkus_archive_path + +## download to controller +- name: Download keycloak archive + ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user + url: "{{ keycloak_quarkus_download_url }}" + dest: "{{ keycloak_quarkus_local_path.stat.path }}/{{ keycloak.bundle }}" + mode: '0640' + url_username: "{{ keycloak_quarkus_binary_download_user | default(omit) }}" + url_password: "{{ keycloak_quarkus_binary_download_pass | default(omit) }}" + validate_certs: "{{ keycloak_quarkus_download_validate_certs | default(omit) }}" + delegate_to: localhost + become: false + run_once: true + when: + - keycloak_quarkus_archive_path is defined + - keycloak_quarkus_archive_path.stat is defined + - not keycloak_quarkus_archive_path.stat.exists + - not keycloak.offline_install + - not rhbk_enable is defined or not rhbk_enable + +- name: Perform download from RHN using the Unified Downloads API + delegate_to: localhost + run_once: true + when: + - keycloak_quarkus_archive_path is defined + - keycloak_quarkus_archive_path.stat is defined + - not keycloak_quarkus_archive_path.stat.exists + - rhbk_enable is defined and rhbk_enable + - not keycloak.offline_install + - keycloak_quarkus_alternate_download_url is undefined + block: + - name: Retrieve product download using Unified Downloads API + middleware_automation.common.product_search: + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_type: DISTRIBUTION + product_version: "{{ rhbk_version }}" + product_category: "{{ rhbk_product_category }}" + validate_certs: "{{ keycloak_quarkus_download_validate_certs | default(omit) }}" + register: keycloak_quarkus_rhn_products + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + + - name: Determine install zipfile from search results + ansible.builtin.set_fact: + keycloak_quarkus_rhn_filtered_products: "{{ keycloak_quarkus_rhn_products.results | selectattr('file_name', 'match', '[^/]*/' + rhbk_archive + '$') }}" + delegate_to: localhost + run_once: true + + - name: Download Red Hat Build of Keycloak + middleware_automation.common.product_download: # noqa risky-file-permissions delegated, uses controller host user + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_id: "{{ (keycloak_quarkus_rhn_filtered_products | first).id }}" + dest: "{{ keycloak_quarkus_local_path.stat.path }}/{{ keycloak.bundle }}" + mode: '0640' + validate_certs: "{{ keycloak_quarkus_download_validate_certs | default(omit) }}" + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + become: false + +- name: Perform download of RHBK from alternate download location + delegate_to: localhost + run_once: true + become: false + when: + - keycloak_quarkus_archive_path is defined + - keycloak_quarkus_archive_path.stat is defined + - not keycloak_quarkus_archive_path.stat.exists + - rhbk_enable is defined and rhbk_enable + - not keycloak.offline_install + - keycloak_quarkus_alternate_download_url is defined + ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user + url: "{{ keycloak_quarkus_alternate_download_url }}" + dest: "{{ keycloak_quarkus_local_path.stat.path }}/{{ keycloak.bundle }}" + mode: '0640' + url_username: "{{ keycloak_quarkus_download_user | default(omit) }}" + url_password: "{{ keycloak_quarkus_download_pass | default(omit) }}" + validate_certs: "{{ keycloak_quarkus_download_validate_certs | default(omit) }}" + +- name: Check downloaded archive + ansible.builtin.stat: + path: "{{ keycloak_quarkus_local_path.stat.path }}/{{ keycloak.bundle }}" + register: keycloak_quarkus_local_archive_path + delegate_to: localhost + become: false + run_once: true + +## copy and unpack +- name: Copy archive to target nodes + ansible.builtin.copy: + src: "{{ keycloak_quarkus_local_path.stat.path }}/{{ keycloak.bundle }}" + dest: "{{ keycloak_quarkus_archive_full_path }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + register: keycloak_quarkus_new_version_downloaded + when: + - not keycloak_quarkus_archive_path.stat.exists + - keycloak_quarkus_local_archive_path.stat is defined + - keycloak_quarkus_local_archive_path.stat.exists + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + +- name: "Check target directory /bin/: {{ keycloak.home }}" + ansible.builtin.stat: + path: "{{ keycloak.home }}/bin/" + register: keycloak_quarkus_path_to_workdir + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + +- name: "Extract Keycloak archive on target" # noqa no-handler need to run this here + ansible.builtin.unarchive: + remote_src: true + src: "{{ keycloak_quarkus_archive_full_path }}" + dest: "{{ keycloak_quarkus_dest }}" + creates: "{{ keycloak.home }}/bin/" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + when: + - (not keycloak_quarkus_path_to_workdir.stat.exists) or keycloak_quarkus_new_version_downloaded.changed + notify: + - restart keycloak + +- name: Inform decompression was not executed + ansible.builtin.debug: + msg: "{{ keycloak.home }} already exists and version unchanged, skipping decompression" + when: + - (not keycloak_quarkus_new_version_downloaded.changed) and keycloak_quarkus_path_to_workdir.stat.exists + +- name: "Copy private key to target" + ansible.builtin.copy: + content: "{{ keycloak_quarkus_key_content }}" + dest: "{{ keycloak_quarkus_key_file }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + when: + - keycloak_quarkus_https_key_file_enabled is defined and keycloak_quarkus_https_key_file_enabled + - keycloak_quarkus_key_file_copy_enabled is defined and keycloak_quarkus_key_file_copy_enabled + - keycloak_quarkus_key_content | length > 0 + +- name: "Copy certificate to target" + ansible.builtin.copy: + src: "{{ keycloak_quarkus_cert_file_src }}" + dest: "{{ keycloak_quarkus_cert_file }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0644' + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + when: + - keycloak_quarkus_https_key_file_enabled is defined and keycloak_quarkus_https_key_file_enabled + - keycloak_quarkus_cert_file_copy_enabled is defined and keycloak_quarkus_cert_file_copy_enabled + - keycloak_quarkus_cert_file_src | length > 0 + +- name: "Install JDBC driver: {{ keycloak_quarkus_db_engine }}" + ansible.builtin.include_tasks: jdbc_driver.yml + when: + - rhbk_enable is defined and rhbk_enable + - keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].driver_jar_url is defined + +- name: "Download custom providers via http" + ansible.builtin.get_url: + url: "{{ item.url }}" + dest: "{{ keycloak.home }}/providers/{{ item.id }}.jar" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + checksum: "{{ item.checksum | default(omit) }}" + validate_certs: "{{ keycloak_quarkus_download_validate_certs | default(omit) }}" + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + loop: "{{ keycloak_quarkus_providers }}" + when: item.url is defined and item.url | length > 0 + no_log: "{{ item.maven.password is defined and item.maven.password | length > 0 | default(false) }}" + notify: "{{ ['invalidate keycloak theme cache', 'rebuild keycloak config', 'restart keycloak'] if not item.restart is defined or item.restart else [] }}" + +# this requires the `lxml` package to be installed; we redirect this step to localhost such that we do need to install it on the remote hosts +- name: "Download custom providers to localhost using maven" + middleware_automation.common.maven_artifact: + repository_url: "{{ item.maven.repository_url }}" + group_id: "{{ item.maven.group_id }}" + artifact_id: "{{ item.maven.artifact_id }}" + version: "{{ item.maven.version | default(omit) }}" + username: "{{ item.maven.username | default(omit) }}" + password: "{{ item.maven.password | default(omit) }}" + dest: "{{ keycloak_quarkus_local_path.stat.path }}/{{ item.id }}.jar" + validate_certs: "{{ keycloak_quarkus_download_validate_certs | default(omit) }}" + delegate_to: "localhost" + run_once: true + loop: "{{ keycloak_quarkus_providers }}" + when: item.maven is defined + no_log: "{{ item.maven.password is defined and item.maven.password | length > 0 | default(false) }}" + +- name: "Copy maven providers" + ansible.builtin.copy: + src: "{{ keycloak_quarkus_local_path.stat.path }}/{{ item.id }}.jar" + dest: "{{ keycloak.home }}/providers/{{ item.id }}.jar" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + checksum: "{{ item.checksum | default(omit) }}" + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + loop: "{{ keycloak_quarkus_providers }}" + when: item.maven is defined + no_log: "{{ item.maven.password is defined and item.maven.password | length > 0 | default(false) }}" + notify: "{{ ['invalidate keycloak theme cache', 'rebuild keycloak config', 'restart keycloak'] if not item.restart is defined or item.restart else [] }}" + +- name: "Copy local providers" + ansible.builtin.copy: + src: "{{ item.local_path }}" + dest: "{{ keycloak.home }}/providers/{{ item.id }}.jar" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + remote_src: "{{ item.remote | default(false) }}" + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + loop: "{{ keycloak_quarkus_providers }}" + when: item.local_path is defined + no_log: "{{ item.maven.password is defined and item.maven.password | length > 0 | default(false) }}" + notify: "{{ ['invalidate keycloak theme cache', 'rebuild keycloak config', 'restart keycloak'] if not item.restart is defined or item.restart else [] }}" + +- name: Ensure required folder structure for policies exists + ansible.builtin.file: + path: "{{ keycloak.home }}/data/{{ item | lower }}" + state: directory + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0750' + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + loop: "{{ keycloak_quarkus_supported_policy_types }}" + +- name: "Install custom policies" + ansible.builtin.get_url: + url: "{{ item.url }}" + dest: "{{ keycloak.home }}/data/{{ item.type | default(keycloak_quarkus_supported_policy_types | first) | lower }}/{{ item.name }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + validate_certs: "{{ keycloak_quarkus_download_validate_certs | default(omit) }}" + become: "{{ keycloak_quarkus_install_require_privilege_escalation | default(true) }}" + loop: "{{ keycloak_quarkus_policies }}" + when: item.url is defined and item.url | length > 0 + notify: "restart keycloak" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/invalidate_theme_cache.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/invalidate_theme_cache.yml new file mode 100644 index 00000000..ad21ea63 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/invalidate_theme_cache.yml @@ -0,0 +1,11 @@ +--- +# From https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/24.0/html/server_developer_guide/themes#creating_a_theme: +# If you want to manually delete the content of the themes cache, +# you can do so by deleting the data/tmp/kc-gzip-cache directory of the server distribution +# It can be useful for instance if you redeployed custom providers or custom themes without +# disabling themes caching in the previous server executions. +- name: "Delete theme cache directory: {{ keycloak.service_name }}" + ansible.builtin.file: + path: "{{ keycloak.home }}/data/tmp/kc-gzip-cache" + state: absent + become: "{{ keycloak_quarkus_invalidate_theme_cache_require_privilege_escalation | default(true) }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/iptables.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/iptables.yml new file mode 100644 index 00000000..42265eab --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/iptables.yml @@ -0,0 +1,20 @@ +--- +- name: Ensure required package iptables are installed + ansible.builtin.include_tasks: fastpackages.yml + vars: + packages_list: + - iptables + +- name: "Configure firewall ports for {{ keycloak.service_name }}" + become: "{{ keycloak_quarkus_iptables_require_privilege_escalation | default(true) }}" + ansible.builtin.iptables: + destination_port: "{{ item }}" + action: "insert" + rule_num: 6 # magic number I forget why + chain: "INPUT" + policy: "ACCEPT" + protocol: tcp + loop: + - "{{ keycloak_quarkus_http_port }}" + - "{{ keycloak_quarkus_https_port }}" + - "{{ keycloak_quarkus_jgroups_port }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/jdbc_driver.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/jdbc_driver.yml new file mode 100644 index 00000000..e75570b8 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/jdbc_driver.yml @@ -0,0 +1,22 @@ +--- +- name: "Verify valid parameters for download credentials when specified" + ansible.builtin.fail: + msg: >- + When JDBC driver download credentials are set, both the username and the password MUST be set + when: > + (keycloak_quarkus_jdbc_download_user is undefined and keycloak_quarkus_jdbc_download_pass is not undefined) or + (keycloak_quarkus_jdbc_download_pass is undefined and keycloak_quarkus_jdbc_download_user is not undefined) + +- name: "Retrieve JDBC Driver from {{ keycloak_jdbc_download_url | default(keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].driver_jar_url) }}" + ansible.builtin.get_url: + url: "{{ keycloak_quarkus_jdbc_download_url | default(keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].driver_jar_url) }}" + dest: "{{ keycloak.home }}/providers" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + url_username: "{{ keycloak_quarkus_jdbc_download_user | default(omit) }}" + url_password: "{{ keycloak_quarkus_jdbc_download_pass | default(omit) }}" + validate_certs: "{{ keycloak_quarkus_jdbc_download_validate_certs | default(omit) }}" + mode: '0640' + become: "{{ keycloak_quarkus_jdbc_driver_require_privilege_escalation | default(true) }}" + notify: + - restart keycloak diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/main.yml new file mode 100644 index 00000000..0c769e99 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/main.yml @@ -0,0 +1,139 @@ +--- +# tasks file for keycloak +- name: Check prerequisites + ansible.builtin.include_tasks: + file: prereqs.yml + apply: + tags: + - prereqs + tags: + - prereqs + - always + +- name: Check for deprecations + ansible.builtin.include_tasks: + file: deprecations.yml + apply: + tags: + - always + tags: + - always + +- name: Distro specific tasks + ansible.builtin.include_tasks: + file: "{{ ansible_os_family | lower }}.yml" + apply: + tags: + - unbound + tags: + - unbound + +- name: Include install tasks + ansible.builtin.include_tasks: + file: install.yml + apply: + tags: + - install + tags: + - install + +- name: Include systemd tasks + ansible.builtin.include_tasks: + file: systemd.yml + apply: + tags: + - systemd + tags: + - systemd + +- name: Include configuration key store tasks + ansible.builtin.include_tasks: + file: config_store.yml + apply: + tags: + - install + when: keycloak.config_key_store_enabled + tags: + - install + +- name: Create tcpping cluster node list + ansible.builtin.set_fact: + keycloak_quarkus_cluster_nodes: > + {{ keycloak_quarkus_cluster_nodes | default([]) + [ + { + "name": item, + "address": 'jgroups-' + item, + "inventory_host": hostvars[item].keycloak_quarkus_jgroups_ip | default(item) + '[' + (keycloak_quarkus_jgroups_port | string) + ']', + "value": hostvars[item].keycloak_quarkus_jgroups_ip | default(item) + } + ] }} + loop: "{{ ansible_play_batch }}" + when: keycloak_quarkus_ha_enabled and keycloak_quarkus_ha_discovery == 'TCPPING' + + +- name: Determine the config files + ansible.builtin.set_fact: + keycloak_quarkus_config_files: "{{ ['keycloak.conf', 'quarkus.properties'] + (keycloak_quarkus_cache_managed_infinispan_config | ternary([keycloak_quarkus_cache_infinispan_template], [])) }}" + +- name: "Configure config files for keycloak service" + ansible.builtin.template: + src: "{{ item }}.j2" + dest: "{{ keycloak.home }}/conf/{{ item }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + become: "{{ keycloak_quarkus_require_privilege_escalation | default(true) }}" + loop: "{{ keycloak_quarkus_config_files }}" + notify: + - rebuild keycloak config + - restart keycloak + +- name: Ensure logdirectory exists + ansible.builtin.file: + state: directory + path: "{{ keycloak.log.file | dirname }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0775' + become: "{{ keycloak_quarkus_require_privilege_escalation | default(true) }}" + +- name: Ensure tmp-directory exists + ansible.builtin.file: + state: directory + path: "{{ keycloak.home }}/data/tmp" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0755' + become: "{{ keycloak_quarkus_require_privilege_escalation | default(true) }}" + +- name: Flush pending handlers + ansible.builtin.meta: flush_handlers + +- name: "Start and wait for keycloak service" + ansible.builtin.include_tasks: start.yml + +- name: Link default logs directory + ansible.builtin.file: + state: link + src: "{{ keycloak.log.file | dirname }}" + dest: "{{ keycloak_quarkus_log_target }}" + force: true + become: "{{ keycloak_quarkus_require_privilege_escalation | default(true) }}" + +- name: Check service status + ansible.builtin.systemd_service: + name: "{{ keycloak.service_name }}" + register: keycloak_quarkus_service_status + changed_when: false + +- name: "Notify to remove `keycloak_quarkus_bootstrap_admin_user[_password]` env vars" + when: + - not ansible_local.keycloak.general.bootstrapped | default(false) | bool # it was not bootstrapped prior to the current role's execution + - keycloak_quarkus_service_status.status.ActiveState == "active" # but it is now + ansible.builtin.assert: { that: true, quiet: true } + changed_when: true + notify: + - bootstrapped + +- name: Flush pending handlers + ansible.builtin.meta: flush_handlers diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/prereqs.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/prereqs.yml new file mode 100644 index 00000000..43c519c6 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/prereqs.yml @@ -0,0 +1,154 @@ +--- +- name: Validate admin console password + ansible.builtin.assert: + that: + - keycloak_quarkus_bootstrap_admin_password is defined + - keycloak_quarkus_bootstrap_admin_password is not none + - keycloak_quarkus_bootstrap_admin_password | length > 12 + quiet: true + fail_msg: "The console administrator password is empty or invalid. Please set the keycloak_quarkus_bootstrap_admin_password to a 12+ char long string" + success_msg: "{{ 'Console administrator password OK' }}" + +- name: Validate http_relative_path + ansible.builtin.assert: + that: + - keycloak_quarkus_http_relative_path is regex('^/.*') + quiet: true + fail_msg: "The relative path for keycloak_quarkus_http_relative_path must begin with /" + success_msg: "{{ 'Relative path OK' }}" + +- name: Validate http_management_relative_path + ansible.builtin.assert: + that: + - keycloak_quarkus_http_management_relative_path is regex('^/.*') + quiet: true + fail_msg: "The relative path for keycloak_quarkus_http_management_relative_path must begin with /" + success_msg: "{{ 'Relative mgmt path OK' }}" + when: keycloak_quarkus_http_management_relative_path is defined + +- name: Validate configuration + ansible.builtin.assert: + that: + - (keycloak_quarkus_ha_enabled and keycloak_quarkus_db_enabled) or + (not keycloak_quarkus_ha_enabled and keycloak_quarkus_db_enabled) or + (not keycloak_quarkus_ha_enabled and not keycloak_quarkus_db_enabled) + quiet: true + fail_msg: "HA setup requires a backend database service. Check keycloak_quarkus_ha_enabled and keycloak_quarkus_db_enabled" + success_msg: "{{ 'Configuring HA' if keycloak_quarkus_ha_enabled else 'Configuring standalone' }}" + +- name: Validate OS family + ansible.builtin.assert: + that: + - ansible_os_family in ["RedHat", "Debian"] + quiet: true + fail_msg: "Can only install on RedHat or Debian OS families; found {{ ansible_os_family }}" + success_msg: "Installing on {{ ansible_os_family }}" + +- name: Load OS specific variables + ansible.builtin.include_vars: "vars/{{ ansible_os_family | lower }}.yml" + tags: + - always + +- name: Ensure required packages are installed + ansible.builtin.include_tasks: fastpackages.yml + vars: + packages_list: "{{ keycloak_quarkus_prereq_package_list }}" + +- name: Check local download archive path + ansible.builtin.stat: + path: "{{ keycloak_quarkus_download_path }}" + register: keycloak_quarkus_local_path + delegate_to: localhost + run_once: true + become: false + +- name: Validate local download path + ansible.builtin.assert: + that: + - keycloak_quarkus_local_path.stat.exists + - keycloak_quarkus_local_path.stat.readable + - keycloak_quarkus_offline_install or keycloak_quarkus_local_path.stat.writeable + quiet: true + fail_msg: "Defined controller path for downloading resources is incorrect or unreadable: {{ keycloak_quarkus_download_path }}" + success_msg: "Will download resource to controller path: {{ keycloak_quarkus_download_path }}" + delegate_to: localhost + run_once: true + +- name: Check downloaded archive if offline + ansible.builtin.stat: + path: "{{ keycloak_quarkus_local_path.stat.path }}/{{ keycloak.bundle }}" + when: keycloak_quarkus_offline_install + register: keycloak_quarkus_local_archive_path_check + delegate_to: localhost + run_once: true + +- name: Validate local downloaded archive if offline + ansible.builtin.assert: + that: + - keycloak_quarkus_local_archive_path_check.stat.exists + - keycloak_quarkus_local_archive_path_check.stat.readable + quiet: true + fail_msg: "Configured for offline install but install archive not found at: {{ keycloak_quarkus_local_path.stat.path }}/{{ keycloak.bundle }}" + success_msg: "Will install offline with expected archive: {{ keycloak_quarkus_local_path.stat.path }}/{{ keycloak.bundle }}" + when: keycloak_quarkus_offline_install + delegate_to: localhost + run_once: true + +- name: "Validate keytool" + when: keycloak_quarkus_config_key_store_password | length > 0 + block: + - name: "Check run keytool" + changed_when: false + ansible.builtin.command: keytool -help + register: keycloak_quarkus_keytool_check + ignore_errors: true + + - name: "Fail when no keytool found" + when: keycloak_quarkus_keytool_check.rc != 0 + ansible.builtin.fail: + msg: "keytool NOT found in the PATH, but is required for setting up the configuration key store" + +- name: "Validate providers" + ansible.builtin.assert: + that: > + item.id is defined and item.id | length > 0 and + ( (item.spi is defined and item.spi | length > 0) or + (item.url is defined and item.url | length > 0) or + ( item.maven is defined and item.maven.repository_url is defined and item.maven.repository_url | length > 0 and + item.maven.group_id is defined and item.maven.group_id | length > 0 and + item.maven.artifact_id is defined and item.maven.artifact_id | length > 0) or + (item.local_path is defined and item.local_path | length > 0) + ) + quiet: true + fail_msg: > + Providers definition incorrect; `id` and one of `spi`, `url`, `local_path`, or `maven` are mandatory. `key` and `value` are mandatory for each property + loop: "{{ keycloak_quarkus_providers }}" + no_log: "{{ item.maven.password is defined and item.maven.password | length > 0 | default(false) }}" + +- name: "Validate policies" + ansible.builtin.assert: + that: + - item.name is defined and item.name | length > 0 + - item.url is defined and item.url | length > 0 + - item.type is not defined or item.type | lower in keycloak_quarkus_supported_policy_types + quiet: true + fail_msg: > + Policy definition is incorrect: `name` and one of `url` are mandatory, `type` needs to be left empty or one of {{ keycloak_quarkus_supported_policy_types }}. + loop: "{{ keycloak_quarkus_policies }}" + +- name: "Validate additional env variables" + ansible.builtin.assert: + that: + - item.key is defined and item.key | length > 0 + - item.value is defined and item.value | length > 0 + quiet: true + fail_msg: "Additional env variable definition is incorrect: `key` and `value` are mandatory." + no_log: true + loop: "{{ keycloak_quarkus_additional_env_vars }}" + +- name: "Validate proxy-headers" + ansible.builtin.assert: + that: + - keycloak_quarkus_proxy_headers | lower in ['', 'forwarded', 'xforwarded'] + quiet: true + fail_msg: "keycloak_quarkus_proxy_headers must be either '', 'forwarded' or 'xforwarded'" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/rebuild_config.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/rebuild_config.yml new file mode 100644 index 00000000..e7e35fd5 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/rebuild_config.yml @@ -0,0 +1,7 @@ +--- +# cf. https://www.keycloak.org/server/configuration#_optimize_the_keycloak_startup +- name: "Rebuild config: {{ keycloak.service_name }}" + ansible.builtin.shell: | # noqa blocked_modules shell is necessary here + env -i bash -c "set -a ; source {{ keycloak_quarkus_sysconf_file }} ; {{ keycloak.home }}/bin/kc.sh build " + become: "{{ keycloak_quarkus_rebuild_config_require_privilege_escalation | default(true) }}" + changed_when: true diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/redhat.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/redhat.yml new file mode 100644 index 00000000..26d552bf --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/redhat.yml @@ -0,0 +1,10 @@ +--- +- name: Include firewall config tasks + ansible.builtin.include_tasks: + file: firewalld.yml + apply: + tags: + - firewall + when: keycloak_quarkus_configure_firewalld + tags: + - firewall diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart.yml new file mode 100644 index 00000000..3f49f1ec --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart.yml @@ -0,0 +1,24 @@ +--- +- name: "Restart and enable service: {{ keycloak.service_name }}" + ansible.builtin.systemd: + name: "{{ keycloak.service_name }}" + enabled: true + state: restarted + daemon_reload: true + become: "{{ keycloak_quarkus_restart_require_privilege_escalation | default(true) }}" + +- name: "Wait until service becomes active: {{ keycloak.service_name }}" + ansible.builtin.uri: + url: "{{ keycloak.health_url }}" + validate_certs: "{{ keycloak_quarkus_validate_certs }}" + register: keycloak_quarkus_status + until: keycloak_quarkus_status.status == 200 + retries: "{{ keycloak_quarkus_restart_health_check_retries }}" + delay: "{{ keycloak_quarkus_restart_health_check_delay }}" + when: internal_force_health_check | default(keycloak_quarkus_restart_health_check) + +- name: Wait to give distributed ispn caches time to (re-)replicate back onto first host + ansible.builtin.pause: + seconds: "{{ keycloak_quarkus_restart_pause }}" + when: + - keycloak_quarkus_ha_enabled diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/none.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/none.yml new file mode 100644 index 00000000..d0489591 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/none.yml @@ -0,0 +1,4 @@ +--- +- name: "Display message" + ansible.builtin.debug: + msg: "keycloak_quarkus_restart_strategy is none, skipping restart" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/serial.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/serial.yml new file mode 100644 index 00000000..2ab52791 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/serial.yml @@ -0,0 +1,11 @@ +--- +- name: "Restart services in serial, with optional healtch check (keycloak_quarkus_restart_health_check)" + throttle: 1 + block: + - name: "Restart and enable service: {{ keycloak.service_name }}" + ansible.builtin.include_tasks: + file: restart.yml + apply: + delegate_to: "{{ item }}" + run_once: true + loop: "{{ ansible_play_hosts }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/serial_then_parallel.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/serial_then_parallel.yml new file mode 100644 index 00000000..17cfd8f6 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/restart/serial_then_parallel.yml @@ -0,0 +1,20 @@ +--- +- name: Verify first restarted service with health URL, then rest restart in parallel + block: + - name: "Restart and enable service on initial host: {{ keycloak.service_name }}" + ansible.builtin.include_tasks: + file: restart.yml + apply: + delegate_to: "{{ ansible_play_hosts | first }}" + run_once: true + vars: + internal_force_health_check: true + + - name: "Restart and enable service on other hosts: {{ keycloak.service_name }}" + ansible.builtin.systemd: + name: "{{ keycloak.service_name }}" + enabled: true + state: restarted + daemon_reload: true + become: "{{ keycloak_quarkus_restart_require_privilege_escalation | default(true) }}" + when: inventory_hostname != ansible_play_hosts | first diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/start.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/start.yml new file mode 100644 index 00000000..c564a3c0 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/start.yml @@ -0,0 +1,18 @@ +--- +- name: "Start service: {{ keycloak.service_name }}" + ansible.builtin.systemd: + name: keycloak + enabled: true + state: started + daemon_reload: true + become: "{{ keycloak_quarkus_start_require_privilege_escalation | default(true) }}" + +- name: "Wait until service becomes active: {{ keycloak.service_name }}" + ansible.builtin.uri: + url: "{{ keycloak.health_url }}" + validate_certs: "{{ keycloak_quarkus_validate_certs }}" + register: keycloak_quarkus_status + until: keycloak_quarkus_status.status == 200 + retries: 25 + delay: 10 + when: internal_force_health_check | default(keycloak_quarkus_restart_health_check) diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/systemd.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/systemd.yml new file mode 100644 index 00000000..44872e30 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/tasks/systemd.yml @@ -0,0 +1,27 @@ +--- +- name: "Configure sysconfig file for service: {{ keycloak.service_name }}" + become: "{{ keycloak_quarkus_systemd_require_privilege_escalation | default(true) }}" + ansible.builtin.template: + src: keycloak-sysconfig.j2 + dest: "{{ keycloak_quarkus_sysconf_file }}" + owner: root + group: root + mode: '0640' + vars: + keycloak_sys_pkg_java_home: "{{ keycloak_quarkus_pkg_java_home }}" + notify: + - rebuild keycloak config + - restart keycloak + +- name: "Configure systemd unit file for keycloak service" + ansible.builtin.template: + src: keycloak.service.j2 + dest: /etc/systemd/system/keycloak.service + owner: root + group: root + mode: '0644' + become: "{{ keycloak_quarkus_systemd_require_privilege_escalation | default(true) }}" + register: keycloak_quarkus_systemdunit + notify: + - rebuild keycloak config + - restart keycloak diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 new file mode 100644 index 00000000..cf74656f --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 @@ -0,0 +1,119 @@ +{{ ansible_managed | comment('xml') }} + + + + +{% set stack_expression='' %} +{% if keycloak_quarkus_version is version_compare('26.2.0', '<') %} +{% if keycloak_quarkus_ha_enabled %} +{% if keycloak_quarkus_ha_discovery == 'TCPPING' %} +{% set stack_expression='stack="tcpping"' %} + + + + + + +{% elif keycloak_quarkus_ha_discovery == 'JDBCPING' %} +{% set stack_expression='stack="JDBC_PING2"' %} +{% endif %} +{% endif %} +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 new file mode 100644 index 00000000..9efd0685 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 @@ -0,0 +1,15 @@ +{{ ansible_managed | comment }} +{% if not ansible_local.keycloak.general.bootstrapped | default(false) | bool %} +KC_BOOTSTRAP_ADMIN_USERNAME={{ keycloak_quarkus_bootstrap_admin_user }} +KC_BOOTSTRAP_ADMIN_PASSWORD='{{ keycloak_quarkus_bootstrap_admin_password }}' +{% else %} +{{ keycloak.bootstrap_mnemonic }} +{% endif %} +PATH="{{ keycloak_quarkus_java_home | default(keycloak_sys_pkg_java_home, true) }}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +JAVA_HOME="{{ keycloak_quarkus_java_home | default(keycloak_sys_pkg_java_home, true) }}" +JAVA_OPTS="{{ keycloak_quarkus_java_opts }}" + +# Custom ENV variables +{% for env in keycloak_quarkus_additional_env_vars %} +{{ env.key }}={{ env.value }} +{% endfor %} diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.conf.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.conf.j2 new file mode 100644 index 00000000..034505d0 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.conf.j2 @@ -0,0 +1,110 @@ +{{ ansible_managed | comment }} + +{% if keycloak_quarkus_db_enabled %} +# Database +db={{ keycloak_quarkus_db_engine }} +db-url={{ keycloak_quarkus_db_url }} +db-username={{ keycloak_quarkus_db_user }} +{% if not keycloak.config_key_store_enabled %} +db-password={{ keycloak_quarkus_db_pass }} +{% endif %} +{% endif %} + +{% if keycloak.config_key_store_enabled %} +# Config store +config-keystore={{ keycloak_quarkus_config_key_store_file }} +config-keystore-password={{ keycloak_quarkus_config_key_store_password }} +{% endif %} + +# Observability +metrics-enabled={{ keycloak_quarkus_metrics_enabled | lower }} +health-enabled={{ keycloak_quarkus_health_enabled | lower }} + +# HTTP +http-enabled={{ keycloak_quarkus_http_enabled | lower }} +{% if keycloak_quarkus_http_enabled %} +http-port={{ keycloak_quarkus_http_port }} +{% endif %} +http-relative-path={{ keycloak_quarkus_http_relative_path }} +http-host={{ keycloak_quarkus_http_host }} + +# Management +http-management-port={{ keycloak_quarkus_http_management_port }} +{% if keycloak_quarkus_http_management_relative_path is defined and keycloak_quarkus_http_management_relative_path | length > 0 %} +http-management-relative-path={{ keycloak_quarkus_http_management_relative_path }} +{% endif %} + +# HTTPS +https-port={{ keycloak_quarkus_https_port }} +{% if keycloak_quarkus_https_key_file_enabled %} +https-certificate-file={{ keycloak_quarkus_cert_file}} +https-certificate-key-file={{ keycloak_quarkus_key_file }} +{% endif %} +{% if keycloak_quarkus_https_key_store_enabled %} +https-key-store-file={{ keycloak_quarkus_https_key_store_file }} +https-key-store-password={{ keycloak_quarkus_https_key_store_password }} +{% endif %} +{% if keycloak_quarkus_https_trust_store_enabled %} +https-trust-store-file={{ keycloak_quarkus_https_trust_store_file }} +https-trust-store-password={{ keycloak_quarkus_https_trust_store_password }} +{% endif %} + +# Client URL configuration +hostname={{ keycloak_quarkus_hostname }} +hostname-admin={{ keycloak_quarkus_hostname_admin }} +hostname-strict={{ keycloak_quarkus_hostname_strict | lower }} +hostname-backchannel-dynamic={{ keycloak_quarkus_hostname_backchannel_dynamic | lower }} + +# Cluster +{% if keycloak_quarkus_ha_enabled %} +cache=ispn +{% if keycloak_quarkus_cache_managed_infinispan_config %} +cache-config-file=cache-ispn.xml +{% endif %} +{% if keycloak_quarkus_cache_remote %} +cache-remote-username={{ keycloak_quarkus_cache_remote_username }} +cache-remote-password={{ keycloak_quarkus_cache_remote_password }} +cache-remote-host={{ keycloak_quarkus_cache_remote_host }} +cache-remote-port={{ keycloak_quarkus_cache_remote_port }} +cache-remote-tls-enabled={{ keycloak_quarkus_cache_remote_tls_enabled | lower }} +{% endif %} +{{ keycloak_quarkus_cache_embedded_properties }} +{% endif %} + +{% if keycloak_quarkus_proxy_headers | length > 0 %} +proxy-headers={{ keycloak_quarkus_proxy_headers | lower }} +{% elif keycloak_quarkus_proxy_mode is defined and keycloak_quarkus_proxy_mode != "none" %} +# Deprecated Proxy configuration +proxy={{ keycloak_quarkus_proxy_mode }} +{% endif %} + +spi-sticky-session-encoder-infinispan-should-attach-route={{ keycloak_quarkus_spi_sticky_session_encoder_infinispan_should_attach_route | d(true) | lower }} + +# Transaction +transaction-xa-enabled={{ keycloak_quarkus_transaction_xa_enabled | lower }} + +# Logging +#log-format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n +log={{ keycloak_quarkus_log }} +log-level={{ keycloak.log.level }} +log-file={{ keycloak.log.file }} +log-file-format={{ keycloak.log.format }} + +# Vault +{% if keycloak_quarkus_ks_vault_enabled %} +vault=keystore +vault-file={{ keycloak_quarkus_ks_vault_file }} +vault-type={{ keycloak_quarkus_ks_vault_type }} +vault-pass={{ keycloak_quarkus_ks_vault_pass }} +{% endif %} + + +# Providers +{% for provider in keycloak_quarkus_providers %} +{% if provider.default is defined and provider.default %} +spi-{{ provider.spi }}-provider={{ provider.id }} +{% endif %} +{% if provider.properties is defined %}{% for property in provider.properties %} +spi-{{ provider.spi }}-{{ provider.id }}-{{ property.key }}={{ property.value }} +{% endfor %}{% endif %} +{% endfor %} diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.fact.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.fact.j2 new file mode 100644 index 00000000..e0351107 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.fact.j2 @@ -0,0 +1,2 @@ +[general] +bootstrapped={{ bootstrapped | lower }} diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.service.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.service.j2 new file mode 100644 index 00000000..96207ed9 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/keycloak.service.j2 @@ -0,0 +1,33 @@ +{{ ansible_managed | comment }} +[Unit] +Description=Keycloak Server +After=network.target + +[Service] +EnvironmentFile=-{{ keycloak_quarkus_sysconf_file }} +{% if keycloak_quarkus_start_dev %} +ExecStart={{ keycloak.home }}/bin/kc.sh start-dev +{% else %} +ExecStart={{ keycloak.home }}/bin/kc.sh start --optimized +{% endif %} +User={{ keycloak.service_user }} +Group={{ keycloak.service_group }} +SuccessExitStatus=0 143 +{% if keycloak_quarkus_service_restart_always %} +Restart=always +{% elif keycloak_quarkus_service_restart_on_failure %} +Restart=on-failure +{% endif %} +RestartSec={{ keycloak_quarkus_service_restartsec }} +{% if keycloak_quarkus_http_port | int < 1024 or keycloak_quarkus_https_port | int < 1024 %} +AmbientCapabilities=CAP_NET_BIND_SERVICE +{% endif %} +{% if keycloak_quarkus_systemd_wait_for_port %} +ExecStartPost=/usr/bin/timeout {{ keycloak_quarkus_systemd_wait_for_timeout }} sh -c 'while ! ss -H -t -l -n sport = :{{ keycloak_quarkus_systemd_wait_for_port_number }} | grep -q "^LISTEN.*:{{ keycloak_quarkus_systemd_wait_for_port_number }}"; do sleep 1; done && /bin/sleep {{ keycloak_quarkus_systemd_wait_for_delay }}' +{% endif %} +{% if keycloak_quarkus_systemd_wait_for_log %} +ExecStartPost=/usr/bin/timeout {{ keycloak_quarkus_systemd_wait_for_timeout }} sh -c 'cat {{ keycloak.log.file }} | sed "/Profile.*activated/ q" && /bin/sleep {{ keycloak_quarkus_systemd_wait_for_delay }}' +{% endif %} + +[Install] +WantedBy=multi-user.target diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/quarkus.properties.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/quarkus.properties.j2 new file mode 100644 index 00000000..cd0f9698 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/quarkus.properties.j2 @@ -0,0 +1,29 @@ +{{ ansible_managed | comment }} +{% if keycloak_quarkus_ha_enabled %} +{% if keycloak_quarkus_version.split('.')[0] | int < 22 %} +quarkus.infinispan-client.server-list={{ keycloak_quarkus_cache_remote_host }}:{{ keycloak_quarkus_cache_remote_port }} +quarkus.infinispan-client.auth-username={{ keycloak_quarkus_cache_remote_username }} +quarkus.infinispan-client.auth-password={{ keycloak_quarkus_cache_remote_password }} +{% else %} +quarkus.infinispan-client.hosts={{ keycloak_quarkus_cache_remote_host }}:{{ keycloak_quarkus_cache_remote_port }} +quarkus.infinispan-client.username={{ keycloak_quarkus_cache_remote_username }} +quarkus.infinispan-client.password={{ keycloak_quarkus_cache_remote_password }} +{% endif %} +quarkus.infinispan-client.client-intelligence=HASH_DISTRIBUTION_AWARE +quarkus.infinispan-client.use-auth=true +quarkus.infinispan-client.auth-realm=default +quarkus.infinispan-client.auth-server-name=infinispan +quarkus.infinispan-client.sasl-mechanism={{ keycloak_quarkus_cache_remote_sasl_mechanism }} +{% if keycloak_quarkus_cache_remote_tls_enabled %} +quarkus.infinispan-client.trust-store={{ keycloak_quarkus_https_trust_store_file }} +quarkus.infinispan-client.trust-store-password={{ keycloak_quarkus_https_trust_store_password }} +quarkus.infinispan-client.trust-store-type=jks +{% endif %} +#quarkus.infinispan-client.use-schema-registration=true +{% endif %} +quarkus.log.file.rotation.max-file-size={{ keycloak_quarkus_log_max_file_size }} +quarkus.log.file.rotation.max-backup-index={{ keycloak_quarkus_log_max_backup_index }} +quarkus.log.file.rotation.file-suffix={{ keycloak_quarkus_log_file_suffix }} +{% if keycloak_quarkus_db_enabled %} +quarkus.transaction-manager.enable-recovery=true +{% endif %} diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk-sysconfig.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk-sysconfig.j2 new file mode 100644 index 00000000..9efd0685 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk-sysconfig.j2 @@ -0,0 +1,15 @@ +{{ ansible_managed | comment }} +{% if not ansible_local.keycloak.general.bootstrapped | default(false) | bool %} +KC_BOOTSTRAP_ADMIN_USERNAME={{ keycloak_quarkus_bootstrap_admin_user }} +KC_BOOTSTRAP_ADMIN_PASSWORD='{{ keycloak_quarkus_bootstrap_admin_password }}' +{% else %} +{{ keycloak.bootstrap_mnemonic }} +{% endif %} +PATH="{{ keycloak_quarkus_java_home | default(keycloak_sys_pkg_java_home, true) }}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +JAVA_HOME="{{ keycloak_quarkus_java_home | default(keycloak_sys_pkg_java_home, true) }}" +JAVA_OPTS="{{ keycloak_quarkus_java_opts }}" + +# Custom ENV variables +{% for env in keycloak_quarkus_additional_env_vars %} +{{ env.key }}={{ env.value }} +{% endfor %} diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.conf.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.conf.j2 new file mode 100644 index 00000000..034505d0 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.conf.j2 @@ -0,0 +1,110 @@ +{{ ansible_managed | comment }} + +{% if keycloak_quarkus_db_enabled %} +# Database +db={{ keycloak_quarkus_db_engine }} +db-url={{ keycloak_quarkus_db_url }} +db-username={{ keycloak_quarkus_db_user }} +{% if not keycloak.config_key_store_enabled %} +db-password={{ keycloak_quarkus_db_pass }} +{% endif %} +{% endif %} + +{% if keycloak.config_key_store_enabled %} +# Config store +config-keystore={{ keycloak_quarkus_config_key_store_file }} +config-keystore-password={{ keycloak_quarkus_config_key_store_password }} +{% endif %} + +# Observability +metrics-enabled={{ keycloak_quarkus_metrics_enabled | lower }} +health-enabled={{ keycloak_quarkus_health_enabled | lower }} + +# HTTP +http-enabled={{ keycloak_quarkus_http_enabled | lower }} +{% if keycloak_quarkus_http_enabled %} +http-port={{ keycloak_quarkus_http_port }} +{% endif %} +http-relative-path={{ keycloak_quarkus_http_relative_path }} +http-host={{ keycloak_quarkus_http_host }} + +# Management +http-management-port={{ keycloak_quarkus_http_management_port }} +{% if keycloak_quarkus_http_management_relative_path is defined and keycloak_quarkus_http_management_relative_path | length > 0 %} +http-management-relative-path={{ keycloak_quarkus_http_management_relative_path }} +{% endif %} + +# HTTPS +https-port={{ keycloak_quarkus_https_port }} +{% if keycloak_quarkus_https_key_file_enabled %} +https-certificate-file={{ keycloak_quarkus_cert_file}} +https-certificate-key-file={{ keycloak_quarkus_key_file }} +{% endif %} +{% if keycloak_quarkus_https_key_store_enabled %} +https-key-store-file={{ keycloak_quarkus_https_key_store_file }} +https-key-store-password={{ keycloak_quarkus_https_key_store_password }} +{% endif %} +{% if keycloak_quarkus_https_trust_store_enabled %} +https-trust-store-file={{ keycloak_quarkus_https_trust_store_file }} +https-trust-store-password={{ keycloak_quarkus_https_trust_store_password }} +{% endif %} + +# Client URL configuration +hostname={{ keycloak_quarkus_hostname }} +hostname-admin={{ keycloak_quarkus_hostname_admin }} +hostname-strict={{ keycloak_quarkus_hostname_strict | lower }} +hostname-backchannel-dynamic={{ keycloak_quarkus_hostname_backchannel_dynamic | lower }} + +# Cluster +{% if keycloak_quarkus_ha_enabled %} +cache=ispn +{% if keycloak_quarkus_cache_managed_infinispan_config %} +cache-config-file=cache-ispn.xml +{% endif %} +{% if keycloak_quarkus_cache_remote %} +cache-remote-username={{ keycloak_quarkus_cache_remote_username }} +cache-remote-password={{ keycloak_quarkus_cache_remote_password }} +cache-remote-host={{ keycloak_quarkus_cache_remote_host }} +cache-remote-port={{ keycloak_quarkus_cache_remote_port }} +cache-remote-tls-enabled={{ keycloak_quarkus_cache_remote_tls_enabled | lower }} +{% endif %} +{{ keycloak_quarkus_cache_embedded_properties }} +{% endif %} + +{% if keycloak_quarkus_proxy_headers | length > 0 %} +proxy-headers={{ keycloak_quarkus_proxy_headers | lower }} +{% elif keycloak_quarkus_proxy_mode is defined and keycloak_quarkus_proxy_mode != "none" %} +# Deprecated Proxy configuration +proxy={{ keycloak_quarkus_proxy_mode }} +{% endif %} + +spi-sticky-session-encoder-infinispan-should-attach-route={{ keycloak_quarkus_spi_sticky_session_encoder_infinispan_should_attach_route | d(true) | lower }} + +# Transaction +transaction-xa-enabled={{ keycloak_quarkus_transaction_xa_enabled | lower }} + +# Logging +#log-format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n +log={{ keycloak_quarkus_log }} +log-level={{ keycloak.log.level }} +log-file={{ keycloak.log.file }} +log-file-format={{ keycloak.log.format }} + +# Vault +{% if keycloak_quarkus_ks_vault_enabled %} +vault=keystore +vault-file={{ keycloak_quarkus_ks_vault_file }} +vault-type={{ keycloak_quarkus_ks_vault_type }} +vault-pass={{ keycloak_quarkus_ks_vault_pass }} +{% endif %} + + +# Providers +{% for provider in keycloak_quarkus_providers %} +{% if provider.default is defined and provider.default %} +spi-{{ provider.spi }}-provider={{ provider.id }} +{% endif %} +{% if provider.properties is defined %}{% for property in provider.properties %} +spi-{{ provider.spi }}-{{ provider.id }}-{{ property.key }}={{ property.value }} +{% endfor %}{% endif %} +{% endfor %} diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.fact.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.fact.j2 new file mode 100644 index 00000000..e0351107 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.fact.j2 @@ -0,0 +1,2 @@ +[general] +bootstrapped={{ bootstrapped | lower }} diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.service.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.service.j2 new file mode 100644 index 00000000..96207ed9 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/templates/rhbk.service.j2 @@ -0,0 +1,33 @@ +{{ ansible_managed | comment }} +[Unit] +Description=Keycloak Server +After=network.target + +[Service] +EnvironmentFile=-{{ keycloak_quarkus_sysconf_file }} +{% if keycloak_quarkus_start_dev %} +ExecStart={{ keycloak.home }}/bin/kc.sh start-dev +{% else %} +ExecStart={{ keycloak.home }}/bin/kc.sh start --optimized +{% endif %} +User={{ keycloak.service_user }} +Group={{ keycloak.service_group }} +SuccessExitStatus=0 143 +{% if keycloak_quarkus_service_restart_always %} +Restart=always +{% elif keycloak_quarkus_service_restart_on_failure %} +Restart=on-failure +{% endif %} +RestartSec={{ keycloak_quarkus_service_restartsec }} +{% if keycloak_quarkus_http_port | int < 1024 or keycloak_quarkus_https_port | int < 1024 %} +AmbientCapabilities=CAP_NET_BIND_SERVICE +{% endif %} +{% if keycloak_quarkus_systemd_wait_for_port %} +ExecStartPost=/usr/bin/timeout {{ keycloak_quarkus_systemd_wait_for_timeout }} sh -c 'while ! ss -H -t -l -n sport = :{{ keycloak_quarkus_systemd_wait_for_port_number }} | grep -q "^LISTEN.*:{{ keycloak_quarkus_systemd_wait_for_port_number }}"; do sleep 1; done && /bin/sleep {{ keycloak_quarkus_systemd_wait_for_delay }}' +{% endif %} +{% if keycloak_quarkus_systemd_wait_for_log %} +ExecStartPost=/usr/bin/timeout {{ keycloak_quarkus_systemd_wait_for_timeout }} sh -c 'cat {{ keycloak.log.file }} | sed "/Profile.*activated/ q" && /bin/sleep {{ keycloak_quarkus_systemd_wait_for_delay }}' +{% endif %} + +[Install] +WantedBy=multi-user.target diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/debian.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/debian.yml new file mode 100644 index 00000000..5391dda5 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/debian.yml @@ -0,0 +1,12 @@ +--- +keycloak_quarkus_varjvm_package: "{{ keycloak_quarkus_jvm_package | default('openjdk-21-jdk-headless') }}" +keycloak_quarkus_prereq_package_list: + - "{{ keycloak_quarkus_varjvm_package }}" + - bash + - unzip + - procps + - apt + - tzdata +keycloak_quarkus_sysconf_file: /etc/default/keycloak +keycloak_quarkus_pkg_java_home: "/usr/lib/jvm/java-{{ keycloak_quarkus_varjvm_package | \ + regex_search('(?!:openjdk-)[0-9.]+') }}-openjdk-{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/main.yml new file mode 100644 index 00000000..4e5b38e7 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/main.yml @@ -0,0 +1,17 @@ +--- +keycloak: # noqa var-naming this is an internal dict of interpolated values + home: "{{ keycloak_quarkus_home }}" + config_dir: "{{ keycloak_quarkus_config_dir }}" + bundle: "{{ keycloak_quarkus_archive }}" + service_name: "keycloak" + health_url: "{{ keycloak_quarkus_health_check_url }}" + cli_path: "{{ keycloak_quarkus_home }}/bin/kcadm.sh" + service_user: "{{ keycloak_quarkus_service_user }}" + service_group: "{{ keycloak_quarkus_service_group }}" + offline_install: "{{ keycloak_quarkus_offline_install }}" + config_key_store_enabled: "{{ keycloak_quarkus_config_key_store_password != '' }}" + log: + file: "{{ keycloak_quarkus_home }}/{{ keycloak_quarkus_log_file }}" + level: "{{ keycloak_quarkus_log_level }}" + format: "{{ keycloak_quarkus_log_format }}" + bootstrap_mnemonic: "# ansible-middleware/keycloak: bootstrapped" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/redhat.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/redhat.yml new file mode 100644 index 00000000..458c8417 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_quarkus/vars/redhat.yml @@ -0,0 +1,11 @@ +--- +keycloak_quarkus_varjvm_package: "{{ keycloak_quarkus_jvm_package | default('java-21-openjdk-headless') }}" +keycloak_quarkus_prereq_package_list: + - "{{ keycloak_quarkus_varjvm_package }}" + - bash + - unzip + - procps-ng + - initscripts + - tzdata-java +keycloak_quarkus_sysconf_file: /etc/sysconfig/keycloak +keycloak_quarkus_pkg_java_home: "/etc/alternatives/jre_{{ keycloak_quarkus_varjvm_package | regex_search('(?<=java-)[0-9.]+') }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/README.md b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/README.md new file mode 100644 index 00000000..120a98fb --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/README.md @@ -0,0 +1,196 @@ +keycloak_realm +============== + + +Create realms and clients in [keycloak](https://keycloak.org/) or [Red Hat Single Sign-On](https://access.redhat.com/products/red-hat-single-sign-on) services. + + +Role Defaults +------------- + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_realm_admin_user`| Administration console user account | `admin` | +|`keycloak_realm_host`| hostname | `localhost` | +|`keycloak_realm_context`| Context path for rest calls (set to `/auth` for legacy WildFly-based Keycloak) | `` | +|`keycloak_realm_http_port`| HTTP port | `8080` | +|`keycloak_realm_https_port`| TLS HTTP port | `8443` | +|`keycloak_realm_auth_realm`| Name of the main authentication realm | `master` | +|`keycloak_realm_management_http_port`| Management port | `9990` | +|`keycloak_realm_auth_client`| Authentication client for configuration REST calls | `admin-cli` | +|`keycloak_realm_client_public`| Configure a public realm client | `True` | +|`keycloak_realm_client_web_origins`| Web origins for realm client | `/*` | +|`keycloak_realm_url`| URL for configuration rest calls | `http://{{ keycloak_realm_host }}:{{ keycloak_realm_http_port }}` | +|`keycloak_realm_management_url`| URL for management console rest calls | `http://{{ keycloak_realm_host }}:{{ keycloak_realm_management_http_port }}` | + + +Role Variables +-------------- + +The following are a set of _required_ variables for the role: + +| Variable | Description | +|:---------|:------------| +|`keycloak_realm_realm` | Name of the realm to be created | +|`keycloak_realm_admin_password`| Password for the administration console user account | + + +The following variables are available for creating clients: + +| Variable | Description | Default | +|:---------|:------------|:---------| +|`keycloak_realm_clients` | List of _client_ declarations for the realm | `[]` | +|`keycloak_realm_client_default_roles` | List of default role name for clients | `[]` | +|`keycloak_realm_client_users` | List of user/role mappings for a client | `[]` | + + +The following variables are available for creating user federation: + +| Variable | Description | Default | +|:---------|:------------|:---------| +|`keycloak_realm_user_federation` | List of _keycloak_user_federation_ for the realm | `[]` | + + +Variable formats +---------------- + +* `keycloak_realm_user_federation`, a list of: + +```yaml + - realm: + name: + provider_id: + provider_type: + config: + mappers: +``` + +Refer to [docs](https://docs.ansible.com/ansible/latest/collections/community/general/keycloak_user_federation_module.html) for information on supported variables. + + +* `keycloak_realm_clients`, a list of: + +```yaml + - name: + id: + client_id: + secret: + roles: + realm: + public_client: + web_origins: + users: +``` + +`name` and either `id` or `client_id` are required. + + +* `keycloak_realm_client_users`, a list of: + +```yaml + - username: + password: + firstName: + lastName: + email: + client_roles: +``` + +* Client user/role mappings, a list of: + +```yaml + - client: + role: + realm: +``` + +For a comprehensive example, refer to the [playbook](../../playbooks/keycloak_realm.yml). + + +Related Modules +--------------- + +For features not covered by this role, the collection provides dedicated modules: + +| Module | What It Manages | +|:-------|:----------------| +| `keycloak_client_scope` | Client scopes and protocol mappers — see [example playbook](../../playbooks/keycloak_client_scope.yml) | +| `keycloak_authentication_flow` | Authentication flows and execution steps — see [example playbook](../../playbooks/keycloak_authentication_flow.yml) | +| `keycloak_client` | Clients (also used internally by this role) | +| `keycloak_role` | Realm and client roles | +| `keycloak_realm_user_federation` | User federations such as LDAP (also used internally by this role) | + + +Example Playbook +---------------- + +The following is an example playbook that makes use of the role to create a realm in keycloak. + +```yaml +--- +- hosts: ... + collections: + - middleware_automation.keycloak + tasks: + - name: Include keycloak role + include_role: + name: keycloak_realm + vars: + keycloak_admin_password: "changeme" + keycloak_realm: TestRealm + keycloak_clients: [...] +``` + +The following example uses the `keycloak_client_scope` module to create a client scope with protocol mappers: + +```yaml +- name: Create client scope + middleware_automation.keycloak.keycloak_client_scope: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: changeme + realm: TestRealm + name: my-scope + protocol_mappers: + - name: email + protocolMapper: oidc-usermodel-attribute-mapper + config: + user.attribute: email + claim.name: email + id.token.claim: "true" + access.token.claim: "true" + state: present +``` + +The following example uses the `keycloak_authentication_flow` module to create a custom authentication flow: + +```yaml +- name: Create authentication flow + middleware_automation.keycloak.keycloak_authentication_flow: + auth_keycloak_url: http://localhost:8080 + auth_realm: master + auth_username: admin + auth_password: changeme + realm: TestRealm + alias: my-browser-flow + executions: + - provider_id: auth-cookie + requirement: ALTERNATIVE + - provider_id: auth-password + requirement: REQUIRED + state: present +``` + + +License +------- + +Apache License 2.0 + + +Author Information +------------------ + +* [Guido Grazioli](https://github.com/guidograzioli) +* [Romain Pelisse](https://github.com/rpelisse) diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/defaults/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/defaults/main.yml new file mode 100644 index 00000000..6de5faab --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/defaults/main.yml @@ -0,0 +1,61 @@ +--- +### Keycloak configuration settings +keycloak_realm_host: localhost +keycloak_realm_http_port: 8080 +keycloak_realm_https_port: 8443 +keycloak_realm_management_http_port: 9990 + +### Keycloak administration console user +keycloak_realm_admin_user: admin +keycloak_realm_auth_realm: master +keycloak_realm_auth_client: admin-cli +keycloak_realm_context: '' + +# administrator console password, this is a required variable +keycloak_realm_admin_password: '' + +### Keycloak realms, clients, roles, federation +# list of clients to create in the realm +# +# Refer to the playbook for a comprehensive example. +# Also refer to meta/argument_specs.yml for specifications. +# +# Each client has the form: +# { name: '', roles: [], realm: '', public_client: bool, web_origins: '', users: [] } +# where roles is a list of default role names for the client +# and users is a list of account, see below for the format definition +# an empty name will skip the creation of the client +# +# keycloak_realm_clients: +# - name: '' +# roles: "{{ keycloak_realm_client_default_roles }}" +# realm: "{{ keycloak_realm_realm }}" +# public_client: "{{ keycloak_realm_client_public }}" +# web_origins: "{{ keycloak_realm_client_web_origins }}" +# redirect_uris: "{{ keycloak_realm_client_redirect_uris }}" +# users: "{{ keycloak_realm_client_users }}" +keycloak_realm_clients: [] + +# list of roles to create in the client +keycloak_realm_client_default_roles: [] + +# if True, create a public client; otherwise, a confidetial client +keycloak_realm_client_public: true + +# allowed web origins for the client +keycloak_realm_client_web_origins: '/*' + +# list of user and role mappings to create in the client +# Each user has the form: +# { username: '', password: '', email: '', firstName: '', lastName: '', client_roles: [] } +# where each client_role has the form: +# { client: '', role: '', realm: '' } +keycloak_realm_client_users: [] + +### List of Keycloak User Federation +keycloak_realm_user_federation: [] + +# other settings +keycloak_realm_url: "http://{{ keycloak_realm_host }}:{{ keycloak_realm_http_port + (keycloak_realm_jboss_port_offset | default(0)) }}" +keycloak_realm_management_url: "http://{{ keycloak_realm_host }}:{{ keycloak_realm_management_http_port + (keycloak_realm_jboss_port_offset | default(0)) }}" +keycloak_realm_no_log: true diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/meta/argument_specs.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/meta/argument_specs.yml new file mode 100644 index 00000000..3d213308 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/meta/argument_specs.yml @@ -0,0 +1,135 @@ +argument_specs: + main: + options: + keycloak_realm_host: + # line 3 of keycloak_realm/defaults/main.yml + default: "localhost" + description: "Hostname for rest calls" + type: "str" + keycloak_realm_context: + # line 5 of keycloak_realm/defaults/main.yml + default: "" + description: "Context path for rest calls (was /auth for legacy WildFly-based Keycloak, empty for Quarkus-based Keycloak/RHBK)" + type: "str" + keycloak_realm_http_port: + # line 4 of keycloak_realm/defaults/main.yml + default: 8080 + description: "HTTP port" + type: "int" + keycloak_realm_https_port: + # line 5 of keycloak_realm/defaults/main.yml + default: 8443 + description: "HTTPS port" + type: "int" + keycloak_realm_management_http_port: + # line 6 of keycloak_realm/defaults/main.yml + default: 9990 + description: "Management port" + type: "int" + keycloak_realm_admin_user: + # line 10 of keycloak_realm/defaults/main.yml + default: "admin" + description: "Administration console user account" + type: "str" + keycloak_realm_auth_realm: + # line 11 of keycloak_realm/defaults/main.yml + default: "master" + description: "Name of the main authentication realm" + type: "str" + keycloak_realm_auth_client: + # line 12 of keycloak_realm/defaults/main.yml + default: "admin-cli" + description: "Authentication client for configuration REST calls" + type: "str" + keycloak_realm_client_default_roles: + # line 36 of keycloak_realm/defaults/main.yml + default: "[]" + description: "List of roles to configure as client default" + type: "list" + keycloak_realm_client_public: + # line 39 of keycloak_realm/defaults/main.yml + default: true + description: "Configure a public realm client" + type: "bool" + keycloak_realm_client_web_origins: + # line 42 of keycloak_realm/defaults/main.yml + default: "/*" + description: "Web origins for realm client" + type: "str" + keycloak_realm_client_users: + # line 49 of keycloak_realm/defaults/main.yml + default: "[]" + description: "List of users to configure in the realm client" + type: "list" + keycloak_realm_user_federation: + # line 52 of keycloak_realm/defaults/main.yml + default: "[]" + description: "List of user federations to configure in the realm" + type: "list" + keycloak_realm_admin_password: + # line 5 of keycloak_realm/vars/main.yml + required: true + description: "Password for the administration console user account" + type: "str" + keycloak_realm_realm: + # line 8 of keycloak_realm/vars/main.yml + required: true + description: "Name of the realm to be configured" + type: "str" + keycloak_realm_clients: + # line 11 of keycloak_realm/vars/main.yml + default: "[]" + description: "List of client declarations for the realm" + type: "list" + keycloak_realm_url: + # line 14 of keycloak_realm/vars/main.yml + default: "http://{{ keycloak_realm_host }}:{{ keycloak_realm_http_port + ( keycloak_realm_jboss_port_offset | default(0) ) }}" + description: "URL for configuration rest calls" + type: "str" + keycloak_realm_management_url: + # line 15 of keycloak_realm/vars/main.yml + default: "http://{{ keycloak_realm_host }}:{{ keycloak_realm_management_http_port + ( keycloak_realm_jboss_port_offset | default(0) ) }}" + description: "URL for management console rest calls" + type: "str" + downstream: + options: + sso_version: + default: "7.6.0" + description: "Red Hat Single Sign-On version" + type: "str" + sso_dest: + default: "/opt/sso" + description: "Root installation directory" + type: "str" + sso_installdir: + default: "{{ sso_dest }}/rh-sso-{{ sso_version.split('.')[0] }}.{{ sso_version.split('.')[1] }}" + description: "Installation path for Red Hat SSO" + type: "str" + sso_apply_patches: + default: false + description: "Install Red Hat SSO most recent cumulative patch" + type: "bool" + sso_enable: + default: true + description: "Enable Red Hat Single Sign-on installation" + type: "bool" + rhbk_version: + default: "22.0.6" + description: "Red Hat Build of Keycloak version" + type: "str" + rhbk_archive: + default: "rhbk-{{ rhbk_version }}.zip" + description: "Red Hat Build of Keycloak install archive filename" + type: "str" + rhbk_dest: + default: "/opt/rhbk" + description: "Root installation directory" + type: "str" + rhbk_installdir: + default: "{{ rhbk_dest }}/rhbk-{{ rhbk_version.split('.')[0] }}.{{ rhbk_version.split('.')[1] }}" + description: "Installation path for Red Hat Build of Keycloak" + type: "str" + rhbk_enable: + default: true + description: "Enable Red Hat Build of Keycloak installation" + type: "bool" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/meta/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/meta/main.yml new file mode 100644 index 00000000..97c69d2d --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/meta/main.yml @@ -0,0 +1,22 @@ +--- +galaxy_info: + role_name: keycloak_realm + namespace: middleware_automation + author: Romain Pelisse, Guido Grazioli + description: Create realms and clients in keycloak or Red Hat Single Sign-On + company: Red Hat, Inc. + + license: Apache License 2.0 + + min_ansible_version: "2.16" + + platforms: + - name: EL + versions: + - "8" + + galaxy_tags: + - keycloak + - redhat + - rhel + - sso diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/main.yml new file mode 100644 index 00000000..ec82dabb --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/main.yml @@ -0,0 +1,119 @@ +--- +- name: Generate keycloak auth token + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/realms/master/protocol/openid-connect/token" + method: POST + body: "client_id={{ keycloak_realm_auth_client }}&username={{ keycloak_realm_admin_user }}&password={{ keycloak_realm_admin_password }}&grant_type=password" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + no_log: "{{ keycloak_realm_no_log | default('True') }}" + register: keycloak_realm_auth_response + until: keycloak_realm_auth_response.status == 200 + retries: 5 + delay: 2 + +- name: "Determine if realm exists" + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms/{{ keycloak_realm_realm }}" + method: GET + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + status_code: + - 200 + - 404 + headers: + Accept: "application/json" + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" + register: keycloak_realm_realm_exists + +- name: Create Realm + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms" + method: POST + body: "{{ lookup('template', 'realm.json.j2') }}" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + body_format: json + headers: + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" + status_code: 201 + when: keycloak_realm_realm_exists.status == 404 + +- name: Create user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}" + auth_realm: "{{ keycloak_realm_auth_realm }}" + auth_username: "{{ keycloak_realm_admin_user }}" + auth_password: "{{ keycloak_realm_admin_password }}" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + realm: "{{ item.realm | default(keycloak_realm_realm) }}" + name: "{{ item.name }}" + state: present + provider_id: "{{ item.provider_id }}" + provider_type: "{{ item.provider_type | default('org.keycloak.storage.UserStorageProvider') }}" + config: "{{ item.config }}" + mappers: "{{ item.mappers | default(omit) }}" + no_log: "{{ keycloak_realm_no_log | default('True') }}" + register: keycloak_realm_create_user_federation_result + loop: "{{ keycloak_realm_user_federation | flatten }}" + when: keycloak_realm_user_federation is defined + +- name: Validate Keycloak clients + ansible.builtin.assert: + that: + - item.name is defined and item.name | length > 0 + - (item.client_id is defined and item.client_id | length > 0) or (item.id is defined and item.id | length > 0) + fail_msg: "For each keycloak client, attributes `name` and either `id` or `client_id` is required" + quiet: true + loop: "{{ keycloak_realm_clients | flatten }}" + loop_control: + label: "{{ item.name | default('unnamed client') }}" + +- name: Create or update a Keycloak client + middleware_automation.keycloak.keycloak_client: + auth_client_id: "{{ keycloak_realm_auth_client }}" + auth_keycloak_url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}" + auth_realm: "{{ keycloak_realm_auth_realm }}" + auth_username: "{{ keycloak_realm_admin_user }}" + auth_password: "{{ keycloak_realm_admin_password }}" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + realm: "{{ item.realm | default(keycloak_realm_realm) }}" + default_roles: "{{ item.roles | default(omit) }}" + client_id: "{{ item.client_id | default(omit) }}" + id: "{{ item.id | default(omit) }}" + secret: "{{ item.secret | default(omit) }}" + name: "{{ item.name | default(omit) }}" + description: "{{ item.description | default(omit) }}" + root_url: "{{ item.root_url | default('') }}" + admin_url: "{{ item.admin_url | default('') }}" + base_url: "{{ item.base_url | default('') }}" + enabled: "{{ item.enabled | default(True) }}" + redirect_uris: "{{ item.redirect_uris | default(omit) }}" + web_origins: "{{ item.web_origins | default(omit) }}" + bearer_only: "{{ item.bearer_only | default(omit) }}" + standard_flow_enabled: "{{ item.standard_flow_enabled | default(omit) }}" + implicit_flow_enabled: "{{ item.implicit_flow_enabled | default(omit) }}" + direct_access_grants_enabled: "{{ item.direct_access_grants_enabled | default(omit) }}" + service_accounts_enabled: "{{ item.service_accounts_enabled | default(omit) }}" + public_client: "{{ item.public_client | default(False) }}" + protocol: "{{ item.protocol | default(omit) }}" + attributes: "{{ item.attributes | default(omit) }}" + state: present + no_log: "{{ keycloak_realm_no_log | default('false') }}" + register: keycloak_realm_create_client_result + loop: "{{ keycloak_realm_clients | flatten }}" + when: (item.name is defined and item.client_id is defined) or (item.name is defined and item.id is defined) + +- name: Create client roles + ansible.builtin.include_tasks: manage_client_roles.yml + loop: "{{ keycloak_realm_clients | flatten }}" + loop_control: + loop_var: client + when: "'roles' in client" + +- name: Create client users + ansible.builtin.include_tasks: manage_client_users.yml + loop: "{{ keycloak_realm_clients | flatten }}" + loop_control: + loop_var: client + when: "'users' in client" + +- name: Provide Access token lifespan + ansible.builtin.include_tasks: manage_token_lifespan.yml diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_client_roles.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_client_roles.yml new file mode 100644 index 00000000..e046aa90 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_client_roles.yml @@ -0,0 +1,14 @@ +- name: Create client roles + middleware_automation.keycloak.keycloak_role: + name: "{{ item }}" + realm: "{{ client.realm | default(keycloak_realm_realm) }}" + client_id: "{{ client.client_id }}" + auth_client_id: "{{ keycloak_realm_auth_client }}" + auth_keycloak_url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}" + auth_realm: "{{ keycloak_realm_auth_realm }}" + auth_username: "{{ keycloak_realm_admin_user }}" + auth_password: "{{ keycloak_realm_admin_password }}" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + state: present + loop: "{{ client.roles | flatten }}" + no_log: "{{ keycloak_realm_no_log | default('True') }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_client_users.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_client_users.yml new file mode 100644 index 00000000..5234cb11 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_client_users.yml @@ -0,0 +1,13 @@ +--- +- name: Manage Users + ansible.builtin.include_tasks: manage_user.yml + loop: "{{ client.users | flatten }}" + loop_control: + loop_var: user + +- name: Manage User Roles + ansible.builtin.include_tasks: manage_user_roles.yml + loop: "{{ client.users | flatten }}" + loop_control: + loop_var: user + when: "'client_roles' in user" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_token_lifespan.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_token_lifespan.yml new file mode 100644 index 00000000..6133fc39 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_token_lifespan.yml @@ -0,0 +1,14 @@ +--- +- name: "Update Access token lifespan" + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms/{{ keycloak_realm_realm }}" + method: PUT + body: + accessTokenLifespan: 300 + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + body_format: json + status_code: + - 200 + - 204 + headers: + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user.yml new file mode 100644 index 00000000..1de35052 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user.yml @@ -0,0 +1,51 @@ +--- +- name: "Check if User Already Exists" + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms/{{ keycloak_realm_realm }}/users?username={{ user.username }}" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + headers: + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" + register: keycloak_realm_user_search_result + +- name: "Create User" + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms/{{ keycloak_realm_realm }}/users" + method: POST + body: + enabled: true + attributes: "{{ user.attributes | default(omit) }}" + username: "{{ user.username }}" + email: "{{ user.email | default(omit) }}" + firstName: "{{ user.firstName | default(omit) }}" + lastName: "{{ user.lastName | default(omit) }}" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + body_format: json + headers: + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" + status_code: 201 + when: keycloak_realm_user_search_result.json | length == 0 + +- name: "Get User" + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms/{{ keycloak_realm_realm }}/users?username={{ user.username }}" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + headers: + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" + register: keycloak_realm_user + +- name: "Update User Password" + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms/{{ keycloak_realm_realm }}/users/{{ (keycloak_realm_user.json | first).id }}/reset-password" + method: PUT + body: + type: password + temporary: false + value: "{{ user.password }}" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + body_format: json + status_code: + - 200 + - 204 + headers: + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" + register: keycloak_realm_user diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user_client_roles.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user_client_roles.yml new file mode 100644 index 00000000..1a6f2502 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user_client_roles.yml @@ -0,0 +1,46 @@ +--- +- name: "Get Realm for role" + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms/{{ client_role.realm | default(keycloak_realm_realm) }}" + method: GET + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + status_code: + - 200 + headers: + Accept: "application/json" + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" + register: keycloak_realm_client_role_realm + +- name: Check if Mapping is available + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms/{{ client_role.realm | \ + default(keycloak_realm_realm) }}/users/{{ (keycloak_realm_user.json | first).id }}/role-mappings/clients/{{ (keycloak_realm_create_client_result.results | \ + selectattr('end_state.clientId', 'equalto', client_role.client) | list | first).end_state.id }}/available" + method: GET + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + status_code: + - 200 + headers: + Accept: "application/json" + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" + register: keycloak_realm_client_role_user_available + +- name: "Create Role Mapping" + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms/{{ client_role.realm | \ + default(keycloak_realm_realm) }}/users/{{ (keycloak_realm_user.json | first).id }}/role-mappings/clients/{{ (keycloak_realm_create_client_result.results | \ + selectattr('end_state.clientId', 'equalto', client_role.client) | list | first).end_state.id }}" + method: POST + body: + - id: "{{ item.id }}" + clientRole: "{{ item.clientRole }}" + containerId: "{{ item.containerId }}" + name: "{{ item.name }}" + composite: "{{ item.composite }}" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + body_format: json + headers: + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" + status_code: 204 + loop: "{{ keycloak_realm_client_role_user_available.json | flatten }}" + when: item.name == client_role.role diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user_roles.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user_roles.yml new file mode 100644 index 00000000..48758a36 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/tasks/manage_user_roles.yml @@ -0,0 +1,26 @@ +--- +- name: "Get User {{ user.username }}" + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/admin/realms/{{ keycloak_realm_realm }}/users?username={{ user.username }}" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + headers: + Authorization: "Bearer {{ keycloak_realm_auth_response.json.access_token }}" + register: keycloak_realm_user + +- name: Refresh keycloak auth token + ansible.builtin.uri: + url: "{{ keycloak_realm_url }}{{ keycloak_realm_context }}/realms/master/protocol/openid-connect/token" + method: POST + body: "client_id={{ keycloak_realm_auth_client }}&username={{ keycloak_realm_admin_user }}&password={{ keycloak_realm_admin_password }}&grant_type=password" + validate_certs: "{{ keycloak_realm_validate_certs | default(omit) }}" + register: keycloak_realm_auth_response + no_log: "{{ keycloak_realm_no_log | default('True') }}" + until: keycloak_realm_auth_response.status == 200 + retries: 5 + delay: 2 + +- name: "Manage Client Role Mapping for {{ user.username }}" + ansible.builtin.include_tasks: manage_user_client_roles.yml + loop: "{{ user.client_roles | flatten }}" + loop_control: + loop_var: client_role diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/templates/realm.json.j2 b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/templates/realm.json.j2 new file mode 100644 index 00000000..e442af32 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/templates/realm.json.j2 @@ -0,0 +1,7 @@ +{ + "id": "{{ keycloak_realm_realm }}", + "realm": "{{ keycloak_realm_realm }}", + "enabled": true, + "eventsEnabled": true, + "eventsExpiration": 7200 +} \ No newline at end of file diff --git a/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/vars/main.yml b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/vars/main.yml new file mode 100644 index 00000000..ec9ddfe8 --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/roles/keycloak_realm/vars/main.yml @@ -0,0 +1,5 @@ +--- +# vars file for keycloak_realm + +# name of the realm to create, this is a required variable +keycloak_realm_realm: diff --git a/ansible_collections/middleware_automation/keycloak/tests/config.yml b/ansible_collections/middleware_automation/keycloak/tests/config.yml new file mode 100644 index 00000000..e129f9ae --- /dev/null +++ b/ansible_collections/middleware_automation/keycloak/tests/config.yml @@ -0,0 +1,5 @@ +--- +# See: https://github.com/ansible/ansible/blob/devel/test/lib/ansible_test/config/config.yml +# Modules use ``from __future__ import annotations`` (Python 3.7+). +modules: + python_requires: '>=3.7' diff --git a/requirements.yml b/requirements.yml index 8f483589..ca06e855 100644 --- a/requirements.yml +++ b/requirements.yml @@ -11,3 +11,7 @@ collections: - source: https://github.com/ansible-collections/community.docker type: git version: "5.0.5" + # middleware_automation.keycloak + - source: https://github.com/ansible-middleware/keycloak + type: git + version: "3.0.11" -- 2.54.0 From 32b5f147bcd128f85f2697d7b0b1af1276daecba Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Wed, 2 Sep 2026 11:07:45 +0200 Subject: [PATCH 2/2] Manage the CCCHH browser authentication flow as YAML Adds a keycloak_auth_flow role that locally validates flow definitions (structure, nesting depth, known provider IDs, and a set of Keycloak authentication-flow gotchas: conditions silently ignored outside a Conditional subflow, keycloak/keycloak#29515's OTP+WebAuthn sibling bug, conditional-credential config shape/semantics, and more) before deploying them idempotently via middleware_automation.keycloak.keycloak_authentication_v2. Includes the CCCHH realm's actual "browser passkey and token" flow (the realm's real browserFlow binding, not the untouched built-in "browser" flow), exported from the live server so it's now reviewable and redeployable from this repo instead of only editable in the Keycloak admin console. --- .../chaosknoten/host_vars/keycloak.yaml | 4 + inventories/chaosknoten/hosts.yaml | 3 + playbooks/deploy.yaml | 7 + .../keycloak/auth_flows/browser.yaml | 62 +++ roles/keycloak_auth_flow/README.md | 114 ++++++ roles/keycloak_auth_flow/defaults/main.yaml | 3 + .../library/keycloak_auth_flow_lint.py | 374 ++++++++++++++++++ .../meta/argument_specs.yaml | 24 ++ roles/keycloak_auth_flow/tasks/main.yaml | 25 ++ 9 files changed, 616 insertions(+) create mode 100644 resources/chaosknoten/keycloak/auth_flows/browser.yaml create mode 100644 roles/keycloak_auth_flow/README.md create mode 100644 roles/keycloak_auth_flow/defaults/main.yaml create mode 100644 roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py create mode 100644 roles/keycloak_auth_flow/meta/argument_specs.yaml create mode 100644 roles/keycloak_auth_flow/tasks/main.yaml diff --git a/inventories/chaosknoten/host_vars/keycloak.yaml b/inventories/chaosknoten/host_vars/keycloak.yaml index c288da52..5676172e 100644 --- a/inventories/chaosknoten/host_vars/keycloak.yaml +++ b/inventories/chaosknoten/host_vars/keycloak.yaml @@ -17,3 +17,7 @@ nginx__configurations: content: "{{ lookup('ansible.builtin.file', 'resources/chaosknoten/keycloak/nginx/keycloak-admin.hamburg.ccc.de.conf') }}" - name: invite.hamburg.ccc.de content: "{{ lookup('ansible.builtin.file', 'resources/chaosknoten/keycloak/nginx/invite.hamburg.ccc.de.conf') }}" + +keycloak_auth_flow__admin_password: "{{ secret__keycloak_admin_password }}" +keycloak_auth_flow__flows: + - "{{ lookup('ansible.builtin.file', 'resources/chaosknoten/keycloak/auth_flows/browser.yaml') | from_yaml }}" diff --git a/inventories/chaosknoten/hosts.yaml b/inventories/chaosknoten/hosts.yaml index 0c841fb6..494c7ace 100644 --- a/inventories/chaosknoten/hosts.yaml +++ b/inventories/chaosknoten/hosts.yaml @@ -162,6 +162,9 @@ docker_compose_hosts: nextcloud_hosts: hosts: cloud: +keycloak_auth_flow_hosts: + hosts: + keycloak: nginx_hosts: hosts: acmedns: diff --git a/playbooks/deploy.yaml b/playbooks/deploy.yaml index 2a1498ac..10c7fcd9 100644 --- a/playbooks/deploy.yaml +++ b/playbooks/deploy.yaml @@ -110,6 +110,13 @@ tags: - docker_compose +- name: Ensure Keycloak authentication flows are configured + hosts: keycloak_auth_flow_hosts + roles: + - keycloak_auth_flow + tags: + - keycloak_auth_flow + - name: Ensure NGINX deployment on nginx_hosts hosts: nginx_hosts:!public_reverse_proxy_hosts roles: diff --git a/resources/chaosknoten/keycloak/auth_flows/browser.yaml b/resources/chaosknoten/keycloak/auth_flows/browser.yaml new file mode 100644 index 00000000..028bd70e --- /dev/null +++ b/resources/chaosknoten/keycloak/auth_flows/browser.yaml @@ -0,0 +1,62 @@ +--- +realm: ccchh +alias: browser passkey and token +description: passkey or (password and (TOTP or token)) +providerId: basic-flow +authenticationExecutions: + - providerId: auth-cookie + requirement: ALTERNATIVE + - providerId: auth-spnego + requirement: DISABLED + - providerId: identity-provider-redirector + requirement: ALTERNATIVE + - subFlow: browser passkey and token browser passkey Organization + requirement: DISABLED + authenticationExecutions: + - subFlow: browser passkey and token browser passkey Browser - Conditional Organization + requirement: CONDITIONAL + authenticationExecutions: + - providerId: conditional-user-configured + requirement: REQUIRED + - providerId: organization + requirement: ALTERNATIVE + - subFlow: browser passkey and token browser passkey forms + requirement: ALTERNATIVE + authenticationExecutions: + - subFlow: browser passkey and token Passkey or Password + requirement: REQUIRED + authenticationExecutions: + - providerId: webauthn-authenticator-passwordless + requirement: ALTERNATIVE + - subFlow: browser passkey and token Password + OTP + requirement: ALTERNATIVE + authenticationExecutions: + - providerId: auth-username-password-form + requirement: REQUIRED + - subFlow: browser passkey and token Conditional OTP + requirement: CONDITIONAL + authenticationExecutions: + - providerId: conditional-credential + requirement: REQUIRED + authenticationConfig: + alias: browser passkey and token not passkey + config: + credentials: webauthn-passwordless + included: 'false' + - providerId: conditional-user-configured + requirement: REQUIRED + - providerId: auth-otp-form + requirement: REQUIRED + - subFlow: WebAuthn Authenticator if configured for the user + requirement: CONDITIONAL + authenticationExecutions: + - providerId: conditional-credential + requirement: REQUIRED + authenticationConfig: + alias: webauthn-passwordless + config: + credentials: webauthn-passwordless + - providerId: conditional-user-configured + requirement: REQUIRED + - providerId: webauthn-authenticator + requirement: REQUIRED diff --git a/roles/keycloak_auth_flow/README.md b/roles/keycloak_auth_flow/README.md new file mode 100644 index 00000000..6c271360 --- /dev/null +++ b/roles/keycloak_auth_flow/README.md @@ -0,0 +1,114 @@ +# `keycloak_auth_flow` role + +Manages Keycloak authentication flows (for example a realm's `browser` flow) as YAML checked +into this repo, instead of hand-editing them in the Keycloak admin console. + +Each flow is validated locally (structure, nesting depth, known provider IDs) with the custom +[`keycloak_auth_flow_lint`](library/keycloak_auth_flow_lint.py) module before being deployed with +`middleware_automation.keycloak.keycloak_authentication_v2`, which applies changes idempotently +via a "safe swap" so the realm is never left without a working flow mid-update. + +Both tasks run against `keycloak_auth_flow__admin_url`, which defaults to the local +`http://127.0.0.1:8080` — i.e. this role is meant to run on the Keycloak host itself, talking to +the Keycloak container directly, since the public admin hostname is IP-restricted. + +## Required Arguments + +- `keycloak_auth_flow__admin_password`: Password of `keycloak_auth_flow__admin_username` (an + admin in the `master` realm). + +## Optional Arguments + +- `keycloak_auth_flow__flows`: List of flow definitions to deploy. Defaults to `[]`. Each item: + - `realm`: Realm the flow belongs to. + - `alias`: Name of the flow. + - `providerId`: `basic-flow` (default) or `client-flow`. + - `description`: Optional human-readable description. + - `authenticationExecutions`: Nested list describing the flow's steps, in the shape documented + by `middleware_automation.keycloak.keycloak_authentication_v2` — each item is either an + execution (`providerId`, `requirement`, optionally `authenticationConfig`) or a sub-flow + (`subFlow`, `requirement`, optionally `subFlowType`, and a nested `authenticationExecutions`). + Up to 10 levels of sub-flow nesting are supported. +- `keycloak_auth_flow__admin_url`: Defaults to `http://127.0.0.1:8080`. +- `keycloak_auth_flow__admin_username`: Defaults to `admin`. + +## Validation checks (`keycloak_auth_flow_lint`) + +The lint module runs entirely offline (no Keycloak credentials or network access needed) and +checks each flow definition before it's ever sent to the server. + +### Errors (fail the run) + +- **Node shape**: every entry in `authenticationExecutions` must be a mapping with no unknown + keys, exactly one of `providerId` (an execution) or `subFlow` (a sub-flow), and a `requirement` + that is one of `REQUIRED`/`ALTERNATIVE`/`DISABLED`/`CONDITIONAL`. A sub-flow's `subFlowType` + (when given) must be `basic-flow` or `form-flow`, its name must be a non-empty string unique + within the flow, and it must define at least one child execution. These mirror the shape + `middleware_automation.keycloak.keycloak_authentication_v2` itself expects — see its + [module source](https://github.com/ansible-middleware/keycloak/blob/3.0.11/plugins/modules/keycloak_authentication_v2.py). +- **Nesting depth**: at most 10 levels of sub-flow nesting, matching the limit built into + `keycloak_authentication_v2` (see the module source linked above; the depth was raised from 4 + to 10 in [ansible-middleware/keycloak#376](https://github.com/ansible-middleware/keycloak/pull/376)). +- **`authenticationConfig` shape**: when present, must be a mapping with only `alias` (non-empty + string) and `config` (a mapping) — again mirrors what + `keycloak_authentication_v2` accepts. +- **Condition execution outside a `CONDITIONAL` sub-flow**: a `conditional-*` execution is + silently ignored — no error, the condition just never fires — unless its immediate parent + sub-flow's own `requirement` is literally `CONDITIONAL`. See the Keycloak Server Admin Guide, + ["Configuring authentication → Execution requirements"](https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/22.0/html/server_administration_guide/configuring-authentication_server_administration_guide): + "Condition executions can only be contained in Conditional subflow." +- **OTP + WebAuthn siblings under one shared `conditional-user-configured`**: this hits + [keycloak/keycloak#29515](https://github.com/keycloak/keycloak/issues/29515) — users with + neither credential configured get a generic login error instead of the check being skipped + (also discussed in + [keycloak/keycloak#14988](https://github.com/keycloak/keycloak/discussions/14988)). The fix is + to give each credential type its own nested `CONDITIONAL` sub-flow with its own + `conditional-user-configured` check. +- **`conditional-credential` config**: requires an `authenticationConfig` with a non-empty + `credentials` entry, and `included` (if set) must be the string `"true"` or `"false"` — Keycloak + stores authenticator config values as strings. See + [`ConditionalCredentialAuthenticatorFactory` javadoc](https://www.keycloak.org/docs-api/latest/javadocs/org/keycloak/authentication/authenticators/conditional/ConditionalCredentialAuthenticatorFactory.html) + and [conditions.adoc](https://github.com/keycloak/keycloak/blob/main/docs/documentation/server_admin/topics/authentication/conditions.adoc). + +### Warnings (non-fatal) + +- **Unrecognized `providerId`**: not in the module's built-in list of known Keycloak provider + IDs — could be a typo, or a legitimate custom SPI provider. See the constant + `KNOWN_PROVIDER_IDS` in the module source for how that list was compiled and how to check or + extend it (live server query vs. Keycloak's authenticator factory sources). +- **`CONDITIONAL` requirement on a non-`conditional-*` provider**: usually a sign the wrong + execution ended up under a `Conditional` sub-flow. +- **`conditional-credential` `included` semantics reminder**: restates what the configured + `included` value actually means (`true` → condition is true when *any* listed credential was + used; `false` → true when *none* were used). This is unlabeled in the admin console and easy to + get backwards — see the same + [conditions.adoc](https://github.com/keycloak/keycloak/blob/main/docs/documentation/server_admin/topics/authentication/conditions.adoc) + reference as above. +- **`auth-conditional-otp-form` with no config**: unlike the plain `auth-otp-form`, `Conditional + OTP Form`'s skip/force logic is entirely self-contained (by user attribute, role, header, or + default) and does *not* inherit a wrapping `Condition-*` check; with no config it defaults to + always showing the OTP form. See + [`ConditionalOtpFormAuthenticator.java`](https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/authenticators/browser/ConditionalOtpFormAuthenticator.java). +- **Ambiguous passkey execution**: when both `auth-username-password-form` and + `webauthn-authenticator-passwordless` appear anywhere in the flow. With native passkey support, + the username/password form can itself complete a full passkey login, so it can be unclear — + and unverifiable from the admin console — which execution actually authenticated a given + login. No online source for this one; verify via the `execution=` query parameter during a + live test, or `DEBUG` logging on `org.keycloak.authentication`. + +## Example + +```yaml +keycloak_auth_flow__flows: + - realm: ccchh + alias: browser passkey and token + providerId: basic-flow + authenticationExecutions: + - providerId: auth-cookie + requirement: ALTERNATIVE + - subFlow: forms + requirement: ALTERNATIVE + authenticationExecutions: + - providerId: auth-username-password-form + requirement: REQUIRED +``` diff --git a/roles/keycloak_auth_flow/defaults/main.yaml b/roles/keycloak_auth_flow/defaults/main.yaml new file mode 100644 index 00000000..c2fa8746 --- /dev/null +++ b/roles/keycloak_auth_flow/defaults/main.yaml @@ -0,0 +1,3 @@ +keycloak_auth_flow__flows: [ ] +keycloak_auth_flow__admin_url: "http://127.0.0.1:8080" +keycloak_auth_flow__admin_username: admin diff --git a/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py b/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py new file mode 100644 index 00000000..032b98d7 --- /dev/null +++ b/roles/keycloak_auth_flow/library/keycloak_auth_flow_lint.py @@ -0,0 +1,374 @@ +#!/usr/bin/python + +from __future__ import annotations + +DOCUMENTATION = r""" +module: keycloak_auth_flow_lint + +short_description: Locally validate a Keycloak authentication flow definition + +description: + - Statically validates the structure of a nested Keycloak authentication flow definition, in + the shape accepted by C(middleware_automation.keycloak.keycloak_authentication_v2)'s + O(middleware_automation.keycloak.keycloak_authentication_v2#module:authenticationExecutions) + option. + - This performs no network calls and requires no Keycloak credentials, so it can catch YAML + authoring mistakes (typos, missing keys, bad nesting) before ever touching a live server. + - Some checks (for example whether a C(providerId) is spelled correctly) are necessarily best + effort, since Keycloak's set of registered authenticator providers depends on the server and + its installed SPI plugins. Those only produce warnings, not failures. + +options: + realm: + description: The name of the realm the flow belongs to. + required: true + type: str + alias: + description: The name of the authentication flow. + required: true + type: str + providerId: + description: The C(providerId) for the flow. + choices: [basic-flow, client-flow] + type: str + default: basic-flow + authenticationExecutions: + description: The desired execution configuration for the flow, at root level. + required: true + type: list + elements: dict + +author: + - CCCHH +""" + +EXAMPLES = r""" +- name: Validate a flow definition before deploying it + keycloak_auth_flow_lint: + realm: "{{ item.realm }}" + alias: "{{ item.alias }}" + providerId: "{{ item.providerId | default('basic-flow') }}" + authenticationExecutions: "{{ item.authenticationExecutions }}" + loop: "{{ keycloak_auth_flow__flows }}" +""" + +RETURN = r""" +warnings: + description: Non-fatal issues found in the flow definition. + returned: always + type: list + elements: str +""" + +from ansible.module_utils.basic import AnsibleModule + +# Non-exhaustive list of Keycloak built-in authenticator provider IDs relevant to browser flows. +# An unrecognized providerId only produces a warning, not a failure, since custom SPI providers +# exist. This list is NOT an authoritative source, just a fast offline pre-check: +# - Most entries were observed directly on the live CCCHH Keycloak server (26.x): the built-in +# flows (browser, direct grant, registration, reset credentials, clients, docker auth, first +# broker login) and the custom "browser passkey and token" flow, fetched via the admin API. +# - The rest (other idp-*, registration-*, conditional-*, auth-conditional-otp-form, +# auth-password-form, identity-provider-autolink) are from general Keycloak knowledge, not +# verified against this server. +# To check or extend this list later: +# - Live server (authoritative, and what keycloak_authentication_v2 itself checks against at +# deploy time): GET /admin/realms/{realm}/authentication/authenticator-providers +# - Keycloak source: each provider's getId() in its *AuthenticatorFactory.java, under +# https://github.com/keycloak/keycloak/tree/main/services/src/main/java/org/keycloak/authentication/authenticators +KNOWN_PROVIDER_IDS = { + "auth-conditional-otp-form", + "auth-cookie", + "auth-otp-form", + "auth-password-form", + "auth-spnego", + "auth-username-password-form", + "client-jwt", + "client-secret", + "client-secret-jwt", + "client-x509", + "conditional-credential", + "conditional-level-of-authentication", + "conditional-user-attribute", + "conditional-user-configured", + "conditional-user-role", + "direct-grant-validate-otp", + "direct-grant-validate-password", + "direct-grant-validate-username", + "docker-http-basic-authenticator", + "identity-provider-autolink", + "identity-provider-redirector", + "idp-confirm-link", + "idp-create-user-if-unique", + "idp-email-verification", + "idp-review-profile", + "idp-username-password-form", + "organization", + "registration-page-form", + "registration-password-action", + "registration-recaptcha-action", + "registration-terms-and-conditions", + "registration-user-creation", + "reset-credential-email", + "reset-credentials-choose-user", + "reset-password", + "webauthn-authenticator", + "webauthn-authenticator-passwordless", +} + +REQUIREMENTS = {"REQUIRED", "ALTERNATIVE", "DISABLED", "CONDITIONAL"} +SUBFLOW_TYPES = {"basic-flow", "form-flow"} +ALLOWED_NODE_KEYS = { + "requirement", + "providerId", + "subFlow", + "subFlowType", + "authenticationExecutions", + "authenticationConfig", +} +MAX_SUBFLOW_DEPTH = 10 + +# providerIds relevant to the sibling-condition bug tracked as keycloak/keycloak#29515: mixing +# these two under one shared 'conditional-user-configured' check breaks login for users with +# neither credential configured. +OTP_PROVIDER_IDS = {"auth-otp-form", "auth-conditional-otp-form"} +WEBAUTHN_PROVIDER_IDS = {"webauthn-authenticator", "webauthn-authenticator-passwordless"} + + +class FlowValidationError(Exception): + pass + + +def validate_authentication_config(auth_config, path, provider_id, warnings): + if not isinstance(auth_config, dict): + raise FlowValidationError(f"{path}.authenticationConfig: must be a mapping") + extra = set(auth_config) - {"alias", "config"} + if extra: + raise FlowValidationError(f"{path}.authenticationConfig: unexpected key(s): {', '.join(sorted(extra))}") + if not isinstance(auth_config.get("alias"), str) or not auth_config["alias"]: + raise FlowValidationError(f"{path}.authenticationConfig: 'alias' is required and must be a non-empty string") + config = auth_config.get("config") + if not isinstance(config, dict): + raise FlowValidationError(f"{path}.authenticationConfig: 'config' is required and must be a mapping") + + if provider_id == "conditional-credential": + credentials = config.get("credentials") + if not credentials: + raise FlowValidationError( + f"{path}.authenticationConfig.config: 'conditional-credential' requires a non-empty " + "'credentials' entry" + ) + included = config.get("included") + if included is not None and included not in ("true", "false"): + raise FlowValidationError( + f"{path}.authenticationConfig.config: 'included' must be the string 'true' or 'false' " + f"(got {included!r}) — Keycloak stores authenticator config values as strings" + ) + # The included=true/false semantics are unlabeled in the Keycloak admin console and easy to + # get backwards, so restate them every time as a standing reminder. + if included == "false": + meaning = f"TRUE when NONE of [{credentials}] were used (inverted)" + else: + meaning = f"TRUE when ANY of [{credentials}] was used" + warnings.append( + f"{path}: 'conditional-credential' with included={included!r} — condition is {meaning}. " + "This is easy to get backwards since the admin console has no tooltip for it." + ) + elif provider_id == "auth-conditional-otp-form" and not config: + warnings.append( + f"{path}: 'auth-conditional-otp-form' (Conditional OTP Form) has an empty config — " + "unlike the plain 'auth-otp-form', its skip/force logic is entirely self-contained (by " + "user attribute, role, header, or default) and does NOT inherit a wrapping Condition-* " + "check; with no config it defaults to always showing the OTP form." + ) + + +def validate_node(node, path, depth, seen_subflow_names, seen_provider_ids, warnings): + if not isinstance(node, dict): + raise FlowValidationError(f"{path}: must be a mapping") + + extra = set(node) - ALLOWED_NODE_KEYS + if extra: + raise FlowValidationError(f"{path}: unexpected key(s): {', '.join(sorted(extra))}") + + requirement = node.get("requirement") + if requirement not in REQUIREMENTS: + raise FlowValidationError( + f"{path}.requirement: must be one of {sorted(REQUIREMENTS)}, got {requirement!r}" + ) + + has_provider = node.get("providerId") is not None + has_subflow = node.get("subFlow") is not None + if has_provider and has_subflow: + raise FlowValidationError(f"{path}: has both 'providerId' and 'subFlow' — an execution is exactly one of the two") + if not has_provider and not has_subflow: + raise FlowValidationError(f"{path}: must have exactly one of 'providerId' or 'subFlow'") + + if has_subflow: + if depth >= MAX_SUBFLOW_DEPTH: + raise FlowValidationError( + f"{path}: nesting exceeds the maximum of {MAX_SUBFLOW_DEPTH} sub-flow levels " + "supported by middleware_automation.keycloak.keycloak_authentication_v2" + ) + + name = node["subFlow"] + if not isinstance(name, str) or not name: + raise FlowValidationError(f"{path}.subFlow: must be a non-empty string") + if name in seen_subflow_names: + raise FlowValidationError(f"{path}.subFlow: duplicate sub-flow name {name!r} within this flow") + seen_subflow_names.add(name) + + subflow_type = node.get("subFlowType", "basic-flow") + if subflow_type not in SUBFLOW_TYPES: + raise FlowValidationError(f"{path}.subFlowType: must be one of {sorted(SUBFLOW_TYPES)}, got {subflow_type!r}") + + if "authenticationConfig" in node: + raise FlowValidationError(f"{path}: 'authenticationConfig' is not valid on a sub-flow node") + + children = node.get("authenticationExecutions") + if not children: + raise FlowValidationError(f"{path}.authenticationExecutions: sub-flow {name!r} must define at least one execution") + if not isinstance(children, list): + raise FlowValidationError(f"{path}.authenticationExecutions: must be a list") + + # Direct-child providerIds, used by the two checks below. Only *direct* children count: + # Keycloak's "Condition executions can only be contained in Conditional subflow" rule, and + # the sibling-condition bug, both concern immediate siblings, not deeper descendants. + direct_provider_ids = { + child.get("providerId") + for child in children + if isinstance(child, dict) and child.get("providerId") + } + + # Gotcha: a 'conditional-*' execution is silently ignored (no error, condition just never + # fires) unless the wrapping subflow's own requirement is literally CONDITIONAL. + conditional_children = {pid for pid in direct_provider_ids if pid.startswith("conditional-")} + if conditional_children and requirement != "CONDITIONAL": + raise FlowValidationError( + f"{path}: sub-flow {name!r} contains condition execution(s) " + f"({', '.join(sorted(conditional_children))}) but its own requirement is " + f"{requirement!r}, not CONDITIONAL — Keycloak silently ignores condition executions " + "whose wrapping subflow isn't CONDITIONAL (no error is shown; the condition just " + "never fires)" + ) + + # Gotcha: keycloak/keycloak#29515 — an OTP provider and a WebAuthn provider as siblings + # under one shared 'conditional-user-configured' check breaks login for users with neither + # credential configured. Give each credential type its own nested Conditional subflow with + # its own 'conditional-user-configured' check instead. + if ( + "conditional-user-configured" in direct_provider_ids + and direct_provider_ids & OTP_PROVIDER_IDS + and direct_provider_ids & WEBAUTHN_PROVIDER_IDS + ): + raise FlowValidationError( + f"{path}: sub-flow {name!r} mixes an OTP provider and a WebAuthn provider as " + "siblings under one shared 'conditional-user-configured' check — this hits " + "keycloak/keycloak#29515: users with neither credential configured get a generic " + "login error instead of the check being skipped. Give each credential type its own " + "nested Conditional subflow with its own 'conditional-user-configured' check." + ) + + for index, child in enumerate(children): + validate_node( + child, + f"{path}.authenticationExecutions[{index}]", + depth + 1, + seen_subflow_names, + seen_provider_ids, + warnings, + ) + else: + if "subFlowType" in node: + raise FlowValidationError(f"{path}: 'subFlowType' is not valid on an execution node") + if "authenticationExecutions" in node: + raise FlowValidationError(f"{path}: 'authenticationExecutions' is not valid on an execution node") + + provider_id = node["providerId"] + if not isinstance(provider_id, str) or not provider_id: + raise FlowValidationError(f"{path}.providerId: must be a non-empty string") + seen_provider_ids.add(provider_id) + if provider_id not in KNOWN_PROVIDER_IDS: + warnings.append( + f"{path}: providerId '{provider_id}' is not in the list of known Keycloak provider " + "IDs — double-check for a typo, or ignore if this is a custom SPI provider" + ) + if requirement == "CONDITIONAL" and not provider_id.startswith("conditional-"): + warnings.append( + f"{path}: requirement is CONDITIONAL but providerId '{provider_id}' does not look " + "like a condition provider (expected something like 'conditional-*')" + ) + + auth_config = node.get("authenticationConfig") + if auth_config is not None: + validate_authentication_config(auth_config, path, provider_id, warnings) + elif provider_id == "conditional-credential": + raise FlowValidationError( + f"{path}: 'conditional-credential' requires an authenticationConfig (with a " + "'credentials' entry) — without it the condition doesn't check anything" + ) + elif provider_id == "auth-conditional-otp-form": + warnings.append( + f"{path}: 'auth-conditional-otp-form' (Conditional OTP Form) has no " + "authenticationConfig — unlike the plain 'auth-otp-form', its skip/force logic is " + "entirely self-contained (by user attribute, role, header, or default) and does NOT " + "inherit a wrapping Condition-* check; with no config it defaults to always showing " + "the OTP form." + ) + + +def main() -> None: + module = AnsibleModule( + argument_spec=dict( + realm=dict(type="str", required=True), + alias=dict(type="str", required=True), + providerId=dict(type="str", default="basic-flow", choices=["basic-flow", "client-flow"]), + authenticationExecutions=dict(type="list", elements="dict", required=True), + ), + supports_check_mode=True, + ) + + warnings: list[str] = [] + seen_subflow_names: set[str] = set() + seen_provider_ids: set[str] = set() + + try: + for index, execution in enumerate(module.params["authenticationExecutions"]): + validate_node( + execution, + f"authenticationExecutions[{index}]", + depth=0, + seen_subflow_names=seen_subflow_names, + seen_provider_ids=seen_provider_ids, + warnings=warnings, + ) + except FlowValidationError as e: + module.fail_json( + msg=f"{module.params['realm']}/{module.params['alias']}: {e}", + warnings=warnings, + ) + + # Gotcha: with native passkey support, 'auth-username-password-form' can itself complete a + # full passkey login, so a separately-added 'webauthn-authenticator-passwordless' execution + # elsewhere in the flow may end up dead/unreached with no indication in the admin console. + if "auth-username-password-form" in seen_provider_ids and "webauthn-authenticator-passwordless" in seen_provider_ids: + warnings.append( + "flow has both 'auth-username-password-form' and 'webauthn-authenticator-passwordless' " + "— with native passkey support, the username/password form can itself complete a " + "passkey login, so it may be ambiguous (and unverifiable from the admin console) which " + "execution actually authenticates a given passkey login. Verify via the 'execution=' " + "query parameter during a live test, or DEBUG logging on 'org.keycloak.authentication'." + ) + + for warning in warnings: + module.warn(f"{module.params['realm']}/{module.params['alias']}: {warning}") + + module.exit_json( + changed=False, + msg=f"{module.params['realm']}/{module.params['alias']}: flow definition is structurally valid", + warnings=warnings, + ) + + +if __name__ == "__main__": + main() diff --git a/roles/keycloak_auth_flow/meta/argument_specs.yaml b/roles/keycloak_auth_flow/meta/argument_specs.yaml new file mode 100644 index 00000000..badee9b7 --- /dev/null +++ b/roles/keycloak_auth_flow/meta/argument_specs.yaml @@ -0,0 +1,24 @@ +--- +argument_specs: + main: + options: + keycloak_auth_flow__flows: + description: >- + List of authentication flow definitions to validate and deploy. Each item needs + C(realm), C(alias) and C(authenticationExecutions) (nested tree, see + middleware_automation.keycloak.keycloak_authentication_v2), and may set C(providerId) + and C(description). + type: list + elements: dict + keycloak_auth_flow__admin_url: + description: Base URL of the Keycloak Admin REST API. + type: str + default: "http://127.0.0.1:8080" + keycloak_auth_flow__admin_username: + description: Username of a Keycloak admin (in the C(master) realm) used to manage flows. + type: str + default: admin + keycloak_auth_flow__admin_password: + description: Password for O(keycloak_auth_flow__admin_username). + type: str + required: true diff --git a/roles/keycloak_auth_flow/tasks/main.yaml b/roles/keycloak_auth_flow/tasks/main.yaml new file mode 100644 index 00000000..1dfc169b --- /dev/null +++ b/roles/keycloak_auth_flow/tasks/main.yaml @@ -0,0 +1,25 @@ +- name: Validate Keycloak authentication flow definitions + keycloak_auth_flow_lint: + realm: "{{ item.realm }}" + alias: "{{ item.alias }}" + providerId: "{{ item.providerId | default('basic-flow') }}" + authenticationExecutions: "{{ item.authenticationExecutions }}" + loop: "{{ keycloak_auth_flow__flows }}" + loop_control: + label: "{{ item.realm }}/{{ item.alias }}" + +- name: Deploy Keycloak authentication flows + middleware_automation.keycloak.keycloak_authentication_v2: + auth_keycloak_url: "{{ keycloak_auth_flow__admin_url }}" + auth_realm: master + auth_username: "{{ keycloak_auth_flow__admin_username }}" + auth_password: "{{ keycloak_auth_flow__admin_password }}" + realm: "{{ item.realm }}" + alias: "{{ item.alias }}" + description: "{{ item.description | default(omit) }}" + providerId: "{{ item.providerId | default('basic-flow') }}" + authenticationExecutions: "{{ item.authenticationExecutions }}" + state: present + loop: "{{ keycloak_auth_flow__flows }}" + loop_control: + label: "{{ item.realm }}/{{ item.alias }}" -- 2.54.0