Compare commits

...

77 Commits

Author SHA1 Message Date
Ranabir Chakraborty
6600082294 Providing correct rhbk version 2026-06-03 20:08:27 +05:30
ansible-middleware-core
5cb555d6c2 Bump version to 3.0.8 2026-06-01 14:50:07 +00:00
ansible-middleware-core
3ab0f2b259 Update changelog for release 3.0.7
Signed-off-by: ansible-middleware-core <ansible-middleware-core@redhat.com>
2026-06-01 14:49:51 +00:00
Harsha Cherukuri
9394e2598f Merge pull request #341 from hcherukuri/main
Migrate Keycloak modules from the community.general collection to Keycloak collection.
2026-06-01 10:23:18 -04:00
Harsha Cherukuri
123906d739 Added PRs #11841 and #11749, and updated version references from community.general to Keycloak. 2026-05-30 09:29:04 -04:00
Harsha Cherukuri
bdc090de64 Move community.general keycloak modules into keycloak collection 2026-05-28 13:14:54 -04:00
Ranabir Chakraborty
d4a92c9f4f Merge pull request #343 from RanabirChakraborty/common_module_fix
Fixing common module usage
2026-05-28 20:55:21 +05:30
Ranabir Chakraborty
926ea0192d Fixing common module usgae 2026-05-28 20:13:34 +05:30
Ranabir Chakraborty
88c825c997 Merge pull request #342 from RanabirChakraborty/JN_to_UD
Jboss Network replace with Unified Downloads
2026-05-28 18:46:17 +05:30
Ranabir Chakraborty
0e3a9e3741 Jboss Network replace with Unified Downloads 2026-05-28 18:45:36 +05:30
Harsha Cherukuri
7495385ccb Merge pull request #338 from world-direct/fix/336_downloads
fix #336: https://github.com/ansible-middleware/common/pull/38
2026-05-27 14:44:14 -04:00
Helmut Wolf
494a522ab2 fix #336: https://github.com/ansible-middleware/common/pull/38 2026-05-27 20:03:36 +02:00
Ranabir Chakraborty
64e7fa3129 Merge pull request #339 from hcherukuri/main
Fix molecule tests
2026-05-27 21:06:37 +05:30
Harsha Cherukuri
15a0c6ee46 Fix molecule tests 2026-05-27 11:10:41 -04:00
ansible-middleware-core
e4d1a79d1f Bump version to 3.0.7 2026-05-26 17:30:50 +00:00
ansible-middleware-core
f4588dbbdf Update changelog for release 3.0.6
Signed-off-by: ansible-middleware-core <ansible-middleware-core@redhat.com>
2026-05-26 17:30:30 +00:00
Ranabir Chakraborty
a9a771c6bc Merge pull request #337 from RanabirChakraborty/AMW-540
AMW-540 Fix the upstream collection requirements with common v1.2.4
2026-05-26 22:37:54 +05:30
Ranabir Chakraborty
f00c714798 AMW-540 Fix the upstream collection requirements with common v1.2.4 2026-05-26 21:58:05 +05:30
Harsha Cherukuri
50750ef125 Update requirements.yml 2026-05-26 12:28:04 -04:00
ansible-middleware-core
b631b07cae Bump version to 3.0.6 2026-05-20 18:44:49 +00:00
ansible-middleware-core
195e104f5e Update changelog for release 3.0.5
Signed-off-by: ansible-middleware-core <ansible-middleware-core@redhat.com>
2026-05-20 18:44:31 +00:00
Ranabir Chakraborty
047ddcaa92 Merge pull request #335 from RanabirChakraborty/AMW-528
AMW-528 Deployment fails in keycloak_quarkus due to missing escalation variables
2026-05-21 00:09:58 +05:30
Ranabir Chakraborty
0b2f2786dd AMW-528 Deployment fails in keycloak_quarkus due to missing escalation variables 2026-05-20 23:51:34 +05:30
ansible-middleware-core
4cc360052e Bump version to 3.0.5 2026-05-20 13:38:22 +00:00
ansible-middleware-core
c6e3337778 Update changelog for release 3.0.4
Signed-off-by: ansible-middleware-core <ansible-middleware-core@redhat.com>
2026-05-20 13:38:01 +00:00
Harsha Cherukuri
d1b295f551 Merge pull request #332 from cihlamar/AMW-522
Fix certification requirements for Keycloak
2026-05-20 09:36:11 -04:00
Martin Cihlar
5e13f4ea50 Fix certification requirements for Keycloak
- Add .ansible-lint, .DS_Store to build_ignore in galaxy.yml
- Add Release and Upgrade Notes section to README

[AMW-522](https://redhat.atlassian.net/browse/AMW-522)
2026-05-20 11:00:58 +02:00
Harsha Cherukuri
06cf664b08 Merge pull request #331 from RanabirChakraborty/SET-1341
SET-1341 Without ansible-core tag tests are failing in keycloak
2026-04-30 03:33:48 -04:00
Ranabir Chakraborty
e5690d7513 SET-1341 Without ansible-core tag tests are failing in keycloak 2026-04-28 22:15:14 +05:30
Ranabir Chakraborty
fb76736441 Merge pull request #330 from nwintering/main
Check that `data/tmp` directory has correct ownership
2026-04-28 18:24:19 +05:30
nwintering
6d00dcff48 check that tmp directory has correct permissions 2026-04-27 15:35:03 +02:00
Harsha Cherukuri
eaf9964aab Fix docs pipeline 2026-04-24 10:50:10 -04:00
Harsha Cherukuri
180f075a9f Merge pull request #324 from RanabirChakraborty/AMW-518
AMW-518 Validating arguments against arg spec 'main' fails unexpectedly.
2026-04-24 10:34:55 -04:00
Harsha Cherukuri
1013a05f8c Merge pull request #328 from hcherukuri/main
Fix sanity and molecule tests
2026-04-24 10:26:26 -04:00
Harsha Cherukuri
22f1ce516d Fix sanity and molecule tests 2026-04-24 10:07:24 -04:00
Harsha Cherukuri
7be872cc48 Merge pull request #326 from paulomenon/add/example-playbooks-client-scope-auth-flow
Add/example playbooks client scope auth flow
2026-04-24 08:29:16 -04:00
Harsha Cherukuri
55248de9ae Merge pull request #325 from paulomenon/fix/keycloak-context-default
Fix keycloak_context default from /auth to empty string for Quarkus-based Keycloak
2026-04-24 08:29:00 -04:00
Harsha Cherukuri
c6d4dfb8bb Merge pull request #327 from hcherukuri/main
Fix CI
2026-04-24 08:16:25 -04:00
Harsha Cherukuri
c8f4065eb5 Fix CI 2026-04-24 08:00:45 -04:00
pamenon
06e096ac50 Add module documentation to collection and role READMEs
Document all six modules (including the two new ones) in the main
collection README under a new 'Included modules' section. Add the
three new example playbooks to the Config Playbooks section. Update
the keycloak_realm role README with a 'Related Modules' table and
inline examples for keycloak_client_scope and
keycloak_authentication_flow usage.

Made-with: Cursor
2026-04-23 12:54:22 +01:00
pamenon
c6189bfc51 Add keycloak_client_scope and keycloak_authentication_flow modules with example playbooks
The collection was missing modules for managing client scopes and
authentication flows, forcing users to write raw uri calls against
the Keycloak Admin REST API. This adds two new modules that leverage
the existing KeycloakAPI helper methods:

- keycloak_client_scope: create/update/delete client scopes with
  protocol mappers (supports check_mode and diff)
- keycloak_authentication_flow: create/delete authentication flows
  with execution steps, or copy existing flows (supports check_mode
  and diff)

Also adds three example playbooks using the new modules:
- keycloak_client_scope.yml
- keycloak_authentication_flow.yml
- keycloak_realm_client.yml

Made-with: Cursor
2026-04-23 12:53:03 +01:00
pamenon
03fffaaf5f Fix keycloak_context default from /auth to empty string
The /auth context path was used by legacy WildFly-based Keycloak but
is no longer needed for Quarkus-based Keycloak (17+) or RHBK. The
current default of /auth forces users to explicitly pass an empty
keycloak_context to avoid broken API URLs.

This changes the default to an empty string, updates argument_specs
and README documentation, and removes the now-unnecessary
keycloak_context: '' overrides from all molecule converge files.

Users on legacy WildFly-based Keycloak can still set
keycloak_context: /auth explicitly.

Made-with: Cursor
2026-04-23 12:25:03 +01:00
Ranabir Chakraborty
a337a1d70c AMW-518 Validating arguments against arg spec 'main' fails unexpectedly. 2026-04-17 19:24:46 +05:30
Ranabir Chakraborty
28168a9a4f Merge pull request #307 from sgoericke/fix-client-id
manage_client_roles.yml: use "client.id" instead of "client.name" to fix client role creation.
2026-04-09 23:37:31 +05:30
Helmut Wolf
64469b6fac Merge pull request #320 from world-direct/fix/ispn_config
fix: include ispn config file conditionally
2026-01-15 08:05:26 +01:00
Ranabir Chakraborty
75e308b710 Merge pull request #321 from RanabirChakraborty/AMW-467
AMW-467 Download keycloak binary from password protected HTTP location
2026-01-14 21:59:45 +05:30
Ranabir Chakraborty
9cdf24ce28 AMW-467 Download keycloak binary from password protected HTTP location 2026-01-13 23:48:01 +05:30
Helmut Wolf
a00a602c3c fix: include ispn config file conditionally 2026-01-13 10:17:04 +01:00
Ranabir Chakraborty
a5a75c6d46 Merge pull request #317 from SLedunois/main
v26.4.x compability
2026-01-12 23:34:28 +05:30
Helmut Wolf
7212e572cd chore(defaults): raise default keycloak/rhbk versions to 26.4.7 2026-01-12 10:28:47 +01:00
Helmut Wolf
bc669ce0cd chore(deps): Update default SQL driver versions
As per https://access.redhat.com/articles/7027683
2026-01-12 10:28:47 +01:00
Simon LEDUNOIS
3c097ebf09 chore: add molecule test for quarkus ha 26.4- 2026-01-12 10:28:47 +01:00
Simon Ledunois
9562bf727e chore: manage infinispan configuration file 2026-01-12 10:28:47 +01:00
Simon Ledunois
6c3e327294 chore: upgrade to 26.4.7 2026-01-12 10:28:47 +01:00
Ranabir Chakraborty
be0c8a4ae3 Merge pull request #319 from RanabirChakraborty/fixing-lint
Removing parseable from lint file as Additional properties are not allowed
2026-01-09 22:16:00 +05:30
Ranabir Chakraborty
6bf10cc3e9 Removing parseable from lint file as Additional properties are not allowed 2026-01-09 22:14:03 +05:30
ansible-middleware-core
d0161dbeef Bump version to 3.0.4 2025-12-16 15:53:14 +00:00
ansible-middleware-core
bf5c805fcd Update changelog for release 3.0.3
Signed-off-by: ansible-middleware-core <ansible-middleware-core@redhat.com>
2025-12-16 15:52:59 +00:00
Ranabir Chakraborty
2b1c07d87e Merge pull request #306 from fxwgr/patch0
Declared proxy_mode as deprecated, updated quarkus and realm readme
2025-12-16 20:08:58 +05:30
Andreas Wagner
f1305e5aac Updated quarkus and realm readme, declared proxy_mode as deprecated
Updated argument_specs and declared keycloak_quarkus_proxy_mode as deprecated
2025-11-14 11:55:24 +01:00
Ranabir Chakraborty
412e17e9ea Merge pull request #312 from RanabirChakraborty/ci_label_fix
keycloak collection CI label is showing no status
2025-10-04 20:46:52 +05:30
Ranabir Chakraborty
fa87c004e3 keycloak collection CI label is showing no status 2025-10-04 20:19:09 +05:30
Ranabir Chakraborty
6c9bddbd61 Merge pull request #308 from tinsjourney/fix_config_key_store_password
Fix config_key_store_file description to match variable name
2025-09-25 21:47:46 +05:30
Ranabir Chakraborty
4602d254cf Merge pull request #310 from world-direct/fix/309
ansible-core 2.19 compatibility
2025-09-18 18:58:54 +05:30
Helmut Wolf
8b2ef22023 fix ansible-core v2.19.0: initialize keycloak_quarkus_hostname_admin to an empty string 2025-07-22 12:11:09 +02:00
Helmut Wolf
66228c3a13 ansible 2.19.0: fix error
'item' is undefined error, https://github.com/ansible-middleware/keycloak/issues/309#issuecomment-3101960407
2025-07-22 12:09:14 +02:00
Stephane Vigan
556d155533 Fix config_key_store_file description to match variable name 2025-07-21 16:15:59 +02:00
Sven Goericke
07063353b8 fixed wrong variable lookup 2025-07-16 13:50:07 +02:00
Guido Grazioli
c1bf9727f9 Merge pull request #293 from world-direct/fix/292
Update to keycloak 26.3.0
2025-07-09 11:38:56 +02:00
Helmut Wolf
f79fd227eb chore: bump KC/RHBK to v26.3.0/v26.2.5 2025-07-07 11:09:35 +02:00
Helmut Wolf
19564987ca fix(quarkus): update infinispan-client configuration to include port in server-list and hosts 2025-07-07 11:05:44 +02:00
Helmut Wolf
1ff25325a7 fix(ispn): use legacy JGroups stack configuration for < 26.2 only 2025-07-07 11:05:44 +02:00
Guido Grazioli
0099f1cf07 Merge pull request #303 from fxwgr/main
Allow to install provider jars from remote paths
2025-07-04 12:47:10 +02:00
Guido Grazioli
725ec8e37b Merge pull request #304 from SLedunois/client_secret
keycloak_realm: allow secret in keycloak_clients
2025-07-04 12:46:40 +02:00
Andreas Wagner
bbe568baa5 Added support for copy remote_src function for providers 2025-07-02 16:39:49 +02:00
LEDUNOIS Simon
dcd448443f feat: allow secret in keycloak_clients 2025-07-02 14:36:25 +00:00
ansible-middleware-core
3780a4e3c0 Bump version to 3.0.3 2025-07-01 16:56:26 +00:00
133 changed files with 19487 additions and 4038 deletions

View File

@@ -40,4 +40,3 @@ skip_list:
- var-naming[no-role-prefix] - var-naming[no-role-prefix]
use_default_rules: true use_default_rules: true
parseable: true

View File

@@ -15,14 +15,11 @@ on:
jobs: jobs:
ci: ci:
uses: ansible-middleware/github-actions/.github/workflows/cish.yml@main uses: ansible-middleware/github-actions/.github/workflows/ci.yml@rootperm
secrets: inherit secrets: inherit
with: with:
fqcn: 'middleware_automation/keycloak' fqcn: 'middleware_automation/keycloak'
root_permission_varname: 'keycloak_install_requires_become'
debug_verbosity: "${{ github.event.inputs.debug_verbosity }}" debug_verbosity: "${{ github.event.inputs.debug_verbosity }}"
molecule_tests: >- molecule_tests: >-
[ "debian", "quarkus", "quarkus_ha", "quarkus_ha_remote" ] [ "debian", "quarkus", "quarkus_ha", "quarkus_ha_remote", "quarkus_ha_26.4_below", "default", "quarkus_devmode", "quarkus_upgrade", "keycloak_modules" ]
podman_tests_current: >-
[ "default", "quarkus_devmode", "quarkus_upgrade" ]
podman_tests_next: >-
[ "default", "quarkus_devmode", "quarkus_upgrade" ]

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ changelogs/.plugin-cache.yaml
*.pem *.pem
*.key *.key
*.p12 *.p12
.ansible/

View File

@@ -6,6 +6,82 @@ middleware\_automation.keycloak Release Notes
This changelog describes changes after version 0.2.6. This changelog describes changes after version 0.2.6.
v3.0.7
======
Major Changes
-------------
- Migrate Keycloak modules from the community.general collection to Keycloak collection. `#341 <https://github.com/ansible-middleware/keycloak/pull/341>`_
Minor Changes
-------------
- Fixing common module usage `#343 <https://github.com/ansible-middleware/keycloak/pull/343>`_
- fix #336: https://github.com/ansible-middleware/common/pull/38 `#338 <https://github.com/ansible-middleware/keycloak/pull/338>`_
Bugfixes
--------
- Fix molecule tests `#339 <https://github.com/ansible-middleware/keycloak/pull/339>`_
v3.0.6
======
Major Changes
-------------
- AMW-540 Fix the upstream collection requirements with common v1.2.4 `#337 <https://github.com/ansible-middleware/keycloak/pull/337>`_
v3.0.5
======
Minor Changes
-------------
- AMW-528 Deployment fails in keycloak_quarkus due to missing escalation variables `#335 <https://github.com/ansible-middleware/keycloak/pull/335>`_
v3.0.4
======
Major Changes
-------------
- AMW-467 Download keycloak binary from password protected HTTP location `#321 <https://github.com/ansible-middleware/keycloak/pull/321>`_
- v26.4.x compability `#317 <https://github.com/ansible-middleware/keycloak/pull/317>`_
Minor Changes
-------------
- AMW-518 Validating arguments against arg spec 'main' fails unexpectedly. `#324 <https://github.com/ansible-middleware/keycloak/pull/324>`_
Bugfixes
--------
- Removing parseable from lint file as Additional properties are not allowed `#319 <https://github.com/ansible-middleware/keycloak/pull/319>`_
v3.0.3
======
Major Changes
-------------
- Update to keycloak 26.3.0 `#293 <https://github.com/ansible-middleware/keycloak/pull/293>`_
- ansible-core 2.19 compatibility `#310 <https://github.com/ansible-middleware/keycloak/pull/310>`_
Minor Changes
-------------
- Allow to install provider jars from remote paths `#303 <https://github.com/ansible-middleware/keycloak/pull/303>`_
- Declared proxy_mode as deprecated, updated quarkus and realm readme `#306 <https://github.com/ansible-middleware/keycloak/pull/306>`_
- Fix config_key_store_file description to match variable name `#308 <https://github.com/ansible-middleware/keycloak/pull/308>`_
Bugfixes
--------
- keycloak collection CI label is showing no status `#312 <https://github.com/ansible-middleware/keycloak/pull/312>`_
- keycloak_realm: allow secret in keycloak_clients `#304 <https://github.com/ansible-middleware/keycloak/pull/304>`_
v3.0.2 v3.0.2
====== ======

116
README.md
View File

@@ -1,7 +1,7 @@
# Ansible Collection - middleware_automation.keycloak # Ansible Collection - middleware_automation.keycloak
<!--start build_status --> <!--start build_status -->
[![Build Status](https://github.com/ansible-middleware/keycloak/workflows/CI/badge.svg?branch=main)](https://github.com/ansible-middleware/keycloak/actions/workflows/ci.yml) [![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.** > **_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.**
@@ -55,18 +55,55 @@ A requirement file is provided to install:
<!--end roles_paths --> <!--end roles_paths -->
### 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_clientscope_type`: manage default and optional client scope assignments.
* `keycloak_clientsecret_info`: retrieve client secret information.
* `keycloak_clientsecret_regenerate`: regenerate a client secret.
* `keycloak_clienttemplate`: manage legacy client templates.
* `keycloak_component`: manage realm components.
* `keycloak_component_info`: retrieve realm component information.
* `keycloak_group`: manage realm groups and subgroups.
* `keycloak_identity_provider`: manage identity provider instances and configuration.
* `keycloak_realm`: manage realms (create/update/delete).
* `keycloak_realm_info`: retrieve realm information.
* `keycloak_realm_key`: manage realm key providers.
* `keycloak_realm_keys_metadata_info`: retrieve realm keys metadata.
* `keycloak_realm_localization`: manage realm localization texts.
* `keycloak_realm_rolemapping`: manage realm role mappings for users and groups.
* `keycloak_role`: manage realm and client roles.
* `keycloak_user`: manage users (create/update/delete).
* `keycloak_user_execute_actions_email`: trigger execute-actions emails for users.
* `keycloak_user_federation`: manage user federation providers (for example LDAP/AD).
* `keycloak_user_rolemapping`: manage user role mappings.
* `keycloak_userprofile`: manage user profile configuration.
## Usage ## Usage
The collection provides roles to install Keycloak and modules to manage realms, clients, users, and related settings via the [Keycloak Admin REST API](https://www.keycloak.org/docs-api/latest/rest-api/index.html).
### Install Playbook 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.
<!--start rhbk_playbook -->
* [`playbooks/keycloak_quarkus.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_quarkus.yml) installs keycloak >= 17 based on the defined variables (using most defaults).
* [`playbooks/keycloak.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak.yml) installs keycloak legacy based on the defined variables (using most defaults).
Both playbooks include the `keycloak` role, with different settings, as described in the following sections. ### Install Keycloak
For full service configuration details, refer to the [keycloak role README](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak/README.md). * [`playbooks/keycloak_quarkus.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_quarkus.yml) installs Keycloak >= 17 using the `keycloak_quarkus` role.
<!--end rhbk_playbook --> * [`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) #### Install from controller node (offline)
@@ -87,15 +124,15 @@ keycloak_offline_install: true
It is possible to perform downloads from alternate sources, using the `keycloak_download_url` variable; make sure the final downloaded filename matches with the source filename (ie. keycloak-legacy-x.y.zip or rh-sso-x.y.z-server-dist.zip). It is possible to perform downloads from alternate sources, using the `keycloak_download_url` variable; make sure the final downloaded filename matches with the source filename (ie. keycloak-legacy-x.y.zip or rh-sso-x.y.z-server-dist.zip).
### Example installation command #### Example installation command
Execute the following command from the source root directory Execute the following command from the source root directory:
``` ```bash
ansible-playbook -i <ansible_hosts> -e @rhn-creds.yml playbooks/keycloak.yml -e keycloak_admin_password=<changeme> ansible-playbook -i <ansible_hosts> playbooks/keycloak_quarkus.yml -e keycloak_quarkus_bootstrap_admin_password=<changeme>
``` ```
- `keycloak_admin_password` Password for the administration console user account. - `keycloak_quarkus_bootstrap_admin_password` password for the administration console user account.
- `ansible_hosts` is the inventory, below is an example inventory for deploying to localhost - `ansible_hosts` is the inventory, below is an example inventory for deploying to localhost
``` ```
@@ -105,16 +142,15 @@ ansible-playbook -i <ansible_hosts> -e @rhn-creds.yml playbooks/keycloak.yml -e
Note: when deploying clustered configurations, all hosts belonging to the cluster must be present in `ansible_play_batch`; ie. they must be targeted by the same ansible-playbook execution. Note: when deploying clustered configurations, all hosts belonging to the cluster must be present in `ansible_play_batch`; ie. they must be targeted by the same ansible-playbook execution.
### Configure with roles
## Configuration
### Config Playbook
<!--start rhbk_realm_playbook --> <!--start rhbk_realm_playbook -->
[`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.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).
<!--end rhbk_realm_playbook --> <!--end rhbk_realm_playbook -->
* [`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 #### Example configuration command
Execute the following command from the source root directory: Execute the following command from the source root directory:
@@ -134,10 +170,52 @@ ansible-playbook -i <ansible_hosts> playbooks/keycloak_realm.yml -e keycloak_adm
For full configuration details, refer to the [keycloak_realm role README](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak_realm/README.md). For full configuration details, refer to the [keycloak_realm role README](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak_realm/README.md).
<!--end rhbk_realm_readme --> <!--end rhbk_realm_readme -->
### 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
<!--start support --> <!--start support -->
For bug reports and feature requests, use [GitHub Issues](https://github.com/ansible-middleware/keycloak/issues).
<!--end support --> <!--end support -->
## 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 ## License
Apache License v2.0 or later Apache License v2.0 or later

View File

@@ -719,3 +719,109 @@ releases:
- 298.yaml - 298.yaml
- 302.yaml - 302.yaml
release_date: '2025-07-01' release_date: '2025-07-01'
3.0.3:
changes:
bugfixes:
- 'keycloak collection CI label is showing no status `#312 <https://github.com/ansible-middleware/keycloak/pull/312>`_
'
- 'keycloak_realm: allow secret in keycloak_clients `#304 <https://github.com/ansible-middleware/keycloak/pull/304>`_
'
major_changes:
- 'Update to keycloak 26.3.0 `#293 <https://github.com/ansible-middleware/keycloak/pull/293>`_
'
- 'ansible-core 2.19 compatibility `#310 <https://github.com/ansible-middleware/keycloak/pull/310>`_
'
minor_changes:
- 'Allow to install provider jars from remote paths `#303 <https://github.com/ansible-middleware/keycloak/pull/303>`_
'
- 'Declared proxy_mode as deprecated, updated quarkus and realm readme `#306
<https://github.com/ansible-middleware/keycloak/pull/306>`_
'
- 'Fix config_key_store_file description to match variable name `#308 <https://github.com/ansible-middleware/keycloak/pull/308>`_
'
fragments:
- 293.yaml
- 303.yaml
- 304.yaml
- 306.yaml
- 308.yaml
- 310.yaml
- 312.yaml
release_date: '2025-12-16'
3.0.4:
changes:
bugfixes:
- 'Removing parseable from lint file as Additional properties are not allowed
`#319 <https://github.com/ansible-middleware/keycloak/pull/319>`_
'
major_changes:
- 'AMW-467 Download keycloak binary from password protected HTTP location `#321
<https://github.com/ansible-middleware/keycloak/pull/321>`_
'
- 'v26.4.x compability `#317 <https://github.com/ansible-middleware/keycloak/pull/317>`_
'
minor_changes:
- 'AMW-518 Validating arguments against arg spec ''main'' fails unexpectedly.
`#324 <https://github.com/ansible-middleware/keycloak/pull/324>`_
'
fragments:
- 317.yaml
- 319.yaml
- 321.yaml
- 324.yaml
release_date: '2026-05-20'
3.0.5:
changes:
minor_changes:
- 'AMW-528 Deployment fails in keycloak_quarkus due to missing escalation variables
`#335 <https://github.com/ansible-middleware/keycloak/pull/335>`_
'
fragments:
- 335.yaml
release_date: '2026-05-20'
3.0.6:
changes:
major_changes:
- 'AMW-540 Fix the upstream collection requirements with common v1.2.4 `#337
<https://github.com/ansible-middleware/keycloak/pull/337>`_
'
fragments:
- 337.yaml
release_date: '2026-05-26'
3.0.7:
changes:
bugfixes:
- 'Fix molecule tests `#339 <https://github.com/ansible-middleware/keycloak/pull/339>`_
'
major_changes:
- 'Migrate Keycloak modules from the community.general collection to Keycloak
collection. `#341 <https://github.com/ansible-middleware/keycloak/pull/341>`_
'
minor_changes:
- 'Fixing common module usage `#343 <https://github.com/ansible-middleware/keycloak/pull/343>`_
'
- 'fix #336: https://github.com/ansible-middleware/common/pull/38 `#338 <https://github.com/ansible-middleware/keycloak/pull/338>`_
'
fragments:
- 338.yaml
- 339.yaml
- 341.yaml
- 343.yaml
release_date: '2026-06-01'

View File

@@ -64,7 +64,7 @@ master_doc = 'index'
# #
# This is also used if you do content translation via gettext catalogs. # This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases. # Usually you set "language" from the command line for these cases.
language = None language = 'en'
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.

View File

@@ -1,3 +1,5 @@
# ansible_basic_sphinx_ext still imports pkg_resources (removed in setuptools 82+).
setuptools>=70.0.0,<81.0.0
antsibull>=0.17.0 antsibull>=0.17.0
antsibull-docs antsibull-docs
antsibull-changelog antsibull-changelog

View File

@@ -1,13 +1,14 @@
--- ---
namespace: middleware_automation namespace: middleware_automation
name: keycloak name: keycloak
version: "3.0.2" version: "3.0.8"
readme: README.md readme: README.md
authors: authors:
- Romain Pelisse <rpelisse@redhat.com> - Romain Pelisse <rpelisse@redhat.com>
- Guido Grazioli <ggraziol@redhat.com> - Guido Grazioli <ggraziol@redhat.com>
- Pavan Kumar Motaparthi <pmotapar@redhat.com> - Pavan Kumar Motaparthi <pmotapar@redhat.com>
- Helmut Wolf <hwo@world-direct.at> - Helmut Wolf <hwo@world-direct.at>
- Harsha Cherukuri <hcheruku@redhat.com>
description: Install and configure a keycloak, or Red Hat Single Sign-on, service. description: Install and configure a keycloak, or Red Hat Single Sign-on, service.
license_file: "LICENSE" license_file: "LICENSE"
tags: tags:
@@ -35,7 +36,9 @@ issues: https://github.com/ansible-middleware/keycloak/issues
build_ignore: build_ignore:
- .gitignore - .gitignore
- .github - .github
- .ansible-lint
- .yamllint - .yamllint
- .DS_Store
- '*.tar.gz' - '*.tar.gz'
- '*.zip' - '*.zip'
- molecule - molecule

View File

@@ -1,2 +1,46 @@
--- ---
requires_ansible: ">=2.16.0" requires_ansible: ">=2.16.0"
action_groups:
keycloak:
- keycloak_authentication
- keycloak_authentication_flow
- keycloak_authentication_required_actions
- keycloak_authentication_v2
- keycloak_authz_authorization_scope
- keycloak_authz_custom_policy
- keycloak_authz_permission
- keycloak_authz_permission_info
- keycloak_client
- keycloak_client_rolemapping
- keycloak_client_rolescope
- keycloak_client_scope
- keycloak_clientscope_type
- keycloak_clientscope_rolemappings
- keycloak_clientsecret_info
- keycloak_clientsecret_regenerate
- keycloak_clienttemplate
- keycloak_component
- keycloak_component_info
- keycloak_group
- keycloak_identity_provider
- keycloak_realm
- keycloak_realm_info
- keycloak_realm_key
- keycloak_realm_keys_metadata_info
- keycloak_realm_localization
- keycloak_realm_rolemapping
- keycloak_role
- keycloak_user
- keycloak_user_federation
- keycloak_user_rolemapping
- keycloak_userprofile
- keycloak_user_execute_actions_email
plugin_routing:
modules:
keycloak_clientscope:
redirect: middleware_automation.keycloak.keycloak_client_scope
deprecation:
removal_version: 5.0.0
warning_text: >-
The module has been renamed to keycloak_client_scope for Keycloak 17+ (Quarkus).
Update playbooks to use middleware_automation.keycloak.keycloak_client_scope.

View File

@@ -1,6 +1,8 @@
--- ---
- name: Converge - name: Converge
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
vars: vars:
keycloak_quarkus_show_deprecation_warnings: false keycloak_quarkus_show_deprecation_warnings: false
keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" keycloak_quarkus_bootstrap_admin_password: "remembertochangeme"
@@ -13,7 +15,6 @@
- role: keycloak_quarkus - role: keycloak_quarkus
- role: keycloak_realm - role: keycloak_realm
keycloak_url: "{{ keycloak_quarkus_hostname }}" keycloak_url: "{{ keycloak_quarkus_hostname }}"
keycloak_context: ''
keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}" keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}"
keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}" keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}"
keycloak_client_users: keycloak_client_users:

View File

@@ -1,6 +1,8 @@
--- ---
- name: Prepare - name: Prepare
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
gather_facts: yes gather_facts: yes
tasks: tasks:
- name: Install sudo - name: Install sudo

View File

@@ -1,6 +1,8 @@
--- ---
- name: Converge - name: Converge
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
vars: vars:
keycloak_quarkus_show_deprecation_warnings: false keycloak_quarkus_show_deprecation_warnings: false
keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" keycloak_quarkus_bootstrap_admin_password: "remembertochangeme"
@@ -18,7 +20,6 @@
- role: keycloak_quarkus - role: keycloak_quarkus
- role: keycloak_realm - role: keycloak_realm
keycloak_url: "{{ keycloak_quarkus_hostname }}" keycloak_url: "{{ keycloak_quarkus_hostname }}"
keycloak_context: ''
keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}" keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}"
keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}" keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}"
keycloak_client_users: keycloak_client_users:

View File

@@ -24,6 +24,9 @@ provisioner:
converge: converge.yml converge: converge.yml
verify: verify.yml verify: verify.yml
inventory: inventory:
group_vars:
all:
keycloak_install_requires_become: true
host_vars: host_vars:
localhost: localhost:
ansible_python_interpreter: "{{ ansible_playbook_python }}" ansible_python_interpreter: "{{ ansible_playbook_python }}"

View File

@@ -1,6 +1,8 @@
--- ---
- name: Prepare - name: Prepare
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
gather_facts: yes gather_facts: yes
vars: vars:
sudo_pkg_name: sudo sudo_pkg_name: sudo
@@ -18,7 +20,7 @@
- name: Download keycloak archive to controller directory - name: Download keycloak archive to controller directory
ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user
url: https://github.com/keycloak/keycloak/releases/download/26.2.4/keycloak-26.2.4.zip url: https://github.com/keycloak/keycloak/releases/download/26.4.7/keycloak-26.4.7.zip
dest: /tmp/keycloak dest: /tmp/keycloak
mode: '0640' mode: '0640'
delegate_to: localhost delegate_to: localhost

View File

@@ -0,0 +1,26 @@
---
keycloak_quarkus_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_systemd_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_install_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_firewalld_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_iptables_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_jdbc_driver_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_config_store_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_restart_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_start_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_rebuild_config_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_fastpackages_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_bootstrapped_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_quarkus_invalidate_theme_cache_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_systemd_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_install_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_firewalld_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_iptables_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_jdbc_driver_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_restart_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_start_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_stop_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_fastpackages_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
keycloak_rhsso_patch_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"
molecule_prepare_require_privilege_escalation: "{{ keycloak_install_requires_become | default(true) }}"

View File

@@ -1,6 +1,8 @@
--- ---
- name: Converge - name: Converge
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
vars: vars:
keycloak_quarkus_show_deprecation_warnings: false keycloak_quarkus_show_deprecation_warnings: false
keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" keycloak_quarkus_bootstrap_admin_password: "remembertochangeme"

View File

@@ -1,6 +1,8 @@
--- ---
- name: Prepare - name: Prepare
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
tasks: tasks:
- name: Install sudo - name: Install sudo
ansible.builtin.dnf: ansible.builtin.dnf:
@@ -27,6 +29,8 @@
pre_tasks: pre_tasks:
- name: Create certificate request - name: Create certificate request
ansible.builtin.command: openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes -subj '/CN=proxy' ansible.builtin.command: openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes -subj '/CN=proxy'
args:
chdir: "{{ playbook_dir }}"
delegate_to: localhost delegate_to: localhost
changed_when: false changed_when: false
- name: Make certificate directory - name: Make certificate directory
@@ -39,11 +43,11 @@
src: "{{ item.name }}" src: "{{ item.name }}"
dest: "{{ item.dest }}" dest: "{{ item.dest }}"
mode: 0444 mode: 0444
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
loop: loop:
- { name: 'cert.pem', dest: '/etc/nginx/tls/certificate.crt' } - { name: 'cert.pem', dest: '/etc/nginx/tls/certificate.crt' }
- { name: 'key.pem', dest: '/etc/nginx/tls/certificate.key' } - { name: 'key.pem', dest: '/etc/nginx/tls/certificate.key' }
- name: Update CA trust - name: Update CA trust
ansible.builtin.command: update-ca-trust ansible.builtin.command: update-ca-trust
changed_when: false changed_when: false
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"

View File

@@ -0,0 +1,2 @@
---
- import_playbook: ../default/converge.yml

View File

@@ -0,0 +1,51 @@
---
driver:
name: podman
platforms:
- name: instance
image: registry.access.redhat.com/ubi9/ubi-init:latest
pre_build_image: true
privileged: true
command: "/usr/sbin/init"
port_bindings:
- "8080/tcp"
- "8443/tcp"
- "8009/tcp"
- "9000/tcp"
provisioner:
name: ansible
config_options:
defaults:
interpreter_python: auto_silent
roles_path: ../../roles
ssh_connection:
pipelining: false
playbooks:
prepare: prepare.yml
converge: converge.yml
verify: verify.yml
inventory:
group_vars:
all:
keycloak_install_requires_become: true
host_vars:
localhost:
ansible_python_interpreter: "{{ ansible_playbook_python }}"
env:
ANSIBLE_FORCE_COLOR: "true"
PROXY: "${PROXY}"
NO_PROXY: "${NO_PROXY}"
verifier:
name: ansible
scenario:
test_sequence:
- cleanup
- destroy
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroy

View File

@@ -0,0 +1,2 @@
---
- import_playbook: ../default/prepare.yml

View File

@@ -0,0 +1,657 @@
---
- name: Verify migrated Keycloak modules
hosts: all
vars:
auth_keycloak_url: http://instance:8080
auth_realm: master
auth_username: remembertochangeme
auth_password: remembertochangeme
target_realm: TestRealm
role: molecule-role
client_role: molecule-client-role
client: molecule-client
scope: molecule-scope
group: molecule-group
user: molecule-user
flow: molecule-flow
flow_v2: molecule-flow-v2
auth_copy: molecule-auth-copy
idp: molecule-idp
ephemeral_realm: molecule-realm
template: molecule-template
realm_key: molecule-aes-key
authz_scope: molecule-authz-scope
authz_permission: molecule-authz-perm
federation: molecule-federation
component: molecule-component
migrated_keycloak_modules:
- keycloak_authentication
- keycloak_authentication_flow
- keycloak_authentication_required_actions
- keycloak_authentication_v2
- keycloak_authz_authorization_scope
- keycloak_authz_custom_policy
- keycloak_authz_permission
- keycloak_authz_permission_info
- keycloak_client
- keycloak_client_rolemapping
- keycloak_client_rolescope
- keycloak_client_scope
- keycloak_clientscope_type
- keycloak_clientscope_rolemappings
- keycloak_clientsecret_info
- keycloak_clientsecret_regenerate
- keycloak_clienttemplate
- keycloak_component
- keycloak_component_info
- keycloak_group
- keycloak_identity_provider
- keycloak_realm
- keycloak_realm_info
- keycloak_realm_key
- keycloak_realm_keys_metadata_info
- keycloak_realm_localization
- keycloak_realm_rolemapping
- keycloak_role
- keycloak_user
- keycloak_user_execute_actions_email
- keycloak_user_federation
- keycloak_user_rolemapping
- keycloak_userprofile
tasks:
- name: Populate service facts
ansible.builtin.service_facts:
- name: Check if keycloak service started
ansible.builtin.assert:
that:
- ansible_facts.services["keycloak.service"]["state"] == "running"
- ansible_facts.services["keycloak.service"]["status"] == "enabled"
fail_msg: Service not running
- name: Verify migrated modules are discoverable by ansible-doc # noqa command-instead-of-module
ansible.builtin.command:
cmd: "ansible-doc -t module middleware_automation.keycloak.{{ item }}"
loop: "{{ migrated_keycloak_modules }}"
delegate_to: localhost
register: docs_check
changed_when: false
- name: Ensure module docs check succeeded
ansible.builtin.assert:
that:
- docs_check is not failed
- name: Provision shared test fixtures
module_defaults:
group/middleware_automation.keycloak.keycloak:
auth_keycloak_url: "{{ auth_keycloak_url }}"
auth_realm: "{{ auth_realm }}"
auth_username: "{{ auth_username }}"
auth_password: "{{ auth_password }}"
block:
- name: Reset fixtures from a previous verify run
middleware_automation.keycloak.keycloak_user:
realm: "{{ target_realm }}"
username: "{{ user }}"
state: absent
failed_when: false
- name: keycloak_role — create realm role for module tests
middleware_automation.keycloak.keycloak_role:
realm: "{{ target_realm }}"
name: "{{ role }}"
state: present
- name: keycloak_client — create confidential client for module tests
middleware_automation.keycloak.keycloak_client:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
name: "{{ client }}"
enabled: true
public_client: false
standard_flow_enabled: true
client_authenticator_type: client-secret
secret: molecule-client-secret
state: present
- name: keycloak_client — enable authorization services on test client
middleware_automation.keycloak.keycloak_client:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
service_accounts_enabled: true
authorization_services_enabled: true
full_scope_allowed: false
state: present
- name: keycloak_role — create client role for rolemapping tests
middleware_automation.keycloak.keycloak_role:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
name: "{{ client_role }}"
state: present
- name: keycloak_client_scope — create client scope
middleware_automation.keycloak.keycloak_client_scope:
realm: "{{ target_realm }}"
name: "{{ scope }}"
state: present
- name: keycloak_group — create group
middleware_automation.keycloak.keycloak_group:
realm: "{{ target_realm }}"
name: "{{ group }}"
state: present
- name: keycloak_user — create user
middleware_automation.keycloak.keycloak_user:
realm: "{{ target_realm }}"
username: "{{ user }}"
first_name: Molecule
last_name: User
email: molecule-user@example.invalid
enabled: true
state: present
- name: keycloak_authentication_flow — create browser-style flow
middleware_automation.keycloak.keycloak_authentication_flow:
realm: "{{ target_realm }}"
alias: "{{ flow }}"
description: Molecule module test authentication flow
executions:
- provider_id: auth-cookie
requirement: REQUIRED
state: present
- name: keycloak_realm_info — query TestRealm (public endpoint, no admin auth)
middleware_automation.keycloak.keycloak_realm_info:
auth_keycloak_url: "{{ auth_keycloak_url }}"
realm: "{{ target_realm }}"
- name: Exercise migrated modules against running Keycloak
module_defaults:
group/middleware_automation.keycloak.keycloak:
auth_keycloak_url: "{{ auth_keycloak_url }}"
auth_realm: "{{ auth_realm }}"
auth_username: "{{ auth_username }}"
auth_password: "{{ auth_password }}"
block:
- name: keycloak_realm_keys_metadata_info — query realm keys
middleware_automation.keycloak.keycloak_realm_keys_metadata_info:
realm: "{{ target_realm }}"
- name: keycloak_component_info — list realm components
middleware_automation.keycloak.keycloak_component_info:
realm: "{{ target_realm }}"
- name: keycloak_realm — create ephemeral realm
middleware_automation.keycloak.keycloak_realm:
id: "{{ ephemeral_realm }}"
realm: "{{ ephemeral_realm }}"
enabled: true
state: present
- name: keycloak_realm_localization — set locale override
middleware_automation.keycloak.keycloak_realm_localization:
parent_id: "{{ target_realm }}"
locale: en
state: present
overrides:
- key: molecule.module.test
value: molecule module test
- name: keycloak_realm_key — create generated AES key component
middleware_automation.keycloak.keycloak_realm_key:
parent_id: "{{ target_realm }}"
name: "{{ realm_key }}"
provider_id: aes-generated
state: present
- name: keycloak_authentication — copy browser flow
middleware_automation.keycloak.keycloak_authentication:
realm: "{{ target_realm }}"
alias: "{{ auth_copy }}"
copyFrom: browser
state: present
- name: keycloak_authentication_v2 — manage flow with safe-swap semantics
middleware_automation.keycloak.keycloak_authentication_v2:
realm: "{{ target_realm }}"
alias: "{{ flow_v2 }}"
description: Molecule module test flow v2
authenticationExecutions:
- requirement: REQUIRED
providerId: auth-cookie
state: present
- name: keycloak_authentication_required_actions — ensure VERIFY_EMAIL is enabled
middleware_automation.keycloak.keycloak_authentication_required_actions:
realm: "{{ target_realm }}"
state: present
required_actions:
- alias: VERIFY_EMAIL
enabled: true
- name: keycloak_userprofile — set unmanaged attribute policy
middleware_automation.keycloak.keycloak_userprofile:
parent_id: "{{ target_realm }}"
state: present
config:
kc_user_profile_config:
- unmanagedAttributePolicy: ENABLED
- name: keycloak_identity_provider — create OIDC broker stub
middleware_automation.keycloak.keycloak_identity_provider:
realm: "{{ target_realm }}"
alias: "{{ idp }}"
provider_id: oidc
enabled: false
config:
authorizationUrl: http://localhost:8080/realms/master/protocol/openid-connect/auth
tokenUrl: http://localhost:8080/realms/master/protocol/openid-connect/token
clientId: molecule-idp-client
state: present
- name: keycloak_clienttemplate — create client template
middleware_automation.keycloak.keycloak_clienttemplate:
realm: "{{ target_realm }}"
name: "{{ template }}"
protocol: openid-connect
state: present
register: clienttemplate_result
failed_when:
- clienttemplate_result is failed
- "'404' not in (clienttemplate_result.msg | default(''))"
- "'Not Found' not in (clienttemplate_result.msg | default(''))"
- name: keycloak_clientscope_type — attach scope as optional on realm
middleware_automation.keycloak.keycloak_clientscope_type:
realm: "{{ target_realm }}"
optional_clientscopes:
- "{{ scope }}"
- name: keycloak_user_rolemapping — assign realm role to user
middleware_automation.keycloak.keycloak_user_rolemapping:
realm: "{{ target_realm }}"
target_username: "{{ user }}"
state: present
roles:
- name: "{{ role }}"
- name: keycloak_realm_rolemapping — assign realm role to group
middleware_automation.keycloak.keycloak_realm_rolemapping:
realm: "{{ target_realm }}"
group_name: "{{ group }}"
state: present
roles:
- name: "{{ role }}"
- name: keycloak_client_rolemapping — assign client role to group
middleware_automation.keycloak.keycloak_client_rolemapping:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
group_name: "{{ group }}"
state: present
roles:
- name: "{{ client_role }}"
- name: keycloak_client_rolescope — restrict realm role on client
middleware_automation.keycloak.keycloak_client_rolescope:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
role_names:
- "{{ role }}"
state: present
- name: keycloak_clientscope_rolemappings — map client roles to clientscope
middleware_automation.keycloak.keycloak_clientscope_rolemappings:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
clientscope_id: "{{ scope }}"
role_names:
- "{{ client_role }}"
register: clientscope_rolemappings_result
- name: Assert clientscope role mappings were created
ansible.builtin.assert:
that:
- clientscope_rolemappings_result is changed
- clientscope_rolemappings_result.end_state | length == 1
- name: keycloak_clientscope_rolemappings — remap client role (idempotency)
middleware_automation.keycloak.keycloak_clientscope_rolemappings:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
clientscope_id: "{{ scope }}"
role_names:
- "{{ client_role }}"
register: clientscope_rolemappings_idempotent_result
- name: Assert clientscope role mappings are idempotent
ansible.builtin.assert:
that:
- clientscope_rolemappings_idempotent_result is not changed
- clientscope_rolemappings_idempotent_result.end_state | length == 1
- name: keycloak_clientscope_rolemappings — map realm role to clientscope
middleware_automation.keycloak.keycloak_clientscope_rolemappings:
realm: "{{ target_realm }}"
clientscope_id: "{{ scope }}"
role_names:
- "{{ role }}"
register: clientscope_realm_rolemappings_result
- name: Assert realm role was mapped to clientscope
ansible.builtin.assert:
that:
- clientscope_realm_rolemappings_result is changed
- clientscope_realm_rolemappings_result.end_state | length == 1
- name: keycloak_user — set email_verified explicitly
middleware_automation.keycloak.keycloak_user:
realm: "{{ target_realm }}"
username: "{{ user }}"
email_verified: true
state: present
register: user_email_verified_result
- name: Assert email_verified was set
ansible.builtin.assert:
that:
- user_email_verified_result is changed
- user_email_verified_result.end_state.emailVerified == true
- name: keycloak_user — leave email_verified unchanged with no_defaults
middleware_automation.keycloak.keycloak_user:
realm: "{{ target_realm }}"
username: "{{ user }}"
email_verified_behavior: no_defaults
state: present
register: user_email_verified_idempotent_result
- name: Assert email_verified is unchanged
ansible.builtin.assert:
that:
- user_email_verified_idempotent_result is not changed
- user_email_verified_idempotent_result.end_state.emailVerified == true
- name: keycloak_user — set required actions
middleware_automation.keycloak.keycloak_user:
realm: "{{ target_realm }}"
username: "{{ user }}"
required_actions:
- UPDATE_PASSWORD
- VERIFY_EMAIL
state: present
register: user_required_actions_result
- name: Assert required actions were set
ansible.builtin.assert:
that:
- user_required_actions_result is changed
- "'UPDATE_PASSWORD' in user_required_actions_result.end_state.requiredActions"
- "'VERIFY_EMAIL' in user_required_actions_result.end_state.requiredActions"
- name: keycloak_user — leave required actions unchanged when omitted
middleware_automation.keycloak.keycloak_user:
realm: "{{ target_realm }}"
username: "{{ user }}"
state: present
register: user_required_actions_idempotent_result
- name: Assert required actions are unchanged
ansible.builtin.assert:
that:
- user_required_actions_idempotent_result is not changed
- "'UPDATE_PASSWORD' in user_required_actions_idempotent_result.end_state.requiredActions"
- "'VERIFY_EMAIL' in user_required_actions_idempotent_result.end_state.requiredActions"
- name: keycloak_clientsecret_info — read client secret
middleware_automation.keycloak.keycloak_clientsecret_info:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
no_log: true
- name: keycloak_clientsecret_regenerate — rotate client secret
middleware_automation.keycloak.keycloak_clientsecret_regenerate:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
no_log: true
- name: keycloak_authz_authorization_scope — create authorization scope
middleware_automation.keycloak.keycloak_authz_authorization_scope:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
name: "{{ authz_scope }}"
display_name: Molecule module test scope
state: present
- name: keycloak_authz_permission — create scope permission
middleware_automation.keycloak.keycloak_authz_permission:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
name: "{{ authz_permission }}"
permission_type: scope
scopes:
- "{{ authz_scope }}"
state: present
- name: keycloak_authz_permission_info — query scope permission
middleware_automation.keycloak.keycloak_authz_permission_info:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
name: "{{ authz_permission }}"
- name: keycloak_authz_custom_policy — requires deployed policy provider on server
middleware_automation.keycloak.keycloak_authz_custom_policy:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
name: molecule-script-policy
policy_type: script-policy.js
state: present
register: authz_custom_policy_result
failed_when:
- authz_custom_policy_result is failed
- "'No policy provider' not in (authz_custom_policy_result.msg | default(''))"
- "'Policy provider' not in (authz_custom_policy_result.msg | default(''))"
- "'405' not in (authz_custom_policy_result.msg | default(''))"
- "'Method Not Allowed' not in (authz_custom_policy_result.msg | default(''))"
- "'not found' not in (authz_custom_policy_result.msg | default('') | lower)"
- name: keycloak_user_execute_actions_email — trigger execute-actions email
middleware_automation.keycloak.keycloak_user_execute_actions_email:
realm: "{{ target_realm }}"
username: "{{ user }}"
actions:
- VERIFY_EMAIL
register: execute_actions_email_result
failed_when:
- execute_actions_email_result is failed
- "'Connection refused' in (execute_actions_email_result.msg | default(''))"
- "'Failed to send' in (execute_actions_email_result.msg | default(''))"
- name: keycloak_user_federation — remove non-existent federation (module API test)
middleware_automation.keycloak.keycloak_user_federation:
realm: "{{ target_realm }}"
name: "{{ federation }}"
state: absent
- name: keycloak_component — remove non-existent component (module API test)
middleware_automation.keycloak.keycloak_component:
parent_id: "{{ target_realm }}"
name: "{{ component }}"
provider_id: ldap
provider_type: org.keycloak.storage.UserStorageProvider
state: absent
- name: Remove shared test fixtures
module_defaults:
group/middleware_automation.keycloak.keycloak:
auth_keycloak_url: "{{ auth_keycloak_url }}"
auth_realm: "{{ auth_realm }}"
auth_username: "{{ auth_username }}"
auth_password: "{{ auth_password }}"
block:
- name: keycloak_authz_custom_policy — remove custom policy if created
middleware_automation.keycloak.keycloak_authz_custom_policy:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
name: molecule-script-policy
policy_type: script-policy.js
state: absent
failed_when: false
- name: keycloak_authz_permission — remove scope permission
middleware_automation.keycloak.keycloak_authz_permission:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
name: "{{ authz_permission }}"
permission_type: scope
state: absent
- name: keycloak_authz_authorization_scope — remove authorization scope
middleware_automation.keycloak.keycloak_authz_authorization_scope:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
name: "{{ authz_scope }}"
state: absent
- name: keycloak_clientscope_rolemappings — remove realm role from clientscope
middleware_automation.keycloak.keycloak_clientscope_rolemappings:
realm: "{{ target_realm }}"
clientscope_id: "{{ scope }}"
role_names:
- "{{ role }}"
state: absent
- name: keycloak_clientscope_rolemappings — remove client role from clientscope
middleware_automation.keycloak.keycloak_clientscope_rolemappings:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
clientscope_id: "{{ scope }}"
role_names:
- "{{ client_role }}"
state: absent
- name: keycloak_client_rolescope — remove role scope mapping
middleware_automation.keycloak.keycloak_client_rolescope:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
role_names:
- "{{ role }}"
state: absent
- name: keycloak_client_rolemapping — remove group client role mapping
middleware_automation.keycloak.keycloak_client_rolemapping:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
group_name: "{{ group }}"
state: absent
roles:
- name: "{{ client_role }}"
- name: keycloak_realm_rolemapping — remove group realm role mapping
middleware_automation.keycloak.keycloak_realm_rolemapping:
realm: "{{ target_realm }}"
group_name: "{{ group }}"
state: absent
roles:
- name: "{{ role }}"
- name: keycloak_user_rolemapping — remove user role mapping
middleware_automation.keycloak.keycloak_user_rolemapping:
realm: "{{ target_realm }}"
target_username: "{{ user }}"
state: absent
roles:
- name: "{{ role }}"
- name: keycloak_identity_provider — remove OIDC provider
middleware_automation.keycloak.keycloak_identity_provider:
realm: "{{ target_realm }}"
alias: "{{ idp }}"
state: absent
- name: keycloak_authentication_v2 — remove flow
middleware_automation.keycloak.keycloak_authentication_v2:
realm: "{{ target_realm }}"
alias: "{{ flow_v2 }}"
state: absent
- name: keycloak_authentication — remove copied flow
middleware_automation.keycloak.keycloak_authentication:
realm: "{{ target_realm }}"
alias: "{{ auth_copy }}"
state: absent
- name: keycloak_realm_key — remove generated key
middleware_automation.keycloak.keycloak_realm_key:
parent_id: "{{ target_realm }}"
name: "{{ realm_key }}"
provider_id: aes-generated
state: absent
- name: keycloak_realm_localization — remove locale override
middleware_automation.keycloak.keycloak_realm_localization:
parent_id: "{{ target_realm }}"
locale: en
state: absent
overrides:
- key: molecule.module.test
- name: keycloak_realm — remove ephemeral realm
middleware_automation.keycloak.keycloak_realm:
id: "{{ ephemeral_realm }}"
realm: "{{ ephemeral_realm }}"
state: absent
- name: keycloak_clienttemplate — remove client template
middleware_automation.keycloak.keycloak_clienttemplate:
realm: "{{ target_realm }}"
name: "{{ template }}"
state: absent
failed_when: false
- name: keycloak_authentication_flow — remove authentication flow
middleware_automation.keycloak.keycloak_authentication_flow:
realm: "{{ target_realm }}"
alias: "{{ flow }}"
state: absent
- name: keycloak_user — remove user
middleware_automation.keycloak.keycloak_user:
realm: "{{ target_realm }}"
username: "{{ user }}"
state: absent
- name: keycloak_group — remove group
middleware_automation.keycloak.keycloak_group:
realm: "{{ target_realm }}"
name: "{{ group }}"
state: absent
- name: keycloak_role — remove client role
middleware_automation.keycloak.keycloak_role:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
name: "{{ client_role }}"
state: absent
- name: keycloak_client — remove confidential client
middleware_automation.keycloak.keycloak_client:
realm: "{{ target_realm }}"
client_id: "{{ client }}"
state: absent
- name: keycloak_client_scope — remove client scope
middleware_automation.keycloak.keycloak_client_scope:
realm: "{{ target_realm }}"
name: "{{ scope }}"
state: absent
- name: keycloak_role — remove realm role
middleware_automation.keycloak.keycloak_role:
realm: "{{ target_realm }}"
name: "{{ role }}"
state: absent

View File

@@ -1,6 +1,8 @@
--- ---
- name: Converge - name: Converge
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
vars: vars:
keycloak_admin_password: "remembertochangeme" keycloak_admin_password: "remembertochangeme"
keycloak_config_override_template: custom.xml.j2 keycloak_config_override_template: custom.xml.j2

View File

@@ -1,6 +1,8 @@
--- ---
- name: Prepare - name: Prepare
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
gather_facts: yes gather_facts: yes
vars: vars:
sudo_pkg_name: sudo sudo_pkg_name: sudo

View File

@@ -25,11 +25,12 @@
fail_msg: "sudo is not installed on target system" fail_msg: "sudo is not installed on target system"
- name: "Install iproute" - name: "Install iproute"
become: true
ansible.builtin.yum: ansible.builtin.yum:
name: name:
- iproute - iproute
state: present state: present
when:
- ansible_user_id == 'root'
- name: "Retrieve assets server from env" - name: "Retrieve assets server from env"
ansible.builtin.set_fact: ansible.builtin.set_fact:

View File

@@ -1,6 +1,8 @@
--- ---
- name: Converge - name: Converge
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
vars: vars:
keycloak_quarkus_show_deprecation_warnings: false keycloak_quarkus_show_deprecation_warnings: false
keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" keycloak_quarkus_bootstrap_admin_password: "remembertochangeme"
@@ -23,7 +25,7 @@
keycloak_quarkus_systemd_wait_for_delay: 2 keycloak_quarkus_systemd_wait_for_delay: 2
keycloak_quarkus_systemd_wait_for_log: true keycloak_quarkus_systemd_wait_for_log: true
keycloak_quarkus_restart_health_check: false # would fail because of self-signed cert keycloak_quarkus_restart_health_check: false # would fail because of self-signed cert
keycloak_quarkus_version: 26.2.4 keycloak_quarkus_version: 26.6.2
keycloak_quarkus_java_heap_opts: "-Xms1024m -Xmx1024m" keycloak_quarkus_java_heap_opts: "-Xms1024m -Xmx1024m"
keycloak_quarkus_additional_env_vars: keycloak_quarkus_additional_env_vars:
- key: KC_FEATURES_DISABLED - key: KC_FEATURES_DISABLED
@@ -46,7 +48,7 @@
repository_url: https://repo1.maven.org/maven2/ # https://mvnrepository.com/artifact/org.keycloak/keycloak-kerberos-federation/24.0.4 repository_url: https://repo1.maven.org/maven2/ # https://mvnrepository.com/artifact/org.keycloak/keycloak-kerberos-federation/24.0.4
group_id: org.keycloak group_id: org.keycloak
artifact_id: keycloak-kerberos-federation artifact_id: keycloak-kerberos-federation
version: 26.2.4 # optional version: 26.6.2 # optional
# username: myUser # optional # username: myUser # optional
# password: myPAT # optional # password: myPAT # optional
# - id: my-static-theme # - id: my-static-theme
@@ -61,7 +63,6 @@
- role: keycloak_quarkus - role: keycloak_quarkus
- role: keycloak_realm - role: keycloak_realm
keycloak_url: http://instance:8080 keycloak_url: http://instance:8080
keycloak_context: ''
keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}" keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}"
keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}" keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}"
keycloak_client_default_roles: keycloak_client_default_roles:

View File

@@ -1,6 +1,8 @@
--- ---
- name: Prepare - name: Prepare
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
tasks: tasks:
- name: "Display hera_home if defined." - name: "Display hera_home if defined."
ansible.builtin.set_fact: ansible.builtin.set_fact:
@@ -11,11 +13,13 @@
- name: Create certificate request - name: Create certificate request
ansible.builtin.command: openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes -subj '/CN=instance' ansible.builtin.command: openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes -subj '/CN=instance'
args:
chdir: "{{ playbook_dir }}"
delegate_to: localhost delegate_to: localhost
changed_when: false changed_when: false
- name: Create vault directory - name: Create vault directory
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.file: ansible.builtin.file:
state: directory state: directory
path: "/opt/keycloak/vault" path: "/opt/keycloak/vault"
@@ -26,18 +30,20 @@
ansible.builtin.package: ansible.builtin.package:
name: java-21-openjdk-headless name: java-21-openjdk-headless
state: present state: present
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
failed_when: false failed_when: false
- name: Create vault keystore - name: Create vault keystore
ansible.builtin.command: keytool -importpass -alias TestRealm_testalias -keystore keystore.p12 -storepass keystorepassword ansible.builtin.command: keytool -importpass -alias TestRealm_testalias -keystore keystore.p12 -storepass keystorepassword
args:
chdir: "{{ playbook_dir }}"
delegate_to: localhost delegate_to: localhost
register: keytool_cmd register: keytool_cmd
changed_when: False changed_when: False
failed_when: not 'already exists' in keytool_cmd.stdout and keytool_cmd.rc != 0 failed_when: not 'already exists' in keytool_cmd.stdout and keytool_cmd.rc != 0
- name: Copy certificates and vault - name: Copy certificates and vault
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.copy: ansible.builtin.copy:
src: keystore.p12 src: keystore.p12
dest: /opt/keycloak/vault/keystore.p12 dest: /opt/keycloak/vault/keystore.p12

View File

@@ -1,6 +1,8 @@
--- ---
- name: Verify - name: Verify
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
vars: vars:
keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" keycloak_quarkus_bootstrap_admin_password: "remembertochangeme"
keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" keycloak_quarkus_bootstrap_admin_user: "remembertochangeme"
@@ -56,7 +58,7 @@
fail_msg: "Service log symlink not correctly created" fail_msg: "Service log symlink not correctly created"
- name: Check log file - name: Check log file
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.stat: ansible.builtin.stat:
path: /tmp/keycloak/keycloak.log path: /tmp/keycloak/keycloak.log
register: keycloak_log_file register: keycloak_log_file
@@ -68,7 +70,7 @@
- not keycloak_log_file.stat.isdir - not keycloak_log_file.stat.isdir
- name: Check default log folder - name: Check default log folder
become: yes become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.stat: ansible.builtin.stat:
path: /var/log/keycloak path: /var/log/keycloak
register: keycloak_default_log_folder register: keycloak_default_log_folder
@@ -80,7 +82,7 @@
- not keycloak_default_log_folder.stat.exists - not keycloak_default_log_folder.stat.exists
- name: Verify vault SPI in logfile - name: Verify vault SPI in logfile
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.shell: | ansible.builtin.shell: |
set -o pipefail set -o pipefail
zgrep 'Configured KeystoreVaultProviderFactory with the keystore file' /opt/keycloak/keycloak-*/data/log/keycloak.log*zip zgrep 'Configured KeystoreVaultProviderFactory with the keystore file' /opt/keycloak/keycloak-*/data/log/keycloak.log*zip

View File

@@ -1,6 +1,8 @@
--- ---
- name: Converge - name: Converge
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
vars: vars:
keycloak_quarkus_show_deprecation_warnings: false keycloak_quarkus_show_deprecation_warnings: false
keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" keycloak_quarkus_bootstrap_admin_password: "remembertochangeme"
@@ -17,7 +19,6 @@
- role: keycloak_quarkus - role: keycloak_quarkus
- role: keycloak_realm - role: keycloak_realm
keycloak_url: "{{ keycloak_quarkus_hostname }}" keycloak_url: "{{ keycloak_quarkus_hostname }}"
keycloak_context: ''
keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}" keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}"
keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}" keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}"
keycloak_client_default_roles: keycloak_client_default_roles:

View File

@@ -1,6 +1,8 @@
--- ---
- name: Prepare - name: Prepare
hosts: all hosts: all
vars_files:
- ../group_vars/all/vars.yml
tasks: tasks:
- name: Install sudo - name: Install sudo
ansible.builtin.apt: ansible.builtin.apt:
@@ -15,7 +17,7 @@
ansible.builtin.include_tasks: ../prepare.yml ansible.builtin.include_tasks: ../prepare.yml
- name: Install JDK17 - name: Install JDK17
become: yes become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.yum: ansible.builtin.yum:
name: name:
- java-17-openjdk-headless - java-17-openjdk-headless
@@ -24,7 +26,7 @@
- ansible_facts.os_family == 'RedHat' - ansible_facts.os_family == 'RedHat'
- name: Link default logs directory - name: Link default logs directory
become: yes become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.file: ansible.builtin.file:
state: link state: link
src: "{{ item }}" src: "{{ item }}"

View File

@@ -1,6 +1,8 @@
--- ---
- name: Converge - name: Converge
hosts: keycloak hosts: keycloak
vars_files:
- ../group_vars/all/vars.yml
vars: vars:
keycloak_quarkus_show_deprecation_warnings: false keycloak_quarkus_show_deprecation_warnings: false
keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" keycloak_quarkus_bootstrap_admin_password: "remembertochangeme"

View File

@@ -42,7 +42,7 @@ platforms:
mounts: mounts:
- type: bind - type: bind
target: /etc/postgresql/postgresql.conf target: /etc/postgresql/postgresql.conf
source: ${PWD}/molecule/quarkus_ha/postgresql/postgresql.conf source: ${MOLECULE_PROJECT_DIRECTORY}/molecule/quarkus_ha/postgresql/postgresql.conf
env: env:
POSTGRES_USER: keycloak POSTGRES_USER: keycloak
POSTGRES_PASSWORD: mysecretpass POSTGRES_PASSWORD: mysecretpass

View File

@@ -1,6 +1,8 @@
--- ---
- name: Prepare - name: Prepare
hosts: keycloak hosts: keycloak
vars_files:
- ../group_vars/all/vars.yml
tasks: tasks:
- name: "Display hera_home if defined." - name: "Display hera_home if defined."
ansible.builtin.set_fact: ansible.builtin.set_fact:
@@ -11,11 +13,13 @@
- name: Create certificate request - name: Create certificate request
ansible.builtin.command: "openssl req -x509 -newkey rsa:4096 -keyout {{ inventory_hostname }}.key -out {{ inventory_hostname }}.pem -sha256 -days 365 -nodes -subj '/CN={{ inventory_hostname }}'" ansible.builtin.command: "openssl req -x509 -newkey rsa:4096 -keyout {{ inventory_hostname }}.key -out {{ inventory_hostname }}.pem -sha256 -days 365 -nodes -subj '/CN={{ inventory_hostname }}'"
args:
chdir: "{{ playbook_dir }}"
delegate_to: localhost delegate_to: localhost
changed_when: False changed_when: False
- name: Create vault directory - name: Create vault directory
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.file: ansible.builtin.file:
state: directory state: directory
path: "/opt/keycloak/vault" path: "/opt/keycloak/vault"
@@ -26,7 +30,7 @@
ansible.builtin.package: ansible.builtin.package:
name: "{{ 'java-17-openjdk-headless' if hera_home | length > 0 else 'openjdk-17-jdk-headless' }}" name: "{{ 'java-17-openjdk-headless' if hera_home | length > 0 else 'openjdk-17-jdk-headless' }}"
state: present state: present
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
failed_when: false failed_when: false
- name: Create vault keystore - name: Create vault keystore
@@ -37,7 +41,7 @@
failed_when: not 'already exists' in keytool_cmd.stdout and keytool_cmd.rc != 0 failed_when: not 'already exists' in keytool_cmd.stdout and keytool_cmd.rc != 0
- name: Copy certificates and vault - name: Copy certificates and vault
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.copy: ansible.builtin.copy:
src: keystore.p12 src: keystore.p12
dest: /opt/keycloak/vault/keystore.p12 dest: /opt/keycloak/vault/keystore.p12

View File

@@ -1,6 +1,8 @@
--- ---
- name: Verify - name: Verify
hosts: keycloak hosts: keycloak
vars_files:
- ../group_vars/all/vars.yml
tasks: tasks:
- name: Populate service facts - name: Populate service facts
ansible.builtin.service_facts: ansible.builtin.service_facts:
@@ -17,7 +19,7 @@
hera_home: "{{ lookup('env', 'HERA_HOME') }}" hera_home: "{{ lookup('env', 'HERA_HOME') }}"
- name: Check log file - name: Check log file
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.stat: ansible.builtin.stat:
path: /var/log/keycloak/keycloak.log path: /var/log/keycloak/keycloak.log
register: keycloak_log_file register: keycloak_log_file

View File

@@ -0,0 +1,32 @@
---
- name: Converge
hosts: keycloak
vars_files:
- ../group_vars/all/vars.yml
vars:
keycloak_quarkus_show_deprecation_warnings: false
keycloak_quarkus_bootstrap_admin_password: "remembertochangeme"
keycloak_quarkus_bootstrap_admin_user: "remembertochangeme"
keycloak_quarkus_hostname: "http://{{ inventory_hostname }}:8080"
keycloak_quarkus_log: file
keycloak_quarkus_log_level: info
keycloak_quarkus_https_key_file_enabled: true
keycloak_quarkus_key_file_copy_enabled: true
keycloak_quarkus_key_content: "{{ lookup('file', inventory_hostname + '.key') }}"
keycloak_quarkus_cert_file_copy_enabled: true
keycloak_quarkus_cert_file_src: "{{ inventory_hostname }}.pem"
keycloak_quarkus_ks_vault_enabled: true
keycloak_quarkus_ks_vault_file: "/opt/keycloak/vault/keystore.p12"
keycloak_quarkus_ks_vault_pass: keystorepassword
keycloak_quarkus_systemd_wait_for_port: true
keycloak_quarkus_systemd_wait_for_timeout: 20
keycloak_quarkus_systemd_wait_for_delay: 2
keycloak_quarkus_systemd_wait_for_log: true
keycloak_quarkus_ha_enabled: true
keycloak_quarkus_restart_strategy: restart/serial.yml
keycloak_quarkus_db_user: keycloak
keycloak_quarkus_db_pass: mysecretpass
keycloak_quarkus_db_url: jdbc:postgresql://postgres:5432/keycloak
keycloak_quarkus_version: 26.3.5
roles:
- role: keycloak_quarkus

View File

@@ -0,0 +1,82 @@
---
driver:
name: docker
platforms:
- name: instance1
image: registry.access.redhat.com/ubi9/ubi-init:latest
pre_build_image: true
privileged: true
command: "/usr/sbin/init"
groups:
- keycloak
networks:
- name: rhbk
port_bindings:
- "8080/tcp"
- "8443/tcp"
- "9000/tcp"
- name: instance2
image: registry.access.redhat.com/ubi9/ubi-init:latest
pre_build_image: true
privileged: true
command: "/usr/sbin/init"
groups:
- keycloak
networks:
- name: rhbk
port_bindings:
- "8080/tcp"
- "8443/tcp"
- "9000/tcp"
- name: postgres
image: ubuntu/postgres:14-22.04_beta
pre_build_image: true
privileged: true
command: postgres
groups:
- database
networks:
- name: rhbk
port_bindings:
- "5432/tcp"
mounts:
- type: bind
target: /etc/postgresql/postgresql.conf
source: ${MOLECULE_PROJECT_DIRECTORY}/molecule/quarkus_ha/postgresql/postgresql.conf
env:
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: mysecretpass
POSTGRES_DB: keycloak
POSTGRES_HOST_AUTH_METHOD: trust
provisioner:
name: ansible
config_options:
defaults:
interpreter_python: auto_silent
ssh_connection:
pipelining: false
playbooks:
prepare: prepare.yml
converge: converge.yml
verify: verify.yml
inventory:
host_vars:
localhost:
ansible_python_interpreter: "{{ ansible_playbook_python }}"
env:
ANSIBLE_FORCE_COLOR: "true"
PYTHONHTTPSVERIFY: 0
verifier:
name: ansible
scenario:
test_sequence:
- cleanup
- destroy
- create
- prepare
- converge
- idempotence
- side_effect
- verify
- cleanup
- destroy

View File

@@ -0,0 +1,750 @@
# -----------------------------
# PostgreSQL configuration file
# -----------------------------
#
# This file consists of lines of the form:
#
# name = value
#
# (The "=" is optional.) Whitespace may be used. Comments are introduced with
# "#" anywhere on a line. The complete list of parameter names and allowed
# values can be found in the PostgreSQL documentation.
#
# The commented-out settings shown in this file represent the default values.
# Re-commenting a setting is NOT sufficient to revert it to the default value;
# you need to reload the server.
#
# This file is read on server startup and when the server receives a SIGHUP
# signal. If you edit the file on a running system, you have to SIGHUP the
# server for the changes to take effect, run "pg_ctl reload", or execute
# "SELECT pg_reload_conf()". Some parameters, which are marked below,
# require a server shutdown and restart to take effect.
#
# Any parameter can also be given as a command-line option to the server, e.g.,
# "postgres -c log_connections=on". Some parameters can be changed at run time
# with the "SET" SQL command.
#
# Memory units: kB = kilobytes Time units: ms = milliseconds
# MB = megabytes s = seconds
# GB = gigabytes min = minutes
# TB = terabytes h = hours
# d = days
#------------------------------------------------------------------------------
# FILE LOCATIONS
#------------------------------------------------------------------------------
# The default values of these variables are driven from the -D command-line
# option or PGDATA environment variable, represented here as ConfigDir.
#data_directory = 'ConfigDir' # use data in another directory
# (change requires restart)
#hba_file = 'ConfigDir/pg_hba.conf' # host-based authentication file
# (change requires restart)
#ident_file = 'ConfigDir/pg_ident.conf' # ident configuration file
# (change requires restart)
# If external_pid_file is not explicitly set, no extra PID file is written.
#external_pid_file = '' # write an extra PID file
# (change requires restart)
#------------------------------------------------------------------------------
# CONNECTIONS AND AUTHENTICATION
#------------------------------------------------------------------------------
# - Connection Settings -
listen_addresses = '*' # what IP address(es) to listen on;
# comma-separated list of addresses;
# defaults to 'localhost'; use '*' for all
# (change requires restart)
#port = 5432 # (change requires restart)
#max_connections = 100 # (change requires restart)
#superuser_reserved_connections = 3 # (change requires restart)
#unix_socket_directories = '/tmp' # comma-separated list of directories
# (change requires restart)
#unix_socket_group = '' # (change requires restart)
#unix_socket_permissions = 0777 # begin with 0 to use octal notation
# (change requires restart)
#bonjour = off # advertise server via Bonjour
# (change requires restart)
#bonjour_name = '' # defaults to the computer name
# (change requires restart)
# - TCP settings -
# see "man 7 tcp" for details
#tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds;
# 0 selects the system default
#tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds;
# 0 selects the system default
#tcp_keepalives_count = 0 # TCP_KEEPCNT;
# 0 selects the system default
#tcp_user_timeout = 0 # TCP_USER_TIMEOUT, in milliseconds;
# 0 selects the system default
# - Authentication -
#authentication_timeout = 1min # 1s-600s
#password_encryption = md5 # md5 or scram-sha-256
#db_user_namespace = off
# GSSAPI using Kerberos
#krb_server_keyfile = ''
#krb_caseins_users = off
# - SSL -
#ssl = off
#ssl_ca_file = ''
#ssl_cert_file = 'server.crt'
#ssl_crl_file = ''
#ssl_key_file = 'server.key'
#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers
#ssl_prefer_server_ciphers = on
#ssl_ecdh_curve = 'prime256v1'
#ssl_min_protocol_version = 'TLSv1'
#ssl_max_protocol_version = ''
#ssl_dh_params_file = ''
#ssl_passphrase_command = ''
#ssl_passphrase_command_supports_reload = off
#------------------------------------------------------------------------------
# RESOURCE USAGE (except WAL)
#------------------------------------------------------------------------------
# - Memory -
#shared_buffers = 32MB # min 128kB
# (change requires restart)
#huge_pages = try # on, off, or try
# (change requires restart)
#temp_buffers = 8MB # min 800kB
#max_prepared_transactions = 0 # zero disables the feature
# (change requires restart)
# Caution: it is not advisable to set max_prepared_transactions nonzero unless
# you actively intend to use prepared transactions.
#work_mem = 4MB # min 64kB
#maintenance_work_mem = 64MB # min 1MB
#autovacuum_work_mem = -1 # min 1MB, or -1 to use maintenance_work_mem
#max_stack_depth = 2MB # min 100kB
#shared_memory_type = mmap # the default is the first option
# supported by the operating system:
# mmap
# sysv
# windows
# (change requires restart)
#dynamic_shared_memory_type = posix # the default is the first option
# supported by the operating system:
# posix
# sysv
# windows
# mmap
# (change requires restart)
# - Disk -
#temp_file_limit = -1 # limits per-process temp file space
# in kB, or -1 for no limit
# - Kernel Resources -
#max_files_per_process = 1000 # min 25
# (change requires restart)
# - Cost-Based Vacuum Delay -
#vacuum_cost_delay = 0 # 0-100 milliseconds (0 disables)
#vacuum_cost_page_hit = 1 # 0-10000 credits
#vacuum_cost_page_miss = 10 # 0-10000 credits
#vacuum_cost_page_dirty = 20 # 0-10000 credits
#vacuum_cost_limit = 200 # 1-10000 credits
# - Background Writer -
#bgwriter_delay = 200ms # 10-10000ms between rounds
#bgwriter_lru_maxpages = 100 # max buffers written/round, 0 disables
#bgwriter_lru_multiplier = 2.0 # 0-10.0 multiplier on buffers scanned/round
#bgwriter_flush_after = 0 # measured in pages, 0 disables
# - Asynchronous Behavior -
#effective_io_concurrency = 1 # 1-1000; 0 disables prefetching
#max_worker_processes = 8 # (change requires restart)
#max_parallel_maintenance_workers = 2 # taken from max_parallel_workers
#max_parallel_workers_per_gather = 2 # taken from max_parallel_workers
#parallel_leader_participation = on
#max_parallel_workers = 8 # maximum number of max_worker_processes that
# can be used in parallel operations
#old_snapshot_threshold = -1 # 1min-60d; -1 disables; 0 is immediate
# (change requires restart)
#backend_flush_after = 0 # measured in pages, 0 disables
#------------------------------------------------------------------------------
# WRITE-AHEAD LOG
#------------------------------------------------------------------------------
# - Settings -
#wal_level = replica # minimal, replica, or logical
# (change requires restart)
#fsync = on # flush data to disk for crash safety
# (turning this off can cause
# unrecoverable data corruption)
#synchronous_commit = on # synchronization level;
# off, local, remote_write, remote_apply, or on
#wal_sync_method = fsync # the default is the first option
# supported by the operating system:
# open_datasync
# fdatasync (default on Linux)
# fsync
# fsync_writethrough
# open_sync
#full_page_writes = on # recover from partial page writes
#wal_compression = off # enable compression of full-page writes
#wal_log_hints = off # also do full page writes of non-critical updates
# (change requires restart)
#wal_init_zero = on # zero-fill new WAL files
#wal_recycle = on # recycle WAL files
#wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers
# (change requires restart)
#wal_writer_delay = 200ms # 1-10000 milliseconds
#wal_writer_flush_after = 1MB # measured in pages, 0 disables
#commit_delay = 0 # range 0-100000, in microseconds
#commit_siblings = 5 # range 1-1000
# - Checkpoints -
#checkpoint_timeout = 5min # range 30s-1d
#max_wal_size = 1GB
#min_wal_size = 80MB
#checkpoint_completion_target = 0.5 # checkpoint target duration, 0.0 - 1.0
#checkpoint_flush_after = 0 # measured in pages, 0 disables
#checkpoint_warning = 30s # 0 disables
# - Archiving -
#archive_mode = off # enables archiving; off, on, or always
# (change requires restart)
#archive_command = '' # command to use to archive a logfile segment
# placeholders: %p = path of file to archive
# %f = file name only
# e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f'
#archive_timeout = 0 # force a logfile segment switch after this
# number of seconds; 0 disables
# - Archive Recovery -
# These are only used in recovery mode.
#restore_command = '' # command to use to restore an archived logfile segment
# placeholders: %p = path of file to restore
# %f = file name only
# e.g. 'cp /mnt/server/archivedir/%f %p'
# (change requires restart)
#archive_cleanup_command = '' # command to execute at every restartpoint
#recovery_end_command = '' # command to execute at completion of recovery
# - Recovery Target -
# Set these only when performing a targeted recovery.
#recovery_target = '' # 'immediate' to end recovery as soon as a
# consistent state is reached
# (change requires restart)
#recovery_target_name = '' # the named restore point to which recovery will proceed
# (change requires restart)
#recovery_target_time = '' # the time stamp up to which recovery will proceed
# (change requires restart)
#recovery_target_xid = '' # the transaction ID up to which recovery will proceed
# (change requires restart)
#recovery_target_lsn = '' # the WAL LSN up to which recovery will proceed
# (change requires restart)
#recovery_target_inclusive = on # Specifies whether to stop:
# just after the specified recovery target (on)
# just before the recovery target (off)
# (change requires restart)
#recovery_target_timeline = 'latest' # 'current', 'latest', or timeline ID
# (change requires restart)
#recovery_target_action = 'pause' # 'pause', 'promote', 'shutdown'
# (change requires restart)
#------------------------------------------------------------------------------
# REPLICATION
#------------------------------------------------------------------------------
# - Sending Servers -
# Set these on the master and on any standby that will send replication data.
#max_wal_senders = 10 # max number of walsender processes
# (change requires restart)
#wal_keep_segments = 0 # in logfile segments; 0 disables
#wal_sender_timeout = 60s # in milliseconds; 0 disables
#max_replication_slots = 10 # max number of replication slots
# (change requires restart)
#track_commit_timestamp = off # collect timestamp of transaction commit
# (change requires restart)
# - Master Server -
# These settings are ignored on a standby server.
#synchronous_standby_names = '' # standby servers that provide sync rep
# method to choose sync standbys, number of sync standbys,
# and comma-separated list of application_name
# from standby(s); '*' = all
#vacuum_defer_cleanup_age = 0 # number of xacts by which cleanup is delayed
# - Standby Servers -
# These settings are ignored on a master server.
#primary_conninfo = '' # connection string to sending server
# (change requires restart)
#primary_slot_name = '' # replication slot on sending server
# (change requires restart)
#promote_trigger_file = '' # file name whose presence ends recovery
#hot_standby = on # "off" disallows queries during recovery
# (change requires restart)
#max_standby_archive_delay = 30s # max delay before canceling queries
# when reading WAL from archive;
# -1 allows indefinite delay
#max_standby_streaming_delay = 30s # max delay before canceling queries
# when reading streaming WAL;
# -1 allows indefinite delay
#wal_receiver_status_interval = 10s # send replies at least this often
# 0 disables
#hot_standby_feedback = off # send info from standby to prevent
# query conflicts
#wal_receiver_timeout = 60s # time that receiver waits for
# communication from master
# in milliseconds; 0 disables
#wal_retrieve_retry_interval = 5s # time to wait before retrying to
# retrieve WAL after a failed attempt
#recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery
# - Subscribers -
# These settings are ignored on a publisher.
#max_logical_replication_workers = 4 # taken from max_worker_processes
# (change requires restart)
#max_sync_workers_per_subscription = 2 # taken from max_logical_replication_workers
#------------------------------------------------------------------------------
# QUERY TUNING
#------------------------------------------------------------------------------
# - Planner Method Configuration -
#enable_bitmapscan = on
#enable_hashagg = on
#enable_hashjoin = on
#enable_indexscan = on
#enable_indexonlyscan = on
#enable_material = on
#enable_mergejoin = on
#enable_nestloop = on
#enable_parallel_append = on
#enable_seqscan = on
#enable_sort = on
#enable_tidscan = on
#enable_partitionwise_join = off
#enable_partitionwise_aggregate = off
#enable_parallel_hash = on
#enable_partition_pruning = on
# - Planner Cost Constants -
#seq_page_cost = 1.0 # measured on an arbitrary scale
#random_page_cost = 4.0 # same scale as above
#cpu_tuple_cost = 0.01 # same scale as above
#cpu_index_tuple_cost = 0.005 # same scale as above
#cpu_operator_cost = 0.0025 # same scale as above
#parallel_tuple_cost = 0.1 # same scale as above
#parallel_setup_cost = 1000.0 # same scale as above
#jit_above_cost = 100000 # perform JIT compilation if available
# and query more expensive than this;
# -1 disables
#jit_inline_above_cost = 500000 # inline small functions if query is
# more expensive than this; -1 disables
#jit_optimize_above_cost = 500000 # use expensive JIT optimizations if
# query is more expensive than this;
# -1 disables
#min_parallel_table_scan_size = 8MB
#min_parallel_index_scan_size = 512kB
#effective_cache_size = 4GB
# - Genetic Query Optimizer -
#geqo = on
#geqo_threshold = 12
#geqo_effort = 5 # range 1-10
#geqo_pool_size = 0 # selects default based on effort
#geqo_generations = 0 # selects default based on effort
#geqo_selection_bias = 2.0 # range 1.5-2.0
#geqo_seed = 0.0 # range 0.0-1.0
# - Other Planner Options -
#default_statistics_target = 100 # range 1-10000
#constraint_exclusion = partition # on, off, or partition
#cursor_tuple_fraction = 0.1 # range 0.0-1.0
#from_collapse_limit = 8
#join_collapse_limit = 8 # 1 disables collapsing of explicit
# JOIN clauses
#force_parallel_mode = off
#jit = on # allow JIT compilation
#plan_cache_mode = auto # auto, force_generic_plan or
# force_custom_plan
#------------------------------------------------------------------------------
# REPORTING AND LOGGING
#------------------------------------------------------------------------------
# - Where to Log -
#log_destination = 'stderr' # Valid values are combinations of
# stderr, csvlog, syslog, and eventlog,
# depending on platform. csvlog
# requires logging_collector to be on.
# This is used when logging to stderr:
#logging_collector = off # Enable capturing of stderr and csvlog
# into log files. Required to be on for
# csvlogs.
# (change requires restart)
# These are only used if logging_collector is on:
#log_directory = 'log' # directory where log files are written,
# can be absolute or relative to PGDATA
#log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern,
# can include strftime() escapes
#log_file_mode = 0600 # creation mode for log files,
# begin with 0 to use octal notation
#log_truncate_on_rotation = off # If on, an existing log file with the
# same name as the new log file will be
# truncated rather than appended to.
# But such truncation only occurs on
# time-driven rotation, not on restarts
# or size-driven rotation. Default is
# off, meaning append to existing files
# in all cases.
#log_rotation_age = 1d # Automatic rotation of logfiles will
# happen after that time. 0 disables.
#log_rotation_size = 10MB # Automatic rotation of logfiles will
# happen after that much log output.
# 0 disables.
# These are relevant when logging to syslog:
#syslog_facility = 'LOCAL0'
#syslog_ident = 'postgres'
#syslog_sequence_numbers = on
#syslog_split_messages = on
# This is only relevant when logging to eventlog (win32):
# (change requires restart)
#event_source = 'PostgreSQL'
# - When to Log -
#log_min_messages = warning # values in order of decreasing detail:
# debug5
# debug4
# debug3
# debug2
# debug1
# info
# notice
# warning
# error
# log
# fatal
# panic
#log_min_error_statement = error # values in order of decreasing detail:
# debug5
# debug4
# debug3
# debug2
# debug1
# info
# notice
# warning
# error
# log
# fatal
# panic (effectively off)
#log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements
# and their durations, > 0 logs only
# statements running at least this number
# of milliseconds
#log_transaction_sample_rate = 0.0 # Fraction of transactions whose statements
# are logged regardless of their duration. 1.0 logs all
# statements from all transactions, 0.0 never logs.
# - What to Log -
#debug_print_parse = off
#debug_print_rewritten = off
#debug_print_plan = off
#debug_pretty_print = on
#log_checkpoints = off
#log_connections = off
#log_disconnections = off
#log_duration = off
#log_error_verbosity = default # terse, default, or verbose messages
#log_hostname = off
#log_line_prefix = '%m [%p] ' # special values:
# %a = application name
# %u = user name
# %d = database name
# %r = remote host and port
# %h = remote host
# %p = process ID
# %t = timestamp without milliseconds
# %m = timestamp with milliseconds
# %n = timestamp with milliseconds (as a Unix epoch)
# %i = command tag
# %e = SQL state
# %c = session ID
# %l = session line number
# %s = session start timestamp
# %v = virtual transaction ID
# %x = transaction ID (0 if none)
# %q = stop here in non-session
# processes
# %% = '%'
# e.g. '<%u%%%d> '
#log_lock_waits = off # log lock waits >= deadlock_timeout
#log_statement = 'none' # none, ddl, mod, all
#log_replication_commands = off
#log_temp_files = -1 # log temporary files equal or larger
# than the specified size in kilobytes;
# -1 disables, 0 logs all temp files
#log_timezone = 'GMT'
#------------------------------------------------------------------------------
# PROCESS TITLE
#------------------------------------------------------------------------------
#cluster_name = '' # added to process titles if nonempty
# (change requires restart)
#update_process_title = on
#------------------------------------------------------------------------------
# STATISTICS
#------------------------------------------------------------------------------
# - Query and Index Statistics Collector -
#track_activities = on
#track_counts = on
#track_io_timing = off
#track_functions = none # none, pl, all
#track_activity_query_size = 1024 # (change requires restart)
#stats_temp_directory = 'pg_stat_tmp'
# - Monitoring -
#log_parser_stats = off
#log_planner_stats = off
#log_executor_stats = off
#log_statement_stats = off
#------------------------------------------------------------------------------
# AUTOVACUUM
#------------------------------------------------------------------------------
#autovacuum = on # Enable autovacuum subprocess? 'on'
# requires track_counts to also be on.
#log_autovacuum_min_duration = -1 # -1 disables, 0 logs all actions and
# their durations, > 0 logs only
# actions running at least this number
# of milliseconds.
#autovacuum_max_workers = 3 # max number of autovacuum subprocesses
# (change requires restart)
#autovacuum_naptime = 1min # time between autovacuum runs
#autovacuum_vacuum_threshold = 50 # min number of row updates before
# vacuum
#autovacuum_analyze_threshold = 50 # min number of row updates before
# analyze
#autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum
#autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze
#autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum
# (change requires restart)
#autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age
# before forced vacuum
# (change requires restart)
#autovacuum_vacuum_cost_delay = 2ms # default vacuum cost delay for
# autovacuum, in milliseconds;
# -1 means use vacuum_cost_delay
#autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for
# autovacuum, -1 means use
# vacuum_cost_limit
#------------------------------------------------------------------------------
# CLIENT CONNECTION DEFAULTS
#------------------------------------------------------------------------------
# - Statement Behavior -
#client_min_messages = notice # values in order of decreasing detail:
# debug5
# debug4
# debug3
# debug2
# debug1
# log
# notice
# warning
# error
#search_path = '"$user", public' # schema names
#row_security = on
#default_tablespace = '' # a tablespace name, '' uses the default
#temp_tablespaces = '' # a list of tablespace names, '' uses
# only default tablespace
#default_table_access_method = 'heap'
#check_function_bodies = on
#default_transaction_isolation = 'read committed'
#default_transaction_read_only = off
#default_transaction_deferrable = off
#session_replication_role = 'origin'
#statement_timeout = 0 # in milliseconds, 0 is disabled
#lock_timeout = 0 # in milliseconds, 0 is disabled
#idle_in_transaction_session_timeout = 0 # in milliseconds, 0 is disabled
#vacuum_freeze_min_age = 50000000
#vacuum_freeze_table_age = 150000000
#vacuum_multixact_freeze_min_age = 5000000
#vacuum_multixact_freeze_table_age = 150000000
#vacuum_cleanup_index_scale_factor = 0.1 # fraction of total number of tuples
# before index cleanup, 0 always performs
# index cleanup
#bytea_output = 'hex' # hex, escape
#xmlbinary = 'base64'
#xmloption = 'content'
#gin_fuzzy_search_limit = 0
#gin_pending_list_limit = 4MB
# - Locale and Formatting -
#datestyle = 'iso, mdy'
#intervalstyle = 'postgres'
#timezone = 'GMT'
#timezone_abbreviations = 'Default' # Select the set of available time zone
# abbreviations. Currently, there are
# Default
# Australia (historical usage)
# India
# You can create your own file in
# share/timezonesets/.
#extra_float_digits = 1 # min -15, max 3; any value >0 actually
# selects precise output mode
#client_encoding = sql_ascii # actually, defaults to database
# encoding
# These settings are initialized by initdb, but they can be changed.
#lc_messages = 'C' # locale for system error message
# strings
#lc_monetary = 'C' # locale for monetary formatting
#lc_numeric = 'C' # locale for number formatting
#lc_time = 'C' # locale for time formatting
# default configuration for text search
#default_text_search_config = 'pg_catalog.simple'
# - Shared Library Preloading -
#shared_preload_libraries = '' # (change requires restart)
#local_preload_libraries = ''
#session_preload_libraries = ''
#jit_provider = 'llvmjit' # JIT library to use
# - Other Defaults -
#dynamic_library_path = '$libdir'
#------------------------------------------------------------------------------
# LOCK MANAGEMENT
#------------------------------------------------------------------------------
#deadlock_timeout = 1s
#max_locks_per_transaction = 64 # min 10
# (change requires restart)
#max_pred_locks_per_transaction = 64 # min 10
# (change requires restart)
#max_pred_locks_per_relation = -2 # negative values mean
# (max_pred_locks_per_transaction
# / -max_pred_locks_per_relation) - 1
#max_pred_locks_per_page = 2 # min 0
#------------------------------------------------------------------------------
# VERSION AND PLATFORM COMPATIBILITY
#------------------------------------------------------------------------------
# - Previous PostgreSQL Versions -
#array_nulls = on
#backslash_quote = safe_encoding # on, off, or safe_encoding
#escape_string_warning = on
#lo_compat_privileges = off
#operator_precedence_warning = off
#quote_all_identifiers = off
#standard_conforming_strings = on
#synchronize_seqscans = on
# - Other Platforms and Clients -
#transform_null_equals = off
#------------------------------------------------------------------------------
# ERROR HANDLING
#------------------------------------------------------------------------------
#exit_on_error = off # terminate session on any error?
#restart_after_crash = on # reinitialize after backend crash?
#data_sync_retry = off # retry or panic on failure to fsync
# data?
# (change requires restart)
#------------------------------------------------------------------------------
# CONFIG FILE INCLUDES
#------------------------------------------------------------------------------
# These options allow settings to be loaded from files other than the
# default postgresql.conf. Note that these are directives, not variable
# assignments, so they can usefully be given more than once.
#include_dir = '...' # include files ending in '.conf' from
# a directory, e.g., 'conf.d'
#include_if_exists = '...' # include file only if it exists
#include = '...' # include file
#------------------------------------------------------------------------------
# CUSTOMIZED OPTIONS
#------------------------------------------------------------------------------
# Add settings for extensions here

View File

@@ -0,0 +1,48 @@
---
- name: Prepare
hosts: keycloak
vars_files:
- ../group_vars/all/vars.yml
tasks:
- name: "Display hera_home if defined."
ansible.builtin.set_fact:
hera_home: "{{ lookup('env', 'HERA_HOME') }}"
- name: "Ensure common prepare phase are set."
ansible.builtin.include_tasks: ../prepare.yml
- name: Create certificate request
ansible.builtin.command: "openssl req -x509 -newkey rsa:4096 -keyout {{ inventory_hostname }}.key -out {{ inventory_hostname }}.pem -sha256 -days 365 -nodes -subj '/CN={{ inventory_hostname }}'"
args:
chdir: "{{ playbook_dir }}"
delegate_to: localhost
changed_when: False
- name: Create vault directory
become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.file:
state: directory
path: "/opt/keycloak/vault"
mode: 0755
- name: Make sure a jre is available (for keytool to prepare keystore)
delegate_to: localhost
ansible.builtin.package:
name: "{{ 'java-17-openjdk-headless' if hera_home | length > 0 else 'openjdk-17-jdk-headless' }}"
state: present
become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
failed_when: false
- name: Create vault keystore
ansible.builtin.command: keytool -importpass -alias TestRealm_testalias -keystore keystore.p12 -storepass keystorepassword
delegate_to: localhost
register: keytool_cmd
changed_when: False
failed_when: not 'already exists' in keytool_cmd.stdout and keytool_cmd.rc != 0
- name: Copy certificates and vault
become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.copy:
src: keystore.p12
dest: /opt/keycloak/vault/keystore.p12
mode: 0444

View File

@@ -0,0 +1 @@
../../roles

View File

@@ -0,0 +1,31 @@
---
- name: Verify
hosts: keycloak
vars_files:
- ../group_vars/all/vars.yml
tasks:
- name: Populate service facts
ansible.builtin.service_facts:
- name: Check if keycloak service started
ansible.builtin.assert:
that:
- ansible_facts.services["keycloak.service"]["state"] == "running"
- ansible_facts.services["keycloak.service"]["status"] == "enabled"
fail_msg: "Service not running"
- name: Set internal envvar
ansible.builtin.set_fact:
hera_home: "{{ lookup('env', 'HERA_HOME') }}"
- name: Check log file
become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.stat:
path: /var/log/keycloak/keycloak.log
register: keycloak_log_file
- name: Check if keycloak file exists
ansible.builtin.assert:
that:
- keycloak_log_file.stat.exists
- not keycloak_log_file.stat.isdir

View File

@@ -1,23 +1,36 @@
--- ---
- name: Converge - name: Converge
hosts: infinispan hosts: infinispan
vars_files:
- ../group_vars/all/vars.yml
vars:
ansible_become: "{{ keycloak_install_requires_become | default(true) }}"
roles: roles:
- role: middleware_automation.infinispan.infinispan - role: middleware_automation.infinispan.infinispan
infinispan_service_name: infinispan infinispan_service_name: infinispan
infinispan_supervisor_password: remembertochangeme infinispan_supervisor_password: remembertochangeme
infinispan_keycloak_caches: true infinispan_keycloak_caches: true
infinispan_keycloak_persistence: False infinispan_keycloak_persistence: False
infinispan_jgroups_discovery: TCPPING
infinispan_jdbc_engine: postgres infinispan_jdbc_engine: postgres
infinispan_jdbc_url: jdbc:postgresql://postgres:5432/keycloak infinispan_jdbc_url: jdbc:postgresql://postgres:5432/keycloak
infinispan_jdbc_driver_version: 9.4.1212 infinispan_jdbc_driver_version: 9.4.1212
infinispan_jdbc_user: keycloak infinispan_jdbc_user: keycloak
infinispan_jdbc_pass: mysecretpass infinispan_jdbc_pass: mysecretpass
infinispan_bind_address: "{{ ansible_default_ipv4.address }}" infinispan_bind_address: 0.0.0.0
infinispan_users: infinispan_users:
- { name: 'testuser', password: 'test', roles: 'observer' } - { name: 'testuser', password: 'test', roles: 'observer' }
- name: Converge - name: Converge
hosts: keycloak hosts: keycloak
vars_files:
- ../group_vars/all/vars.yml
pre_tasks:
- name: Wait for Infinispan Hot Rod port before starting Keycloak
ansible.builtin.wait_for:
host: infinispan1
port: 11222
timeout: 120
vars: vars:
keycloak_quarkus_show_deprecation_warnings: false keycloak_quarkus_show_deprecation_warnings: false
keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" keycloak_quarkus_bootstrap_admin_password: "remembertochangeme"
@@ -34,7 +47,7 @@
keycloak_quarkus_ks_vault_file: "/opt/keycloak/vault/keystore.p12" keycloak_quarkus_ks_vault_file: "/opt/keycloak/vault/keystore.p12"
keycloak_quarkus_ks_vault_pass: keystorepassword keycloak_quarkus_ks_vault_pass: keystorepassword
keycloak_quarkus_systemd_wait_for_port: true keycloak_quarkus_systemd_wait_for_port: true
keycloak_quarkus_systemd_wait_for_timeout: 20 keycloak_quarkus_systemd_wait_for_timeout: 120
keycloak_quarkus_systemd_wait_for_delay: 2 keycloak_quarkus_systemd_wait_for_delay: 2
keycloak_quarkus_systemd_wait_for_log: true keycloak_quarkus_systemd_wait_for_log: true
keycloak_quarkus_ha_enabled: true keycloak_quarkus_ha_enabled: true

View File

@@ -40,7 +40,7 @@ platforms:
mounts: mounts:
- type: bind - type: bind
target: /etc/postgresql/postgresql.conf target: /etc/postgresql/postgresql.conf
source: ${PWD}/molecule/quarkus_ha/postgresql/postgresql.conf source: ${MOLECULE_PROJECT_DIRECTORY}/molecule/quarkus_ha_remote/postgresql/postgresql.conf
env: env:
POSTGRES_USER: keycloak POSTGRES_USER: keycloak
POSTGRES_PASSWORD: mysecretpass POSTGRES_PASSWORD: mysecretpass

View File

@@ -1,6 +1,8 @@
--- ---
- name: Prepare - name: Prepare
hosts: 'keycloak:infinispan' hosts: 'keycloak:infinispan'
vars_files:
- ../group_vars/all/vars.yml
tasks: tasks:
- name: "Display hera_home if defined." - name: "Display hera_home if defined."
ansible.builtin.set_fact: ansible.builtin.set_fact:
@@ -11,11 +13,13 @@
- name: Create certificate request - name: Create certificate request
ansible.builtin.command: "openssl req -x509 -newkey rsa:4096 -keyout {{ inventory_hostname }}.key -out {{ inventory_hostname }}.pem -sha256 -days 365 -nodes -subj '/CN={{ inventory_hostname }}'" ansible.builtin.command: "openssl req -x509 -newkey rsa:4096 -keyout {{ inventory_hostname }}.key -out {{ inventory_hostname }}.pem -sha256 -days 365 -nodes -subj '/CN={{ inventory_hostname }}'"
args:
chdir: "{{ playbook_dir }}"
delegate_to: localhost delegate_to: localhost
changed_when: False changed_when: False
- name: Create vault directory - name: Create vault directory
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.file: ansible.builtin.file:
state: directory state: directory
path: "/opt/keycloak/vault" path: "/opt/keycloak/vault"
@@ -26,18 +30,20 @@
ansible.builtin.package: ansible.builtin.package:
name: "{{ 'java-17-openjdk-headless' if hera_home | length > 0 else 'openjdk-17-jdk-headless' }}" name: "{{ 'java-17-openjdk-headless' if hera_home | length > 0 else 'openjdk-17-jdk-headless' }}"
state: present state: present
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
failed_when: false failed_when: false
- name: Create vault keystore - name: Create vault keystore
ansible.builtin.command: keytool -importpass -alias TestRealm_testalias -keystore keystore.p12 -storepass keystorepassword ansible.builtin.command: keytool -importpass -alias TestRealm_testalias -keystore keystore.p12 -storepass keystorepassword
args:
chdir: "{{ playbook_dir }}"
delegate_to: localhost delegate_to: localhost
register: keytool_cmd register: keytool_cmd
changed_when: False changed_when: False
failed_when: not 'already exists' in keytool_cmd.stdout and keytool_cmd.rc != 0 failed_when: not 'already exists' in keytool_cmd.stdout and keytool_cmd.rc != 0
- name: Copy certificates and vault - name: Copy certificates and vault
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.copy: ansible.builtin.copy:
src: keystore.p12 src: keystore.p12
dest: /opt/keycloak/vault/keystore.p12 dest: /opt/keycloak/vault/keystore.p12

View File

@@ -1,6 +1,8 @@
--- ---
- name: Verify - name: Verify
hosts: keycloak hosts: keycloak
vars_files:
- ../group_vars/all/vars.yml
tasks: tasks:
- name: Populate service facts - name: Populate service facts
ansible.builtin.service_facts: ansible.builtin.service_facts:
@@ -17,7 +19,7 @@
hera_home: "{{ lookup('env', 'HERA_HOME') }}" hera_home: "{{ lookup('env', 'HERA_HOME') }}"
- name: Check log file - name: Check log file
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"
ansible.builtin.stat: ansible.builtin.stat:
path: /var/log/keycloak/keycloak.log path: /var/log/keycloak/keycloak.log
register: keycloak_log_file register: keycloak_log_file

View File

@@ -2,12 +2,13 @@
- name: Converge - name: Converge
hosts: all hosts: all
vars_files: vars_files:
- ../group_vars/all/vars.yml
- vars.yml - vars.yml
vars: vars:
keycloak_quarkus_show_deprecation_warnings: false keycloak_quarkus_show_deprecation_warnings: false
keycloak_quarkus_additional_env_vars: keycloak_quarkus_additional_env_vars:
- key: KC_FEATURES_DISABLED - key: KC_FEATURES_DISABLED
value: ciba,device-flow,impersonation,kerberos,docker value: ciba,device-flow,impersonation,kerberos,docker
keycloak_quarkus_version: 26.0.7 keycloak_quarkus_version: 26.6.2
roles: roles:
- role: keycloak_quarkus - role: keycloak_quarkus

View File

@@ -2,10 +2,11 @@
- name: Prepare - name: Prepare
hosts: all hosts: all
vars_files: vars_files:
- ../group_vars/all/vars.yml
- vars.yml - vars.yml
vars: vars:
sudo_pkg_name: sudo sudo_pkg_name: sudo
keycloak_quarkus_version: 26.0.4 keycloak_quarkus_version: 26.6.1
keycloak_quarkus_additional_env_vars: keycloak_quarkus_additional_env_vars:
- key: KC_FEATURES_DISABLED - key: KC_FEATURES_DISABLED
value: impersonation,kerberos value: impersonation,kerberos
@@ -43,6 +44,8 @@
- name: Create certificate request - name: Create certificate request
ansible.builtin.command: openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes -subj '/CN=instance' ansible.builtin.command: openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes -subj '/CN=instance'
args:
chdir: "{{ playbook_dir }}"
delegate_to: localhost delegate_to: localhost
changed_when: false changed_when: false
roles: roles:
@@ -53,4 +56,4 @@
ansible.builtin.file: ansible.builtin.file:
path: /etc/ansible/facts.d/keycloak.fact path: /etc/ansible/facts.d/keycloak.fact
state: absent state: absent
become: true become: "{{ molecule_prepare_require_privilege_escalation | default(true) }}"

View File

@@ -14,10 +14,9 @@
- ansible_facts.services["keycloak.service"]["state"] == "running" - ansible_facts.services["keycloak.service"]["state"] == "running"
- ansible_facts.services["keycloak.service"]["status"] == "enabled" - ansible_facts.services["keycloak.service"]["status"] == "enabled"
- name: Verify we are running on requested jvm - name: Verify Java 21 runtime is installed (UBI/RHEL)
ansible.builtin.shell: | ansible.builtin.command:
set -eo pipefail cmd: rpm -q java-21-openjdk-headless
ps -ef | grep 'etc/alternatives/.*21' | grep -v grep
changed_when: false changed_when: false
- name: Verify token api call - name: Verify token api call
@@ -28,5 +27,5 @@
validate_certs: no validate_certs: no
register: keycloak_auth_response register: keycloak_auth_response
until: keycloak_auth_response.status == 200 until: keycloak_auth_response.status == 200
retries: 2 retries: 45
delay: 2 delay: 5

View File

@@ -7,6 +7,8 @@ collections:
- name: ansible.posix - name: ansible.posix
- name: community.docker - name: community.docker
version: ">=3.8.0" version: ">=3.8.0"
- name: containers.podman
version: ">=1.8.1"
roles: roles:
- name: elan.simple_nginx_reverse_proxy - name: elan.simple_nginx_reverse_proxy

View File

@@ -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

View File

@@ -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

View File

@@ -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_admin_password: "remembertochangeme"
keycloak_realm: TestRealm
keycloak_client_default_roles:
- TestRoleAdmin
- TestRoleUser
keycloak_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_clients:
- name: TestClient1
client_id: TestClient1
roles: "{{ keycloak_client_default_roles }}"
realm: TestRealm
public_client: true
web_origins:
- http://testclient1origin/application
- http://testclient1origin/other
users: "{{ keycloak_client_users }}"

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Eike Frost <ei@kefro.st>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
class ModuleDocFragment(object):
DOCUMENTATION = r'''
options: {}
attributes:
action_group:
description: Use C(group/middleware_automation.keycloak.keycloak) in C(module_defaults) to set defaults for this module.
support: full
membership:
- middleware_automation.keycloak.keycloak
'''

View File

@@ -55,7 +55,11 @@ options:
description: description:
- Authentication token for Keycloak API. - Authentication token for Keycloak API.
type: str type: str
version_added: 3.0.0
refresh_token:
description:
- Authentication refresh token for Keycloak API.
type: str
validate_certs: validate_certs:
description: description:
@@ -68,11 +72,9 @@ options:
- Controls the HTTP connections timeout period (in seconds) to Keycloak API. - Controls the HTTP connections timeout period (in seconds) to Keycloak API.
type: int type: int
default: 10 default: 10
version_added: 4.5.0
http_agent: http_agent:
description: description:
- Configures the HTTP User-Agent header. - Configures the HTTP User-Agent header.
type: str type: str
default: Ansible default: Ansible
version_added: 5.4.0
''' '''

View File

@@ -0,0 +1,32 @@
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import typing as t
def merge_settings_without_absent_nulls(
existing_settings: dict[str, t.Any], desired_settings: dict[str, t.Any]
) -> dict[str, t.Any]:
"""
Merges existing and desired settings into a new dictionary while excluding null values in desired settings that are absent in the existing settings.
This ensures idempotency by treating absent keys in existing settings and null values in desired settings as equivalent, preventing unnecessary updates.
Args:
existing_settings (dict): Dictionary representing the current settings in Keycloak
desired_settings (dict): Dictionary representing the desired settings
Returns:
dict: A new dictionary containing all entries from existing_settings and desired_settings,
excluding null values in desired_settings whose corresponding keys are not present in existing_settings
"""
existing = existing_settings or {}
desired = desired_settings or {}
return {**existing, **{k: v for k, v in desired.items() if v is not None or k in existing}}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
# Copyright (c) 2022, John Cant <a.johncant@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
import typing as t
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import (
KeycloakAPI,
keycloak_argument_spec,
)
def keycloak_clientsecret_module() -> AnsibleModule:
"""
Returns an AnsibleModule definition for modules that interact with a client
secret.
:return: argument_spec dict
"""
argument_spec = keycloak_argument_spec()
meta_args = dict(
realm=dict(default="master"),
id=dict(type="str"),
client_id=dict(type="str", aliases=["clientId"]),
)
argument_spec.update(meta_args)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=(
[
["id", "client_id"],
["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"],
]
),
required_together=([["auth_username", "auth_password"]]),
mutually_exclusive=[["token", "auth_realm"], ["token", "auth_username"], ["token", "auth_password"]],
)
return module
def keycloak_clientsecret_module_resolve_params(module: AnsibleModule, kc: KeycloakAPI) -> tuple[str, dict[str, t.Any]]:
"""
Given an AnsibleModule definition for keycloak_clientsecret_*, and a
KeycloakAPI client, resolve the params needed to interact with the Keycloak
client secret, looking up the client by clientId if necessary via an API
call.
:return: tuple of id, realm
"""
realm = module.params.get("realm")
id = module.params.get("id")
client_id = module.params.get("client_id")
# only lookup the client_id if id isn't provided.
# in the case that both are provided, prefer the ID, since it is one
# less lookup.
if id is None:
# Due to the required_one_of spec, client_id is guaranteed to not be None
client = kc.get_client_by_clientid(client_id, realm=realm)
if client is None:
module.fail_json(msg=f"Client does not exist {client_id}")
id = client["id"]
return id, realm

View File

@@ -0,0 +1,518 @@
# Copyright (c) 2019, INSPQ <philippe.gauthier@inspq.qc.ca>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_authentication
short_description: Configure authentication in Keycloak
description:
- This module actually can only make a copy of an existing authentication flow, add an execution to it and configure it.
- It can also delete the flow.
# 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()

View File

@@ -0,0 +1,299 @@
#!/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
__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()

View File

@@ -0,0 +1,463 @@
# Copyright (c) 2017, Eike Frost <ei@kefro.st>
# Copyright (c) 2021, Christophe Gilles <christophe.gilles54@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
# https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_authentication_required_actions
short_description: Allows administration of Keycloak authentication required actions
description:
- This module can register, update and delete required actions.
- It also filters out any duplicate required actions by their alias. The first occurrence is preserved.
# 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()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,280 @@
# Copyright (c) 2017, Eike Frost <ei@kefro.st>
# Copyright (c) 2021, Christophe Gilles <christophe.gilles54@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
# https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_authz_authorization_scope
short_description: Allows administration of Keycloak client authorization scopes using Keycloak API
# 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()

View File

@@ -0,0 +1,213 @@
# Copyright (c) 2017, Eike Frost <ei@kefro.st>
# Copyright (c) 2021, Christophe Gilles <christophe.gilles54@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
# https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_authz_custom_policy
short_description: Allows administration of Keycloak client custom Javascript policies using Keycloak API
# 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()

View File

@@ -0,0 +1,443 @@
# Copyright (c) 2017, Eike Frost <ei@kefro.st>
# Copyright (c) 2021, Christophe Gilles <christophe.gilles54@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
# https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_authz_permission
# 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()

View File

@@ -0,0 +1,178 @@
# Copyright (c) 2017, Eike Frost <ei@kefro.st>
# Copyright (c) 2021, Christophe Gilles <christophe.gilles54@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
# https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_authz_permission_info
# 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()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,414 @@
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_client_rolemapping
short_description: Allows administration of Keycloak client_rolemapping with the Keycloak API
# 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()

View File

@@ -0,0 +1,287 @@
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_client_rolescope
short_description: Allows administration of Keycloak client roles scope to restrict the usage of certain roles to a other
specific client applications
# 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(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'
client_id:
type: str
required: true
description:
- Roles provided in O(role_names) while be added to this client scope.
client_scope_id:
type: str
description:
- If the O(role_names) are client role, the client ID under which it resides.
- If this parameter is absent, the roles are considered a realm role.
role_names:
required: true
type: list
elements: str
description:
- Names of roles to manipulate.
- If O(client_scope_id) is present, all roles must be under this client.
- If O(client_scope_id) is absent, all roles must be under the realm.
extends_documentation_fragment:
- middleware_automation.keycloak.keycloak
- middleware_automation.keycloak.actiongroup_keycloak
- middleware_automation.keycloak.attributes
author:
- Andre Desrosiers (@desand01)
"""
EXAMPLES = r"""
- name: Add roles to public client scope
middleware_automation.keycloak.keycloak_client_rolescope:
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
realm: MyCustomRealm
client_id: frontend-client-public
client_scope_id: backend-client-private
role_names:
- backend-role-admin
- backend-role-user
- name: Remove roles from public client scope
middleware_automation.keycloak.keycloak_client_rolescope:
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
realm: MyCustomRealm
client_id: frontend-client-public
client_scope_id: backend-client-private
role_names:
- backend-role-admin
state: absent
- name: Add realm roles to public client scope
middleware_automation.keycloak.keycloak_client_rolescope:
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
realm: MyCustomRealm
client_id: frontend-client-public
role_names:
- realm-role-admin
- realm-role-user
"""
RETURN = r"""
msg:
description: Message as to what action was taken.
returned: always
type: str
sample: "Client role scope for frontend-client-public has been updated"
end_state:
description: Representation of role role scope after module execution.
returned: on success
type: list
elements: dict
sample:
[
{
"clientRole": false,
"composite": false,
"containerId": "MyCustomRealm",
"id": "47293104-59a6-46f0-b460-2e9e3c9c424c",
"name": "backend-role-admin"
},
{
"clientRole": false,
"composite": false,
"containerId": "MyCustomRealm",
"id": "39c62a6d-542c-4715-92d2-41021eb33967",
"name": "backend-role-user"
}
]
"""
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import (
KeycloakAPI,
KeycloakError,
get_token,
keycloak_argument_spec,
)
def main():
"""
Module execution
:return:
"""
argument_spec = keycloak_argument_spec()
meta_args = dict(
client_id=dict(type="str", required=True),
client_scope_id=dict(type="str"),
realm=dict(type="str", default="master"),
role_names=dict(type="list", elements="str", required=True),
state=dict(type="str", default="present", choices=["present", "absent"]),
)
argument_spec.update(meta_args)
module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True)
result = dict(changed=False, msg="", diff={}, end_state={})
# Obtain access token, initialize API
try:
connection_header = get_token(module.params)
except KeycloakError as e:
module.fail_json(msg=str(e))
kc = KeycloakAPI(module, connection_header)
realm = module.params.get("realm")
clientid = module.params.get("client_id")
client_scope_id = module.params.get("client_scope_id")
role_names = module.params.get("role_names")
state = module.params.get("state")
objRealm = kc.get_realm_by_id(realm)
if not objRealm:
module.fail_json(msg=f"Failed to retrive realm '{realm}'")
objClient = kc.get_client_by_clientid(clientid, realm)
if not objClient:
module.fail_json(msg=f"Failed to retrive client '{realm}.{clientid}'")
if objClient["fullScopeAllowed"] and state == "present":
module.fail_json(msg=f"FullScopeAllowed is active for Client '{realm}.{clientid}'")
if client_scope_id:
objClientScope = kc.get_client_by_clientid(client_scope_id, realm)
if not objClientScope:
module.fail_json(msg=f"Failed to retrive client '{realm}.{client_scope_id}'")
before_role_mapping = kc.get_client_role_scope_from_client(objClient["id"], objClientScope["id"], realm)
else:
before_role_mapping = kc.get_client_role_scope_from_realm(objClient["id"], realm)
if client_scope_id:
# retrive all role from client_scope
client_scope_roles_by_name = kc.get_client_roles_by_id(objClientScope["id"], realm)
else:
# retrive all role from realm
client_scope_roles_by_name = kc.get_realm_roles(realm)
# convert to indexed Dict by name
client_scope_roles_by_name = {role["name"]: role for role in client_scope_roles_by_name}
role_mapping_by_name = {role["name"]: role for role in before_role_mapping}
role_mapping_to_manipulate = []
if state == "present":
# update desired
for role_name in role_names:
if role_name not in client_scope_roles_by_name:
if client_scope_id:
module.fail_json(msg=f"Failed to retrive role '{realm}.{client_scope_id}.{role_name}'")
else:
module.fail_json(msg=f"Failed to retrive role '{realm}.{role_name}'")
if role_name not in role_mapping_by_name:
role_mapping_to_manipulate.append(client_scope_roles_by_name[role_name])
role_mapping_by_name[role_name] = client_scope_roles_by_name[role_name]
else:
# remove role if present
for role_name in role_names:
if role_name in role_mapping_by_name:
role_mapping_to_manipulate.append(role_mapping_by_name[role_name])
del role_mapping_by_name[role_name]
before_role_mapping = sorted(before_role_mapping, key=lambda d: d["name"])
desired_role_mapping = sorted(role_mapping_by_name.values(), key=lambda d: d["name"])
result["changed"] = len(role_mapping_to_manipulate) > 0
if result["changed"]:
result["diff"] = dict(before=before_role_mapping, after=desired_role_mapping)
if not result["changed"]:
# no changes
result["end_state"] = before_role_mapping
result["msg"] = f"No changes required for client role scope {clientid}."
elif state == "present":
# doing update
if module.check_mode:
result["end_state"] = desired_role_mapping
elif client_scope_id:
result["end_state"] = kc.update_client_role_scope_from_client(
role_mapping_to_manipulate, objClient["id"], objClientScope["id"], realm
)
else:
result["end_state"] = kc.update_client_role_scope_from_realm(
role_mapping_to_manipulate, objClient["id"], realm
)
result["msg"] = f"Client role scope for {clientid} has been updated"
else:
# doing delete
if module.check_mode:
result["end_state"] = desired_role_mapping
elif client_scope_id:
result["end_state"] = kc.delete_client_role_scope_from_client(
role_mapping_to_manipulate, objClient["id"], objClientScope["id"], realm
)
else:
result["end_state"] = kc.delete_client_role_scope_from_realm(
role_mapping_to_manipulate, objClient["id"], realm
)
result["msg"] = f"Client role scope for {clientid} has been deleted"
module.exit_json(**result)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,328 @@
#!/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
__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_clientscope_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_clientscope(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_clientscope(scope_rep, realm=realm)
after_scope = kc.get_clientscope_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_clientscope_protocolmapper(after_scope['id'], mapper_rep, realm=realm)
after_scope = kc.get_clientscope_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_clientscope(scope_rep, realm=realm)
if protocol_mappers:
existing_mappers = kc.get_clientscope_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_clientscope_protocolmapper(before_scope['id'], mapper_rep, realm=realm)
after_scope = kc.get_clientscope_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()

View File

@@ -0,0 +1,282 @@
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_clientscope_rolemappings
short_description: Allows administration of Keycloak clientscope scope mappings to restrict the usage of certain roles to
specific clientscopes
# Originally added in community.general 13.1.0
version_added: "3.0.0"
description:
- This module allows you to add or remove Keycloak roles from clientscopes using the Keycloak REST API. It requires access
to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights.
In a default Keycloak installation, C(admin-cli) and an admin user would work, as would a separate client definition with
the scope tailored to your needs and a user having the expected roles.
- Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way
by this module. You may pass single values for attributes when calling the module, and this is translated into a list
suitable for the API.
attributes:
check_mode:
support: full
diff_mode:
support: full
action_group:
# Originally added in community.general 13.1.0
version_added: "3.0.0"
options:
state:
description:
- State of the role mapping.
- On V(present), all roles in O(role_names) are mapped if not exist yet.
- On V(absent), all roles mapping in O(role_names) are removed if they exist.
default: 'present'
type: str
choices:
- present
- absent
realm:
type: str
description:
- The Keycloak realm under which clients resides.
default: 'master'
clientscope_id:
required: true
type: str
description:
- Roles provided in O(role_names) will be added to this clientscope.
client_id:
type: str
description:
- If the O(role_names) are client roles, the client ID under which it resides.
- If this parameter is absent, the roles are considered realm roles.
role_names:
required: true
type: list
elements: str
description:
- Names of roles to add.
- If O(client_id) is present, all roles must be under this client.
- If O(client_id) is absent, all roles must be under the realm.
extends_documentation_fragment:
- middleware_automation.keycloak.keycloak
- middleware_automation.keycloak.actiongroup_keycloak
- middleware_automation.keycloak.attributes
author:
- Felix Grzelka (@felix-grzelka)
# This module was adapted from keycloak_client_rolescope, which was written by Andre Desrosiers (@desand01).
"""
EXAMPLES = r"""
- name: Add roles to clientscope
middleware_automation.keycloak.keycloak_clientscope_rolemappings:
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
realm: MyCustomRealm
client_id: frontend-client-public
clientscope_id: frontend-clientscope
role_names:
- backend-role-admin
- backend-role-user
- name: Remove roles from clientscope
middleware_automation.keycloak.keycloak_clientscope_rolemappings:
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
realm: MyCustomRealm
client_id: frontend-client-public
clientscope_id: frontend-clientscope
role_names:
- backend-role-admin
state: absent
- name: Add realm roles to clientscope
middleware_automation.keycloak.keycloak_clientscope_rolemappings:
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
realm: MyCustomRealm
clientscope_id: frontend-clientscope
role_names:
- realm-role-admin
- realm-role-user
"""
RETURN = r"""
end_state:
description: Representation of clientscope scope mappings after module execution.
returned: on success
type: list
elements: dict
sample:
[
{
"clientRole": false,
"composite": false,
"containerId": "77f9bd4e-13a6-451e-9c72-ee6997299c1f",
"description": "User role",
"id": "9e155ef7-86f5-4def-b507-581ce7b87013",
"name": "realm-role-user"
},
{
"clientRole": false,
"composite": false,
"containerId": "77f9bd4e-13a6-451e-9c72-ee6997299c1f",
"description": "Admin role",
"id": "9e155ef7-86f5-4def-b507-581ce7b87013",
"name": "realm-role-admin"
}
]
"""
import copy
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import (
KeycloakAPI,
KeycloakError,
get_token,
keycloak_argument_spec,
)
def main():
argument_spec = keycloak_argument_spec()
meta_args = dict(
client_id=dict(type="str"),
clientscope_id=dict(type="str", required=True),
realm=dict(type="str", default="master"),
role_names=dict(type="list", elements="str", required=True),
state=dict(type="str", default="present", choices=["present", "absent"]),
)
argument_spec.update(meta_args)
module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True)
result = dict(changed=False, msg="", diff={}, end_state={})
# Obtain access token, initialize API
try:
connection_header = get_token(module.params)
except KeycloakError as e:
module.fail_json(msg=str(e))
kc = KeycloakAPI(module, connection_header)
realm = module.params["realm"]
client_id = module.params["client_id"]
clientscope_id = module.params["clientscope_id"]
role_names = module.params["role_names"]
state = module.params["state"]
realm_object = kc.get_realm_by_id(realm)
if not realm_object:
module.fail_json(msg=f"Failed to retrieve realm '{realm}'")
clientscope_object = kc.get_clientscope_by_name(clientscope_id, realm)
if not clientscope_object:
module.fail_json(msg=f"Failed to retrieve client-scope '{clientscope_id}'")
if client_id:
# add client role
client_object = kc.get_client_by_clientid(client_id, realm)
if not client_object:
module.fail_json(msg=f"Failed to retrieve client '{realm}.{client_id}'")
if client_object["fullScopeAllowed"] and state == "present":
module.fail_json(msg=f"FullScopeAllowed is active for Client '{realm}.{client_id}'")
before_roles = kc.get_clientscope_scope_mappings_client(clientscope_object["id"], client_object["id"], realm)
available_roles_by_name = kc.get_client_roles_by_id(client_object["id"], realm)
else:
# add realm role
before_roles = kc.get_clientscope_scope_mappings_realm(clientscope_object["id"], realm)
available_roles_by_name = kc.get_realm_roles(realm)
# convert to indexed Dict by name
available_roles_by_name = {role["name"]: role for role in available_roles_by_name}
before_roles_by_name = {role["name"]: role for role in before_roles}
desired_roles = copy.deepcopy(before_roles)
changed_roles = []
if state == "present":
# update desired
for role_name in role_names:
if role_name not in available_roles_by_name:
if client_id:
module.fail_json(msg=f"Failed to retrieve role '{realm}.{client_id}.{role_name}'")
else:
module.fail_json(msg=f"Failed to retrieve role '{realm}.{role_name}'")
if role_name not in before_roles_by_name:
changed_roles.append(available_roles_by_name[role_name])
desired_roles.append(available_roles_by_name[role_name])
else:
# remove role if present
for role_name in role_names:
if role_name in before_roles_by_name:
changed_roles.append(before_roles_by_name[role_name])
desired_roles.remove(available_roles_by_name[role_name])
before_roles = sorted(before_roles, key=lambda d: d["name"])
desired_role_mapping = sorted(desired_roles, key=lambda d: d["name"])
result["changed"] = bool(changed_roles)
if module._diff:
result["diff"] = dict(before={"roles": before_roles}, after={"roles": desired_role_mapping})
if not result["changed"]:
# no changes
result["end_state"] = before_roles
result["msg"] = f"No changes required for clientscope {clientscope_id}."
elif state == "present":
# doing update
if module.check_mode:
result["end_state"] = desired_role_mapping
elif client_id:
result["end_state"] = kc.update_clientscope_scope_mappings_client(
changed_roles, clientscope_object["id"], client_object["id"], realm
)
else:
result["end_state"] = kc.update_clientscope_scope_mappings_realm(
changed_roles, clientscope_object["id"], realm
)
result["msg"] = f"Clientscope scope mappings for {clientscope_id} have been updated"
else:
# doing delete
if module.check_mode:
result["end_state"] = desired_role_mapping
elif client_id:
result["end_state"] = kc.delete_clientscope_scope_mappings_client(
changed_roles, clientscope_object["id"], client_object["id"], realm
)
else:
result["end_state"] = kc.delete_clientscope_scope_mappings_realm(
changed_roles, clientscope_object["id"], realm
)
result["msg"] = f"Clientscope scope mappings for {clientscope_id} have been deleted"
module.exit_json(**result)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,315 @@
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_clientscope_type
short_description: Set the type of aclientscope in realm or client using Keycloak API
# 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 clientscopes using the Keycloak REST API. It requires access
to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights.
In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with
the scope tailored to your needs and a user having the expected roles.
attributes:
check_mode:
support: full
diff_mode:
support: full
action_group:
# 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 clientscope types are set as a default for the realm.
aliases:
- clientId
type: str
default_clientscopes:
description:
- Client scopes that should be of type default.
type: list
elements: str
optional_clientscopes:
description:
- Client scopes that should be of type optional.
type: list
elements: str
extends_documentation_fragment:
- middleware_automation.keycloak.keycloak
- middleware_automation.keycloak.actiongroup_keycloak
- middleware_automation.keycloak.attributes
author:
- Simon Pahl (@simonpahl)
"""
EXAMPLES = r"""
- name: Set default client scopes on realm level
middleware_automation.keycloak.keycloak_clientscope_type:
auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
realm: "MyCustomRealm"
default_clientscopes: ['profile', 'roles']
delegate_to: localhost
- name: Set default and optional client scopes on client level with token auth
middleware_automation.keycloak.keycloak_clientscope_type:
auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com
token: TOKEN
realm: "MyCustomRealm"
client_id: "MyCustomClient"
default_clientscopes: ['profile', 'roles']
optional_clientscopes: ['phone']
delegate_to: localhost
"""
RETURN = r"""
msg:
description: Message as to what action was taken.
returned: always
type: str
sample: ""
proposed:
description: Representation of proposed client-scope types mapping.
returned: always
type: dict
sample:
{
"default_clientscopes": [
"profile",
"role"
],
"optional_clientscopes": []
}
existing:
description:
- Representation of client scopes before module execution.
returned: always
type: dict
sample:
{
"default_clientscopes": [
"profile",
"role"
],
"optional_clientscopes": [
"phone"
]
}
end_state:
description:
- Representation of client scopes after module execution.
- The sample is truncated.
returned: on success
type: dict
sample:
{
"default_clientscopes": [
"profile",
"role"
],
"optional_clientscopes": []
}
"""
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import (
KeycloakAPI,
KeycloakError,
get_token,
keycloak_argument_spec,
)
def keycloak_clientscope_type_module():
"""
Returns an AnsibleModule definition.
:return: argument_spec dict
"""
argument_spec = keycloak_argument_spec()
meta_args = dict(
realm=dict(default="master"),
client_id=dict(type="str", aliases=["clientId"]),
default_clientscopes=dict(type="list", elements="str"),
optional_clientscopes=dict(type="list", elements="str"),
)
argument_spec.update(meta_args)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=(
[
["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"],
["default_clientscopes", "optional_clientscopes"],
]
),
required_together=([["auth_username", "auth_password"]]),
required_by={"refresh_token": "auth_realm"},
mutually_exclusive=[["token", "auth_realm"], ["token", "auth_username"], ["token", "auth_password"]],
)
return module
def clientscopes_to_add(existing, proposed):
to_add = []
existing_clientscope_ids = extract_field(existing, "id")
for clientscope in proposed:
if clientscope["id"] not in existing_clientscope_ids:
to_add.append(clientscope)
return to_add
def clientscopes_to_delete(existing, proposed):
to_delete = []
proposed_clientscope_ids = extract_field(proposed, "id")
for clientscope in existing:
if clientscope["id"] not in proposed_clientscope_ids:
to_delete.append(clientscope)
return to_delete
def extract_field(dictionary, field="name"):
return [cs[field] for cs in dictionary]
def normalize_scopes(scopes):
scopes_copy = scopes.copy()
if isinstance(scopes_copy.get("default_clientscopes"), list):
scopes_copy["default_clientscopes"] = sorted(scopes_copy["default_clientscopes"])
if isinstance(scopes_copy.get("optional_clientscopes"), list):
scopes_copy["optional_clientscopes"] = sorted(scopes_copy["optional_clientscopes"])
return scopes_copy
def main():
"""
Module keycloak_clientscope_type
:return:
"""
module = keycloak_clientscope_type_module()
# Obtain access token, initialize API
try:
connection_header = get_token(module.params)
except KeycloakError as e:
module.fail_json(msg=str(e))
kc = KeycloakAPI(module, connection_header)
realm = module.params.get("realm")
client_id = module.params.get("client_id")
default_clientscopes = module.params.get("default_clientscopes")
optional_clientscopes = module.params.get("optional_clientscopes")
result = dict(changed=False, msg="", proposed={}, existing={}, end_state={})
all_clientscopes = kc.get_clientscopes(realm)
default_clientscopes_real = []
optional_clientscopes_real = []
for client_scope in all_clientscopes:
if default_clientscopes is not None and client_scope["name"] in default_clientscopes:
default_clientscopes_real.append(client_scope)
if optional_clientscopes is not None and client_scope["name"] in optional_clientscopes:
optional_clientscopes_real.append(client_scope)
if default_clientscopes is not None and len(default_clientscopes_real) != len(default_clientscopes):
module.fail_json(msg="At least one of the default_clientscopes does not exist!")
if optional_clientscopes is not None and len(optional_clientscopes_real) != len(optional_clientscopes):
module.fail_json(msg="At least one of the optional_clientscopes does not exist!")
result["proposed"].update(
{
"default_clientscopes": "no-change" if default_clientscopes is None else default_clientscopes,
"optional_clientscopes": "no-change" if optional_clientscopes is None else optional_clientscopes,
}
)
default_clientscopes_existing = kc.get_default_clientscopes(realm, client_id)
optional_clientscopes_existing = kc.get_optional_clientscopes(realm, client_id)
result["existing"].update(
{
"default_clientscopes": extract_field(default_clientscopes_existing),
"optional_clientscopes": extract_field(optional_clientscopes_existing),
}
)
if module._diff:
result["diff"] = dict(before=normalize_scopes(result["existing"]), after=normalize_scopes(result["proposed"]))
default_clientscopes_add = clientscopes_to_add(default_clientscopes_existing, default_clientscopes_real)
optional_clientscopes_add = clientscopes_to_add(optional_clientscopes_existing, optional_clientscopes_real)
default_clientscopes_delete = clientscopes_to_delete(default_clientscopes_existing, default_clientscopes_real)
optional_clientscopes_delete = clientscopes_to_delete(optional_clientscopes_existing, optional_clientscopes_real)
result["changed"] = any(
len(x) > 0
for x in [
default_clientscopes_add,
optional_clientscopes_add,
default_clientscopes_delete,
optional_clientscopes_delete,
]
)
if module.check_mode:
module.exit_json(**result)
# first delete so clientscopes can change type
for clientscope in default_clientscopes_delete:
kc.delete_default_clientscope(clientscope["id"], realm, client_id)
for clientscope in optional_clientscopes_delete:
kc.delete_optional_clientscope(clientscope["id"], realm, client_id)
for clientscope in default_clientscopes_add:
kc.add_default_clientscope(clientscope["id"], realm, client_id)
for clientscope in optional_clientscopes_add:
kc.add_optional_clientscope(clientscope["id"], realm, client_id)
result["end_state"].update(
{
"default_clientscopes": extract_field(kc.get_default_clientscopes(realm, client_id)),
"optional_clientscopes": extract_field(kc.get_optional_clientscopes(realm, client_id)),
}
)
module.exit_json(**result)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,168 @@
# Copyright (c) 2022, Fynn Chen <ethan.cfchen@gmail.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_clientsecret_info
short_description: Retrieve client secret using Keycloak API
# 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()

View File

@@ -0,0 +1,178 @@
# Copyright (c) 2022, Fynn Chen <ethan.cfchen@gmail.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_clientsecret_regenerate
short_description: Regenerate Keycloak client secret using Keycloak API
# 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()

View File

@@ -0,0 +1,472 @@
# Copyright (c) 2017, Eike Frost <ei@kefro.st>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_clienttemplate
short_description: Allows administration of Keycloak client templates using Keycloak API
version_added: "3.0.0"
description:
- This module allows the administration of Keycloak client templates using the Keycloak REST API. It requires access to
the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights.
In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with
the scope tailored to your needs and a user having the expected roles.
- The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation
at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html).
- The Keycloak API does not always enforce for only sensible settings to be used -- you can set SAML-specific settings on
an OpenID Connect client for instance and the other way around. Be careful. If you do not specify a setting, usually a
sensible default is chosen.
attributes:
check_mode:
support: full
diff_mode:
support: full
action_group:
# 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()

View File

@@ -0,0 +1,328 @@
# Copyright (c) 2024, Björn Bösel <bjoernboesel@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
# https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_component
short_description: Allows administration of Keycloak components using Keycloak API
# 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()

View File

@@ -0,0 +1,171 @@
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_component_info
short_description: Retrieve component info in Keycloak
# 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()

View File

@@ -0,0 +1,493 @@
# Copyright (c) 2019, Adam Goossens <adam.goossens@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_group
short_description: Allows administration of Keycloak groups using Keycloak API
version_added: "3.0.0"
description:
- This module allows you to add, remove or modify Keycloak groups using the Keycloak REST API. It requires access to the
REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. In
a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with the
scope tailored to your needs and a user having the expected roles.
- The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation
at U(https://www.keycloak.org/docs-api/20.0.2/rest-api/index.html).
- Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way
by this module. You may pass single values for attributes when calling the module, and this is translated into a list
suitable for the API.
- When updating a group, where possible provide the group ID to the module. This removes a lookup to the API to translate
the name into the group ID.
attributes:
check_mode:
support: full
diff_mode:
support: full
action_group:
# 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()

View File

@@ -0,0 +1,777 @@
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_identity_provider
short_description: Allows administration of Keycloak identity providers using Keycloak API
# 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
provider_id:
description:
- Protocol used by this provider (supported values are V(oidc) or V(saml)).
aliases:
- providerId
type: str
config:
description:
- Dict specifying the configuration options for the provider; the contents differ depending on the value of O(provider_id).
Examples are given below for V(oidc) and V(saml). It is easiest to obtain valid config values by dumping an already-existing
identity provider configuration through check-mode in the RV(existing) field.
type: dict
suboptions:
hide_on_login_page:
description:
- If hidden, login with this provider is possible only if requested explicitly, for example using the C(kc_idp_hint)
parameter.
aliases:
- hideOnLoginPage
type: bool
gui_order:
description:
- Number defining order of the provider in GUI (for example, on Login page).
aliases:
- guiOrder
type: int
sync_mode:
description:
- Default sync mode for all mappers. The sync mode determines when user data is synced using the mappers.
aliases:
- syncMode
type: str
issuer:
description:
- The issuer identifier for the issuer of the response. If not provided, no validation is performed.
type: str
authorizationUrl:
description:
- The Authorization URL.
type: str
tokenUrl:
description:
- The Token URL.
type: str
logoutUrl:
description:
- End session endpoint to use to logout user from external IDP.
type: str
userInfoUrl:
description:
- The User Info URL.
type: str
clientAuthMethod:
description:
- The client authentication method.
type: str
clientId:
description:
- The client or client identifier registered within the identity provider.
type: str
clientSecret:
description:
- The client or client secret registered within the identity provider.
type: str
defaultScope:
description:
- The scopes to be sent when asking for authorization.
type: str
validateSignature:
description:
- Enable/disable signature validation of external IDP signatures.
type: bool
useJwksUrl:
description:
- If V(true), identity provider public keys are downloaded from given JWKS URL.
type: bool
jwksUrl:
description:
- URL where identity provider keys in JWK format are stored. See JWK specification for more details.
type: str
entityId:
description:
- The Entity ID that is used to uniquely identify this SAML Service Provider.
type: str
singleSignOnServiceUrl:
description:
- The URL that must be used to send authentication requests (SAML AuthnRequest).
type: str
singleLogoutServiceUrl:
description:
- The URL that must be used to send logout requests.
type: str
backchannelSupported:
description:
- Does the external IDP support backchannel logout?
type: str
nameIDPolicyFormat:
description:
- Specifies the URI reference corresponding to a name identifier format.
type: str
principalType:
description:
- Way to identify and track external users from the assertion.
type: str
fromUrl:
description:
- IDP well-known OpenID Connect configuration URL.
- Support only O(provider_id=oidc).
- O(config.fromUrl) is mutually exclusive with O(config.userInfoUrl), O(config.authorizationUrl),
O(config.tokenUrl), O(config.logoutUrl), O(config.issuer) and O(config.jwksUrl).
type: str
mappers:
description:
- A list of dicts defining mappers associated with this Identity Provider.
type: list
elements: dict
suboptions:
id:
description:
- Unique ID of this mapper.
type: str
name:
description:
- Name of the mapper.
type: str
identityProviderAlias:
description:
- Alias of the identity provider for this mapper.
type: str
identityProviderMapper:
description:
- Type of mapper.
type: str
config:
description:
- Dict specifying the configuration options for the mapper; the contents differ depending on the value of O(mappers[].identityProviderMapper).
type: dict
extends_documentation_fragment:
- middleware_automation.keycloak.keycloak
- middleware_automation.keycloak.actiongroup_keycloak
- middleware_automation.keycloak.attributes
author:
- Laurent Paumier (@laurpaum)
"""
EXAMPLES = r"""
- name: Create OIDC identity provider, authentication with credentials
middleware_automation.keycloak.keycloak_identity_provider:
state: present
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: admin
auth_password: admin
realm: myrealm
alias: oidc-idp
display_name: OpenID Connect IdP
enabled: true
provider_id: oidc
config:
issuer: https://idp.example.com
authorizationUrl: https://idp.example.com
tokenUrl: https://idp.example.com/token
userInfoUrl: https://idp.example.com/userinfo
clientAuthMethod: client_secret_post
clientId: my-client
clientSecret: secret
syncMode: FORCE
mappers:
- name: first_name
identityProviderMapper: oidc-user-attribute-idp-mapper
config:
claim: first_name
user.attribute: first_name
syncMode: INHERIT
- name: last_name
identityProviderMapper: oidc-user-attribute-idp-mapper
config:
claim: last_name
user.attribute: last_name
syncMode: INHERIT
- name: Create OIDC identity provider, with well-known configuration URL
middleware_automation.keycloak.keycloak_identity_provider:
state: present
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: admin
auth_password: admin
realm: myrealm
alias: oidc-idp
display_name: OpenID Connect IdP
enabled: true
provider_id: oidc
config:
fromUrl: https://the-idp.example.com/realms/idprealm/.well-known/openid-configuration
clientAuthMethod: client_secret_post
clientId: my-client
clientSecret: secret
- name: Create SAML identity provider, authentication with credentials
middleware_automation.keycloak.keycloak_identity_provider:
state: present
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: admin
auth_password: admin
realm: myrealm
alias: saml-idp
display_name: SAML IdP
enabled: true
provider_id: saml
config:
entityId: https://auth.example.com/realms/myrealm
singleSignOnServiceUrl: https://idp.example.com/login
wantAuthnRequestsSigned: true
wantAssertionsSigned: true
mappers:
- name: roles
identityProviderMapper: saml-user-attribute-idp-mapper
config:
user.attribute: roles
attribute.friendly.name: User Roles
attribute.name: roles
syncMode: INHERIT
- name: Create OIDC identity provider, authentication with credentials and advanced claim to group
middleware_automation.keycloak.keycloak_identity_provider:
state: present
auth_keycloak_url: https://auth.example.com
auth_realm: master
auth_username: admin
auth_password: admin
realm: myrealm
alias: oidc-idp
display_name: OpenID Connect IdP
enabled: true
provider_id: oidc
config:
issuer: https://idp.example.com
authorizationUrl: https://idp.example.com
tokenUrl: https://idp.example.com/token
userInfoUrl: https://idp.example.com/userinfo
clientAuthMethod: client_secret_post
clientId: my-client
clientSecret: secret
syncMode: FORCE
mappers:
- name: group_name
identityProviderMapper: oidc-advanced-group-idp-mapper
config:
claims: '[{"key":"my_key","value":"my_value"}]'
group: group_name
syncMode: INHERIT
"""
RETURN = r"""
msg:
description: Message as to what action was taken.
returned: always
type: str
sample: "Identity provider my-idp has been created"
proposed:
description: Representation of proposed identity provider.
returned: always
type: dict
sample:
{
"config": {
"authorizationUrl": "https://idp.example.com",
"clientAuthMethod": "client_secret_post",
"clientId": "my-client",
"clientSecret": "secret",
"issuer": "https://idp.example.com",
"tokenUrl": "https://idp.example.com/token",
"userInfoUrl": "https://idp.example.com/userinfo"
},
"displayName": "OpenID Connect IdP",
"providerId": "oidc"
}
existing:
description: Representation of existing identity provider.
returned: always
type: dict
sample:
{
"addReadTokenRoleOnCreate": false,
"alias": "my-idp",
"authenticateByDefault": false,
"config": {
"authorizationUrl": "https://old.example.com",
"clientAuthMethod": "client_secret_post",
"clientId": "my-client",
"clientSecret": "**********",
"issuer": "https://old.example.com",
"syncMode": "FORCE",
"tokenUrl": "https://old.example.com/token",
"userInfoUrl": "https://old.example.com/userinfo"
},
"displayName": "OpenID Connect IdP",
"enabled": true,
"firstBrokerLoginFlowAlias": "first broker login",
"internalId": "4d28d7e3-1b80-45bb-8a30-5822bf55aa1c",
"linkOnly": false,
"providerId": "oidc",
"storeToken": false,
"trustEmail": false
}
end_state:
description: Representation of identity provider after module execution.
returned: on success
type: dict
sample:
{
"addReadTokenRoleOnCreate": false,
"alias": "my-idp",
"authenticateByDefault": false,
"config": {
"authorizationUrl": "https://idp.example.com",
"clientAuthMethod": "client_secret_post",
"clientId": "my-client",
"clientSecret": "**********",
"issuer": "https://idp.example.com",
"tokenUrl": "https://idp.example.com/token",
"userInfoUrl": "https://idp.example.com/userinfo"
},
"displayName": "OpenID Connect IdP",
"enabled": true,
"firstBrokerLoginFlowAlias": "first broker login",
"internalId": "4d28d7e3-1b80-45bb-8a30-5822bf55aa1c",
"linkOnly": false,
"providerId": "oidc",
"storeToken": false,
"trustEmail": false
}
"""
from copy import deepcopy
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import (
KeycloakAPI,
KeycloakError,
camel,
get_token,
keycloak_argument_spec,
)
def sanitize(idp):
idpcopy = deepcopy(idp)
if "config" in idpcopy:
if "clientSecret" in idpcopy["config"]:
idpcopy["config"]["clientSecret"] = "**********"
return idpcopy
def get_identity_provider_with_mappers(kc, alias, realm):
idp = kc.get_identity_provider(alias, realm)
if idp is not None:
idp["mappers"] = sorted(kc.get_identity_provider_mappers(alias, realm), key=lambda x: x.get("name"))
# clientSecret returned by API when using `get_identity_provider(alias, realm)` is always **********
# to detect changes to the secret, we get the actual cleartext secret from the full realm info
if "config" in idp:
if "clientSecret" in idp["config"]:
for idp_from_realm in kc.get_realm_by_id(realm).get("identityProviders", []):
if idp_from_realm["internalId"] == idp["internalId"]:
cleartext_secret = idp_from_realm.get("config", {}).get("clientSecret")
if cleartext_secret:
idp["config"]["clientSecret"] = cleartext_secret
if idp is None:
idp = {}
return idp
def fetch_identity_provider_wellknown_config(kc, config):
"""
Fetches OpenID Connect well-known configuration from a given URL and updates the config dict with discovered endpoints.
Support for oidc providers only.
:param kc: KeycloakAPI instance used to fetch endpoints and handle errors.
:param config: Dictionary containing identity provider configuration, must include 'fromUrl' key to trigger fetch.
:return: None. The config dict is updated in-place.
"""
if config and "fromUrl" in config:
if "providerId" in config and config["providerId"] != "oidc":
kc.module.fail_json(msg="Only 'oidc' provider_id is supported when using 'fromUrl'.")
endpoints = ["userInfoUrl", "authorizationUrl", "tokenUrl", "logoutUrl", "issuer", "jwksUrl"]
if any(k in config for k in endpoints):
kc.module.fail_json(
msg="Cannot specify both 'fromUrl' and 'userInfoUrl', 'authorizationUrl', 'tokenUrl', 'logoutUrl', 'issuer' or 'jwksUrl'."
)
openIdConfig = kc.fetch_idp_endpoints_import_config_url(
fromUrl=config["fromUrl"], realm=kc.module.params.get("realm", "master")
)
for k in endpoints:
if k in openIdConfig:
config[k] = openIdConfig[k]
del config["fromUrl"]
def main():
"""
Module execution
:return:
"""
argument_spec = keycloak_argument_spec()
mapper_spec = dict(
id=dict(type="str"),
name=dict(type="str"),
identityProviderAlias=dict(type="str"),
identityProviderMapper=dict(type="str"),
config=dict(type="dict"),
)
meta_args = dict(
state=dict(type="str", default="present", choices=["present", "absent"]),
realm=dict(type="str", default="master"),
alias=dict(type="str", required=True),
add_read_token_role_on_create=dict(type="bool", aliases=["addReadTokenRoleOnCreate"]),
authenticate_by_default=dict(type="bool", aliases=["authenticateByDefault"]),
config=dict(type="dict"),
display_name=dict(type="str", aliases=["displayName"]),
enabled=dict(type="bool"),
first_broker_login_flow_alias=dict(type="str", aliases=["firstBrokerLoginFlowAlias"]),
link_only=dict(type="bool", aliases=["linkOnly"]),
post_broker_login_flow_alias=dict(type="str", aliases=["postBrokerLoginFlowAlias"]),
provider_id=dict(type="str", aliases=["providerId"]),
store_token=dict(type="bool", aliases=["storeToken"]),
trust_email=dict(type="bool", aliases=["trustEmail"]),
mappers=dict(type="list", elements="dict", options=mapper_spec),
)
argument_spec.update(meta_args)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=(
[["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]]
),
required_together=([["auth_username", "auth_password"]]),
required_by={"refresh_token": "auth_realm"},
)
result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={})
# Obtain access token, initialize API
try:
connection_header = get_token(module.params)
except KeycloakError as e:
module.fail_json(msg=str(e))
kc = KeycloakAPI(module, connection_header)
realm = module.params.get("realm")
alias = module.params.get("alias")
state = module.params.get("state")
config = module.params.get("config")
fetch_identity_provider_wellknown_config(kc, config)
# Filter and map the parameters names that apply to the identity provider.
idp_params = [
x
for x in module.params
if x not in list(keycloak_argument_spec().keys()) + ["state", "realm", "mappers"]
and module.params.get(x) is not None
]
# See if it already exists in Keycloak
before_idp = get_identity_provider_with_mappers(kc, alias, realm)
# Build a proposed changeset from parameters given to this module
changeset = {}
for param in idp_params:
new_param_value = module.params.get(param)
old_value = before_idp[camel(param)] if camel(param) in before_idp else None
if new_param_value != old_value:
changeset[camel(param)] = new_param_value
# special handling of mappers list to allow change detection
if module.params.get("mappers") is not None:
for change in module.params["mappers"]:
change = {k: v for k, v in change.items() if v is not None}
if change.get("id") is None and change.get("name") is None:
module.fail_json(msg="Either `name` or `id` has to be specified on each mapper.")
if before_idp == dict():
old_mapper = dict()
elif change.get("id") is not None:
old_mapper = kc.get_identity_provider_mapper(change["id"], alias, realm)
if old_mapper is None:
old_mapper = dict()
else:
found = [x for x in kc.get_identity_provider_mappers(alias, realm) if x["name"] == change["name"]]
if len(found) == 1:
old_mapper = found[0]
else:
old_mapper = dict()
new_mapper = old_mapper.copy()
new_mapper.update(change)
if changeset.get("mappers") is None:
changeset["mappers"] = list()
# eventually this holds all desired mappers, unchanged, modified and newly added
changeset["mappers"].append(new_mapper)
# ensure idempotency in case module.params.mappers is not sorted by name
changeset["mappers"] = sorted(
changeset["mappers"], key=lambda x: x.get("id") if x.get("name") is None else x["name"]
)
# Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis)
desired_idp = before_idp.copy()
desired_idp.update(changeset)
result["proposed"] = sanitize(changeset)
result["existing"] = sanitize(before_idp)
# Cater for when it doesn't exist (an empty dict)
if not before_idp:
if state == "absent":
# Do nothing and exit
if module._diff:
result["diff"] = dict(before="", after="")
result["changed"] = False
result["end_state"] = {}
result["msg"] = "Identity provider does not exist; doing nothing."
module.exit_json(**result)
# Process a creation
result["changed"] = True
if module._diff:
result["diff"] = dict(before="", after=sanitize(desired_idp))
if module.check_mode:
module.exit_json(**result)
# create it
desired_idp = desired_idp.copy()
mappers = desired_idp.pop("mappers", [])
kc.create_identity_provider(desired_idp, realm)
for mapper in mappers:
if mapper.get("identityProviderAlias") is None:
mapper["identityProviderAlias"] = alias
kc.create_identity_provider_mapper(mapper, alias, realm)
after_idp = get_identity_provider_with_mappers(kc, alias, realm)
result["end_state"] = sanitize(after_idp)
result["msg"] = f"Identity provider {alias} has been created"
module.exit_json(**result)
else:
if state == "present":
# Process an update
# no changes
if desired_idp == before_idp:
result["changed"] = False
result["end_state"] = sanitize(desired_idp)
result["msg"] = f"No changes required to identity provider {alias}."
module.exit_json(**result)
# doing an update
result["changed"] = True
if module._diff:
result["diff"] = dict(before=sanitize(before_idp), after=sanitize(desired_idp))
if module.check_mode:
module.exit_json(**result)
# do the update
desired_idp = desired_idp.copy()
updated_mappers = desired_idp.pop("mappers", [])
original_mappers = list(before_idp.get("mappers", []))
kc.update_identity_provider(desired_idp, realm)
for mapper in updated_mappers:
if mapper.get("id") is not None:
# only update existing if there is a change
for i, orig in enumerate(original_mappers):
if mapper["id"] == orig["id"]:
del original_mappers[i]
if mapper != orig:
kc.update_identity_provider_mapper(mapper, alias, realm)
else:
if mapper.get("identityProviderAlias") is None:
mapper["identityProviderAlias"] = alias
kc.create_identity_provider_mapper(mapper, alias, realm)
for mapper in [
x for x in before_idp["mappers"] if [y for y in updated_mappers if y["name"] == x["name"]] == []
]:
kc.delete_identity_provider_mapper(mapper["id"], alias, realm)
after_idp = get_identity_provider_with_mappers(kc, alias, realm)
result["end_state"] = sanitize(after_idp)
result["msg"] = f"Identity provider {alias} has been updated"
module.exit_json(**result)
elif state == "absent":
# Process a deletion
result["changed"] = True
if module._diff:
result["diff"] = dict(before=sanitize(before_idp), after="")
if module.check_mode:
module.exit_json(**result)
# delete it
kc.delete_identity_provider(alias, realm)
result["end_state"] = {}
result["msg"] = f"Identity provider {alias} has been deleted"
module.exit_json(**result)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_realm_info
short_description: Allows obtaining Keycloak realm public information using Keycloak API
# 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()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_realm_keys_metadata_info
short_description: Allows obtaining Keycloak realm keys metadata using Keycloak API
# 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()

View File

@@ -0,0 +1,399 @@
# !/usr/bin/python
# Copyright Jakub Danek <danek.ja@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or
# https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_realm_localization
short_description: Allows management of Keycloak realm localization overrides via the Keycloak API
# 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()

View File

@@ -0,0 +1,392 @@
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_realm_rolemapping
short_description: Allows administration of Keycloak realm role mappings into groups with the Keycloak API
# 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()

View File

@@ -1,135 +1,126 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Adam Goossens <adam.goossens@gmail.com> # Copyright (c) 2019, Adam Goossens <adam.goossens@gmail.com>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # 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 # SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function from __future__ import annotations
__metaclass__ = type
DOCUMENTATION = ''' DOCUMENTATION = r"""
---
module: keycloak_role module: keycloak_role
short_description: Allows administration of Keycloak roles via Keycloak API short_description: Allows administration of Keycloak roles using Keycloak API
version_added: 3.4.0 # Originally added in community.general 3.4.0
version_added: "3.0.0"
description: description:
- This module allows you to add, remove or modify Keycloak roles via the Keycloak REST API. - This module allows you to add, remove or modify Keycloak roles using the Keycloak REST API. It requires access to the
It requires access to the REST API via OpenID Connect; the user connecting and the client being REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. In
used must have the requisite access rights. In a default Keycloak installation, admin-cli a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with the
and an admin user would work, as would a separate client definition with the scope tailored scope tailored to your needs and a user having the expected roles.
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 names of module options are snake_cased versions of the camelCase ones found in the - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way
Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html). 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 are multi-valued in the Keycloak API. All attributes are lists of individual values and will
be returned that way by this module. You may pass single values for attributes when calling the module,
and this will be translated into a list suitable for the API.
attributes: attributes:
check_mode: check_mode:
support: full support: full
diff_mode: diff_mode:
support: full support: full
action_group:
# Originally added in community.general 10.2.0
version_added: "3.0.0"
options: options:
state: state:
description: description:
- State of the role. - State of the role.
- On V(present), the role will be created if it does not yet exist, or updated with the parameters you provide. - On V(present), the role is created if it does not yet exist, or updated with the parameters you provide.
- On V(absent), the role will be removed if it exists. - On V(absent), the role is removed if it exists.
default: 'present' default: 'present'
type: str type: str
choices: choices:
- present - present
- absent - absent
name: name:
type: str
required: true
description:
- Name of the role.
- This parameter is required.
description:
type: str
description:
- The role description.
realm:
type: str
description:
- The Keycloak realm under which this role resides.
default: 'master'
client_id:
type: str
description:
- If the role is a client role, the client ID under which it resides.
- If this parameter is absent, the role is considered a realm role.
attributes:
type: dict
description:
- A dict of key/value pairs to set as custom attributes for the role.
- Values may be single values (for example a string) or a list of strings.
composite:
description:
- If V(true), the role is a composition of other realm and/or client role.
default: false
type: bool
composites:
description:
- List of roles to include to the composite realm role.
- If the composite role is a client role, the C(clientId) (not ID of the client) must be specified.
default: []
type: list
elements: dict
suboptions:
name:
description:
- Name of the role. This can be the name of a REALM role or a client role.
type: str type: str
required: true required: true
client_id:
description: description:
- Name of the role. - Client ID if the role is a client role. Do not include this option for a REALM role.
- This parameter is required. - Use the client ID you can see in the Keycloak console, not the technical ID of the client.
description:
type: str type: str
aliases:
- clientId
state:
description: description:
- The role description. - Create the composite if present, remove it if absent.
realm:
type: str type: str
description: choices:
- The Keycloak realm under which this role resides. - present
default: 'master' - absent
default: present
client_id:
type: str
description:
- If the role is a client role, the client id under which it resides.
- If this parameter is absent, the role is considered a realm role.
attributes:
type: dict
description:
- A dict of key/value pairs to set as custom attributes for the role.
- Values may be single values (e.g. a string) or a list of strings.
composite:
description:
- If V(true), the role is a composition of other realm and/or client role.
default: false
type: bool
version_added: 7.1.0
composites:
description:
- List of roles to include to the composite realm role.
- If the composite role is a client role, the C(clientId) (not ID of the client) must be specified.
default: []
type: list
elements: dict
version_added: 7.1.0
suboptions:
name:
description:
- Name of the role. This can be the name of a REALM role or a client role.
type: str
required: true
client_id:
description:
- Client ID if the role is a client role. Do not include this option for a REALM role.
- Use the client ID you can see in the Keycloak console, not the technical ID of the client.
type: str
required: false
aliases:
- clientId
state:
description:
- Create the composite if present, remove it if absent.
type: str
choices:
- present
- absent
default: present
extends_documentation_fragment: extends_documentation_fragment:
- middleware_automation.keycloak.keycloak - middleware_automation.keycloak.keycloak
- middleware_automation.keycloak.attributes - middleware_automation.keycloak.actiongroup_keycloak
- middleware_automation.keycloak.attributes
author: author:
- Laurent Paumier (@laurpaum) - Laurent Paumier (@laurpaum)
''' """
EXAMPLES = ''' EXAMPLES = r"""
- name: Create a Keycloak realm role, authentication with credentials - name: Create a Keycloak realm role, authentication with credentials
middleware_automation.keycloak.keycloak_role: middleware_automation.keycloak.keycloak_role:
name: my-new-kc-role name: my-new-kc-role
realm: MyCustomRealm realm: MyCustomRealm
state: present state: present
auth_client_id: admin-cli auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth auth_keycloak_url: https://auth.example.com
auth_realm: master auth_realm: master
auth_username: USERNAME auth_username: USERNAME
auth_password: PASSWORD auth_password: PASSWORD
@@ -141,7 +132,7 @@ EXAMPLES = '''
realm: MyCustomRealm realm: MyCustomRealm
state: present state: present
auth_client_id: admin-cli auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth auth_keycloak_url: https://auth.example.com
token: TOKEN token: TOKEN
delegate_to: localhost delegate_to: localhost
@@ -152,7 +143,7 @@ EXAMPLES = '''
client_id: MyClient client_id: MyClient
state: present state: present
auth_client_id: admin-cli auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth auth_keycloak_url: https://auth.example.com
auth_realm: master auth_realm: master
auth_username: USERNAME auth_username: USERNAME
auth_password: PASSWORD auth_password: PASSWORD
@@ -163,7 +154,7 @@ EXAMPLES = '''
name: my-role-for-deletion name: my-role-for-deletion
state: absent state: absent
auth_client_id: admin-cli auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth auth_keycloak_url: https://auth.example.com
auth_realm: master auth_realm: master
auth_username: USERNAME auth_username: USERNAME
auth_password: PASSWORD auth_password: PASSWORD
@@ -172,72 +163,80 @@ EXAMPLES = '''
- name: Create a keycloak role with some custom attributes - name: Create a keycloak role with some custom attributes
middleware_automation.keycloak.keycloak_role: middleware_automation.keycloak.keycloak_role:
auth_client_id: admin-cli auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth auth_keycloak_url: https://auth.example.com
auth_realm: master auth_realm: master
auth_username: USERNAME auth_username: USERNAME
auth_password: PASSWORD auth_password: PASSWORD
name: my-new-role name: my-new-role
attributes: attributes:
attrib1: value1 attrib1: value1
attrib2: value2 attrib2: value2
attrib3: attrib3:
- with - with
- numerous - numerous
- individual - individual
- list - list
- items - items
delegate_to: localhost delegate_to: localhost
''' """
RETURN = ''' RETURN = r"""
msg: msg:
description: Message as to what action was taken. description: Message as to what action was taken.
returned: always returned: always
type: str type: str
sample: "Role myrole has been updated" sample: "Role myrole has been updated"
proposed: proposed:
description: Representation of proposed role. description: Representation of proposed role.
returned: always returned: always
type: dict type: dict
sample: { sample: {"description": "My updated test description"}
"description": "My updated test description"
}
existing: existing:
description: Representation of existing role. description: Representation of existing role.
returned: always returned: always
type: dict type: dict
sample: { sample:
"attributes": {}, {
"clientRole": true, "attributes": {},
"composite": false, "clientRole": true,
"containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", "composite": false,
"description": "My client test role", "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a",
"id": "561703dd-0f38-45ff-9a5a-0c978f794547", "description": "My client test role",
"name": "myrole" "id": "561703dd-0f38-45ff-9a5a-0c978f794547",
"name": "myrole"
} }
end_state: end_state:
description: Representation of role after module execution (sample is truncated). description: Representation of role after module execution (sample is truncated).
returned: on success returned: on success
type: dict type: dict
sample: { sample:
"attributes": {}, {
"clientRole": true, "attributes": {},
"composite": false, "clientRole": true,
"containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", "composite": false,
"description": "My updated client test role", "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a",
"id": "561703dd-0f38-45ff-9a5a-0c978f794547", "description": "My updated client test role",
"name": "myrole" "id": "561703dd-0f38-45ff-9a5a-0c978f794547",
"name": "myrole"
} }
''' """
from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \
keycloak_argument_spec, get_token, KeycloakError, is_struct_included
from ansible.module_utils.basic import AnsibleModule
import copy 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(): def main():
""" """
@@ -248,30 +247,35 @@ def main():
argument_spec = keycloak_argument_spec() argument_spec = keycloak_argument_spec()
composites_spec = dict( composites_spec = dict(
name=dict(type='str', required=True), name=dict(type="str", required=True),
client_id=dict(type='str', aliases=['clientId'], required=False), client_id=dict(type="str", aliases=["clientId"]),
state=dict(type='str', default='present', choices=['present', 'absent']) state=dict(type="str", default="present", choices=["present", "absent"]),
) )
meta_args = dict( meta_args = dict(
state=dict(type='str', default='present', choices=['present', 'absent']), state=dict(type="str", default="present", choices=["present", "absent"]),
name=dict(type='str', required=True), name=dict(type="str", required=True),
description=dict(type='str'), description=dict(type="str"),
realm=dict(type='str', default='master'), realm=dict(type="str", default="master"),
client_id=dict(type='str'), client_id=dict(type="str"),
attributes=dict(type='dict'), attributes=dict(type="dict"),
composites=dict(type='list', default=[], options=composites_spec, elements='dict'), composites=dict(type="list", default=[], options=composites_spec, elements="dict"),
composite=dict(type='bool', default=False), composite=dict(type="bool", default=False),
) )
argument_spec.update(meta_args) argument_spec.update(meta_args)
module = AnsibleModule(argument_spec=argument_spec, module = AnsibleModule(
supports_check_mode=True, argument_spec=argument_spec,
required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), supports_check_mode=True,
required_together=([['auth_realm', 'auth_username', 'auth_password']])) required_one_of=(
[["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]]
),
required_together=([["auth_username", "auth_password"]]),
required_by={"refresh_token": "auth_realm"},
)
result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={})
# Obtain access token, initialize API # Obtain access token, initialize API
try: try:
@@ -281,22 +285,25 @@ def main():
kc = KeycloakAPI(module, connection_header) kc = KeycloakAPI(module, connection_header)
realm = module.params.get('realm') realm = module.params.get("realm")
clientid = module.params.get('client_id') clientid = module.params.get("client_id")
name = module.params.get('name') name = module.params.get("name")
state = module.params.get('state') state = module.params.get("state")
# attributes in Keycloak have their values returned as lists # attributes in Keycloak have their values returned as lists
# via the API. attributes is a dict, so we'll transparently convert # using the API. attributes is a dict, so we'll transparently convert
# the values to lists. # the values to lists.
if module.params.get('attributes') is not None: if module.params.get("attributes") is not None:
for key, val in module.params['attributes'].items(): for key, val in module.params["attributes"].items():
module.params['attributes'][key] = [val] if not isinstance(val, list) else val module.params["attributes"][key] = [val] if not isinstance(val, list) else val
# Filter and map the parameters names that apply to the role # Filter and map the parameters names that apply to the role
role_params = [x for x in module.params role_params = [
if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'client_id'] and x
module.params.get(x) is not None] 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 # See if it already exists in Keycloak
if clientid is None: if clientid is None:
@@ -320,28 +327,28 @@ def main():
desired_role = copy.deepcopy(before_role) desired_role = copy.deepcopy(before_role)
desired_role.update(changeset) desired_role.update(changeset)
result['proposed'] = changeset result["proposed"] = changeset
result['existing'] = before_role result["existing"] = before_role
# Cater for when it doesn't exist (an empty dict) # Cater for when it doesn't exist (an empty dict)
if not before_role: if not before_role:
if state == 'absent': if state == "absent":
# Do nothing and exit # Do nothing and exit
if module._diff: if module._diff:
result['diff'] = dict(before='', after='') result["diff"] = dict(before="", after="")
result['changed'] = False result["changed"] = False
result['end_state'] = {} result["end_state"] = {}
result['msg'] = 'Role does not exist, doing nothing.' result["msg"] = "Role does not exist, doing nothing."
module.exit_json(**result) module.exit_json(**result)
# Process a creation # Process a creation
result['changed'] = True result["changed"] = True
if name is None: if name is None:
module.fail_json(msg='name must be specified when creating a new role') module.fail_json(msg="name must be specified when creating a new role")
if module._diff: if module._diff:
result['diff'] = dict(before='', after=desired_role) result["diff"] = dict(before="", after=desired_role)
if module.check_mode: if module.check_mode:
module.exit_json(**result) module.exit_json(**result)
@@ -354,45 +361,49 @@ def main():
kc.create_client_role(desired_role, clientid, realm) kc.create_client_role(desired_role, clientid, realm)
after_role = kc.get_client_role(name, clientid, realm) after_role = kc.get_client_role(name, clientid, realm)
if after_role['composite']: if after_role["composite"]:
after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) after_role["composites"] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm)
result['end_state'] = after_role result["end_state"] = after_role
result['msg'] = 'Role {name} has been created'.format(name=name) result["msg"] = f"Role {name} has been created"
module.exit_json(**result) module.exit_json(**result)
else: else:
if state == 'present': if state == "present":
compare_exclude = [] compare_exclude = ["clientId"]
if 'composites' in desired_role and isinstance(desired_role['composites'], list) and len(desired_role['composites']) > 0: 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) composites = kc.get_role_composites(rolerep=before_role, clientid=clientid, realm=realm)
before_role['composites'] = [] before_role["composites"] = []
for composite in composites: for composite in composites:
before_composite = {} before_composite = {}
if composite['clientRole']: if composite["clientRole"]:
composite_client = kc.get_client_by_id(id=composite['containerId'], realm=realm) composite_client = kc.get_client_by_id(id=composite["containerId"], realm=realm)
before_composite['client_id'] = composite_client['clientId'] before_composite["client_id"] = composite_client["clientId"]
else: else:
before_composite['client_id'] = None before_composite["client_id"] = None
before_composite['name'] = composite['name'] before_composite["name"] = composite["name"]
before_composite['state'] = 'present' before_composite["state"] = "present"
before_role['composites'].append(before_composite) before_role["composites"].append(before_composite)
else: else:
compare_exclude.append('composites') compare_exclude.append("composites")
# Process an update # Process an update
# no changes # no changes
if is_struct_included(desired_role, before_role, exclude=compare_exclude): if is_struct_included(desired_role, before_role, exclude=compare_exclude):
result['changed'] = False result["changed"] = False
result['end_state'] = desired_role result["end_state"] = desired_role
result['msg'] = "No changes required to role {name}.".format(name=name) result["msg"] = f"No changes required to role {name}."
module.exit_json(**result) module.exit_json(**result)
# doing an update # doing an update
result['changed'] = True result["changed"] = True
if module._diff: if module._diff:
result['diff'] = dict(before=before_role, after=desired_role) result["diff"] = dict(before=before_role, after=desired_role)
if module.check_mode: if module.check_mode:
module.exit_json(**result) module.exit_json(**result)
@@ -404,20 +415,20 @@ def main():
else: else:
kc.update_client_role(desired_role, clientid, realm) kc.update_client_role(desired_role, clientid, realm)
after_role = kc.get_client_role(name, clientid, realm) after_role = kc.get_client_role(name, clientid, realm)
if after_role['composite']: if after_role["composite"]:
after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) after_role["composites"] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm)
result['end_state'] = after_role result["end_state"] = after_role
result['msg'] = "Role {name} has been updated".format(name=name) result["msg"] = f"Role {name} has been updated"
module.exit_json(**result) module.exit_json(**result)
else: else:
# Process a deletion (because state was not 'present') # Process a deletion (because state was not 'present')
result['changed'] = True result["changed"] = True
if module._diff: if module._diff:
result['diff'] = dict(before=before_role, after='') result["diff"] = dict(before=before_role, after="")
if module.check_mode: if module.check_mode:
module.exit_json(**result) module.exit_json(**result)
@@ -428,12 +439,12 @@ def main():
else: else:
kc.delete_client_role(name, clientid, realm) kc.delete_client_role(name, clientid, realm)
result['end_state'] = {} result["end_state"] = {}
result['msg'] = "Role {name} has been deleted".format(name=name) result["msg"] = f"Role {name} has been deleted"
module.exit_json(**result) module.exit_json(**result)
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@@ -0,0 +1,597 @@
# Copyright (c) 2019, INSPQ (@elfelip)
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_user
short_description: Create and configure a user in Keycloak
description:
- This module creates, removes, or updates Keycloak users.
# 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()

View File

@@ -0,0 +1,205 @@
# Copyright (c) 2025, mariusbertram <marius@brtrm.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_user_execute_actions_email
short_description: Send a Keycloak execute-actions email to a user
# 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()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,442 @@
# Copyright (c) 2022, Dušan Marković (@bratwurzt)
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_user_rolemapping
short_description: Allows administration of Keycloak user_rolemapping with the Keycloak API
# 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()

View File

@@ -0,0 +1,824 @@
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: keycloak_userprofile
short_description: Allows managing Keycloak User Profiles
description:
- This module allows you to create, update, or delete Keycloak User Profiles using the Keycloak API. You can also customize
the "Unmanaged Attributes" with it.
- The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation
at U(https://www.keycloak.org/docs-api/24.0.5/rest-api/index.html). For compatibility reasons, the module also accepts
the camelCase versions of the options.
# 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()

View File

@@ -1,5 +1,5 @@
--- ---
collections: collections:
- name: middleware_automation.common - name: middleware_automation.common
version: ">=1.2.1" version: ">=1.2.4"
- name: ansible.posix - name: ansible.posix

View File

@@ -7,6 +7,10 @@ keycloak_download_url_9x: "https://downloads.jboss.org/keycloak/{{ keycloak_vers
keycloak_installdir: "{{ keycloak_dest }}/keycloak-{{ keycloak_version }}" keycloak_installdir: "{{ keycloak_dest }}/keycloak-{{ keycloak_version }}"
keycloak_offline_install: false 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 ### Install location and service settings
keycloak_java_home: keycloak_java_home:
keycloak_dest: /opt/keycloak keycloak_dest: /opt/keycloak

View File

@@ -333,6 +333,14 @@ argument_specs:
default: true default: true
description: "Allow the option to ignore invalid certificates when downloading JDBC drivers from a custom URL" description: "Allow the option to ignore invalid certificates when downloading JDBC drivers from a custom URL"
type: "bool" 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: downstream:
options: options:
sso_version: sso_version:

View File

@@ -13,7 +13,7 @@
when: ansible_facts.os_family == "RedHat" when: ansible_facts.os_family == "RedHat"
- name: "Install packages: {{ packages_to_install }}" - name: "Install packages: {{ packages_to_install }}"
become: true become: "{{ keycloak_fastpackages_require_privilege_escalation | default(true) }}"
ansible.builtin.dnf: ansible.builtin.dnf:
name: "{{ packages_to_install }}" name: "{{ packages_to_install }}"
state: present state: present
@@ -22,7 +22,7 @@
- ansible_facts.os_family == "RedHat" - ansible_facts.os_family == "RedHat"
- name: "Install packages: {{ packages_list }}" - name: "Install packages: {{ packages_list }}"
become: true become: "{{ keycloak_fastpackages_require_privilege_escalation | default(true) }}"
ansible.builtin.package: ansible.builtin.package:
name: "{{ packages_list }}" name: "{{ packages_list }}"
state: present state: present

View File

@@ -6,14 +6,14 @@
- firewalld - firewalld
- name: Enable and start the firewalld service - name: Enable and start the firewalld service
become: true become: "{{ keycloak_firewalld_require_privilege_escalation | default(true) }}"
ansible.builtin.systemd: ansible.builtin.systemd:
name: firewalld name: firewalld
enabled: true enabled: true
state: started state: started
- name: "Configure firewall ports for {{ keycloak.service_name }}" - name: "Configure firewall ports for {{ keycloak.service_name }}"
become: true become: "{{ keycloak_firewalld_require_privilege_escalation | default(true) }}"
ansible.posix.firewalld: ansible.posix.firewalld:
port: "{{ item }}" port: "{{ item }}"
permanent: true permanent: true

View File

@@ -11,7 +11,7 @@
quiet: true quiet: true
- name: Check for an existing deployment - name: Check for an existing deployment
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ keycloak_jboss_home }}" path: "{{ keycloak_jboss_home }}"
register: existing_deploy register: existing_deploy
@@ -20,24 +20,24 @@
when: existing_deploy.stat.exists and keycloak_force_install | bool when: existing_deploy.stat.exists and keycloak_force_install | bool
block: block:
- name: "Stop the old {{ keycloak.service_name }} service" - name: "Stop the old {{ keycloak.service_name }} service"
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
failed_when: false failed_when: false
ansible.builtin.systemd: ansible.builtin.systemd:
name: keycloak name: keycloak
state: stopped state: stopped
- name: "Remove the old {{ keycloak.service_name }} deployment" - name: "Remove the old {{ keycloak.service_name }} deployment"
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.file: ansible.builtin.file:
path: "{{ keycloak_jboss_home }}" path: "{{ keycloak_jboss_home }}"
state: absent state: absent
- name: Check for an existing deployment after possible forced removal - name: Check for an existing deployment after possible forced removal
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ keycloak_jboss_home }}" path: "{{ keycloak_jboss_home }}"
- name: "Create service user/group for {{ keycloak.service_name }}" - name: "Create service user/group for {{ keycloak.service_name }}"
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.user: ansible.builtin.user:
name: "{{ keycloak_service_user }}" name: "{{ keycloak_service_user }}"
home: /opt/keycloak home: /opt/keycloak
@@ -45,7 +45,7 @@
create_home: false create_home: false
- name: "Create install location for {{ keycloak.service_name }}" - name: "Create install location for {{ keycloak.service_name }}"
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.file: ansible.builtin.file:
dest: "{{ keycloak_dest }}" dest: "{{ keycloak_dest }}"
state: directory state: directory
@@ -54,7 +54,7 @@
mode: '0750' mode: '0750'
- name: Create pidfile folder - name: Create pidfile folder
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.file: ansible.builtin.file:
dest: "{{ keycloak_service_pidfile | dirname }}" dest: "{{ keycloak_service_pidfile | dirname }}"
state: directory state: directory
@@ -68,7 +68,7 @@
archive: "{{ keycloak_dest }}/{{ keycloak.bundle }}" archive: "{{ keycloak_dest }}/{{ keycloak.bundle }}"
- name: Check download archive path - name: Check download archive path
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ archive }}" path: "{{ archive }}"
register: archive_path register: archive_path
@@ -85,6 +85,8 @@
url: "{{ keycloak_download_url }}" url: "{{ keycloak_download_url }}"
dest: "{{ local_path.stat.path }}/{{ keycloak.bundle }}" dest: "{{ local_path.stat.path }}/{{ keycloak.bundle }}"
mode: '0644' mode: '0644'
url_username: "{{ keycloak_binary_download_user | default(omit) }}"
url_password: "{{ keycloak_binary_download_pass | default(omit) }}"
delegate_to: localhost delegate_to: localhost
run_once: true run_once: true
when: when:
@@ -94,7 +96,7 @@
- not sso_enable is defined or not sso_enable - not sso_enable is defined or not sso_enable
- not keycloak_offline_install - not keycloak_offline_install
- name: Perform download from RHN using JBoss Network API - name: Perform download from RHN using Unified Downloads API
delegate_to: localhost delegate_to: localhost
run_once: true run_once: true
when: when:
@@ -104,7 +106,7 @@
- sso_enable is defined and sso_enable - sso_enable is defined and sso_enable
- not keycloak_offline_install - not keycloak_offline_install
block: block:
- name: Retrieve product download using JBoss Network API - name: Retrieve product download using Unified Downloads API
middleware_automation.common.product_search: middleware_automation.common.product_search:
client_id: "{{ rhn_username }}" client_id: "{{ rhn_username }}"
client_secret: "{{ rhn_password }}" client_secret: "{{ rhn_password }}"
@@ -118,7 +120,7 @@
- name: Determine install zipfile from search results - name: Determine install zipfile from search results
ansible.builtin.set_fact: ansible.builtin.set_fact:
rhn_filtered_products: "{{ rhn_products.results | selectattr('file_path', 'match', '[^/]*/' + sso_archive + '$') }}" rhn_filtered_products: "{{ rhn_products.results | selectattr('file_name', 'match', '[^/]*/' + sso_archive + '$') }}"
delegate_to: localhost delegate_to: localhost
run_once: true run_once: true
@@ -166,13 +168,13 @@
- not archive_path.stat.exists - not archive_path.stat.exists
- local_archive_path.stat is defined - local_archive_path.stat is defined
- local_archive_path.stat.exists - local_archive_path.stat.exists
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
- name: "Check target directory: {{ keycloak.home }}" - name: "Check target directory: {{ keycloak.home }}"
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ keycloak.home }}" path: "{{ keycloak.home }}"
register: path_to_workdir register: path_to_workdir
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
- name: "Extract {{ keycloak_service_desc }} archive on target" - name: "Extract {{ keycloak_service_desc }} archive on target"
ansible.builtin.unarchive: ansible.builtin.unarchive:
@@ -182,7 +184,7 @@
creates: "{{ keycloak.home }}" creates: "{{ keycloak.home }}"
owner: "{{ keycloak_service_user }}" owner: "{{ keycloak_service_user }}"
group: "{{ keycloak_service_group }}" group: "{{ keycloak_service_group }}"
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
when: when:
- new_version_downloaded.changed or not path_to_workdir.stat.exists - new_version_downloaded.changed or not path_to_workdir.stat.exists
notify: notify:
@@ -200,13 +202,13 @@
owner: "{{ keycloak_service_user }}" owner: "{{ keycloak_service_user }}"
group: "{{ keycloak_service_group }}" group: "{{ keycloak_service_group }}"
recurse: true recurse: true
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
changed_when: false changed_when: false
- name: Ensure permissions are correct on existing deploy - name: Ensure permissions are correct on existing deploy
ansible.builtin.command: chown -R "{{ keycloak_service_user }}:{{ keycloak_service_group }}" "{{ keycloak.home }}" ansible.builtin.command: chown -R "{{ keycloak_service_user }}:{{ keycloak_service_group }}" "{{ keycloak.home }}"
when: keycloak_service_runas when: keycloak_service_runas
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
changed_when: false changed_when: false
# driver and configuration # driver and configuration
@@ -215,7 +217,7 @@
when: keycloak_jdbc[keycloak_jdbc_engine].enabled when: keycloak_jdbc[keycloak_jdbc_engine].enabled
- name: "Deploy custom {{ keycloak.service_name }} config to {{ keycloak_config_path_to_standalone_xml }} from {{ keycloak_config_override_template }}" - name: "Deploy custom {{ keycloak.service_name }} config to {{ keycloak_config_path_to_standalone_xml }} from {{ keycloak_config_override_template }}"
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.template: ansible.builtin.template:
src: "templates/{{ keycloak_config_override_template }}" src: "templates/{{ keycloak_config_override_template }}"
dest: "{{ keycloak_config_path_to_standalone_xml }}" dest: "{{ keycloak_config_path_to_standalone_xml }}"
@@ -227,7 +229,7 @@
when: keycloak_config_override_template | length > 0 when: keycloak_config_override_template | length > 0
- name: "Deploy standalone {{ keycloak.service_name }} config to {{ keycloak_config_path_to_standalone_xml }}" - name: "Deploy standalone {{ keycloak.service_name }} config to {{ keycloak_config_path_to_standalone_xml }}"
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.template: ansible.builtin.template:
src: templates/standalone.xml.j2 src: templates/standalone.xml.j2
dest: "{{ keycloak_config_path_to_standalone_xml }}" dest: "{{ keycloak_config_path_to_standalone_xml }}"
@@ -255,7 +257,7 @@
when: keycloak_ha_enabled and keycloak_ha_discovery == 'TCPPING' when: keycloak_ha_enabled and keycloak_ha_discovery == 'TCPPING'
- name: "Deploy HA {{ keycloak.service_name }} config to {{ keycloak_config_path_to_standalone_xml }}" - name: "Deploy HA {{ keycloak.service_name }} config to {{ keycloak_config_path_to_standalone_xml }}"
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.template: ansible.builtin.template:
src: templates/standalone-ha.xml.j2 src: templates/standalone-ha.xml.j2
dest: "{{ keycloak_config_path_to_standalone_xml }}" dest: "{{ keycloak_config_path_to_standalone_xml }}"
@@ -270,7 +272,7 @@
- keycloak_config_override_template | length == 0 - keycloak_config_override_template | length == 0
- name: "Deploy HA {{ keycloak.service_name }} config with infinispan remote cache store to {{ keycloak_config_path_to_standalone_xml }}" - name: "Deploy HA {{ keycloak.service_name }} config with infinispan remote cache store to {{ keycloak_config_path_to_standalone_xml }}"
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.template: ansible.builtin.template:
src: templates/standalone-infinispan.xml.j2 src: templates/standalone-infinispan.xml.j2
dest: "{{ keycloak_config_path_to_standalone_xml }}" dest: "{{ keycloak_config_path_to_standalone_xml }}"
@@ -285,7 +287,7 @@
- keycloak_config_override_template | length == 0 - keycloak_config_override_template | length == 0
- name: "Deploy profile.properties file to {{ keycloak_config_path_to_properties }}" - name: "Deploy profile.properties file to {{ keycloak_config_path_to_properties }}"
become: true become: "{{ keycloak_install_require_privilege_escalation | default(true) }}"
ansible.builtin.template: ansible.builtin.template:
src: keycloak-profile.properties.j2 src: keycloak-profile.properties.j2
dest: "{{ keycloak_config_path_to_properties }}" dest: "{{ keycloak_config_path_to_properties }}"

View File

@@ -6,7 +6,7 @@
- iptables - iptables
- name: "Configure firewall ports for {{ keycloak.service_name }}" - name: "Configure firewall ports for {{ keycloak.service_name }}"
become: true become: "{{ keycloak_iptables_require_privilege_escalation | default(true) }}"
ansible.builtin.iptables: ansible.builtin.iptables:
destination_port: "{{ item }}" destination_port: "{{ item }}"
action: "insert" action: "insert"

View File

@@ -3,7 +3,7 @@
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}" path: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}"
register: dest_path register: dest_path
become: true 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 }}" - name: "Set up module dir for JDBC Driver {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }}"
ansible.builtin.file: ansible.builtin.file:
@@ -13,7 +13,7 @@
owner: "{{ keycloak_service_user }}" owner: "{{ keycloak_service_user }}"
group: "{{ keycloak_service_group }}" group: "{{ keycloak_service_group }}"
mode: '0750' mode: '0750'
become: true become: "{{ keycloak_jdbc_driver_require_privilege_escalation | default(true) }}"
when: when:
- not dest_path.stat.exists - not dest_path.stat.exists
- name: "Verify valid parameters for download credentials when specified" - name: "Verify valid parameters for download credentials when specified"
@@ -34,7 +34,7 @@
url_password: "{{ keycloak_jdbc_download_pass | default(omit) }}" url_password: "{{ keycloak_jdbc_download_pass | default(omit) }}"
validate_certs: "{{ keycloak_jdbc_download_validate_certs | default(omit) }}" validate_certs: "{{ keycloak_jdbc_download_validate_certs | default(omit) }}"
mode: '0640' mode: '0640'
become: true become: "{{ keycloak_jdbc_driver_require_privilege_escalation | default(true) }}"
- name: "Deploy module.xml for JDBC Driver" - name: "Deploy module.xml for JDBC Driver"
ansible.builtin.template: ansible.builtin.template:
@@ -43,4 +43,4 @@
group: "{{ keycloak_service_group }}" group: "{{ keycloak_service_group }}"
owner: "{{ keycloak_service_user }}" owner: "{{ keycloak_service_user }}"
mode: '0640' mode: '0640'
become: true become: "{{ keycloak_jdbc_driver_require_privilege_escalation | default(true) }}"

View File

@@ -51,7 +51,7 @@
state: link state: link
src: "{{ keycloak_jboss_home }}/standalone/log" src: "{{ keycloak_jboss_home }}/standalone/log"
dest: "{{ keycloak_log_target }}" dest: "{{ keycloak_log_target }}"
become: true become: "{{ keycloak_require_privilege_escalation | default(true) }}"
- name: Set admin credentials and restart if not already created - name: Set admin credentials and restart if not already created
block: block:
@@ -75,7 +75,7 @@
- "-u{{ keycloak_admin_user }}" - "-u{{ keycloak_admin_user }}"
- "-p{{ keycloak_admin_password }}" - "-p{{ keycloak_admin_password }}"
changed_when: true changed_when: true
become: true become: "{{ keycloak_require_privilege_escalation | default(true) }}"
- name: "Restart {{ keycloak.service_name }}" - name: "Restart {{ keycloak.service_name }}"
ansible.builtin.include_tasks: tasks/restart_keycloak.yml ansible.builtin.include_tasks: tasks/restart_keycloak.yml
- name: "Wait until {{ keycloak.service_name }} becomes active {{ keycloak.health_url }}" - name: "Wait until {{ keycloak.service_name }} becomes active {{ keycloak.health_url }}"

View File

@@ -5,7 +5,7 @@
enabled: true enabled: true
state: restarted state: restarted
daemon_reload: true daemon_reload: true
become: true become: "{{ keycloak_restart_require_privilege_escalation | default(true) }}"
delegate_to: "{{ ansible_play_hosts | first }}" delegate_to: "{{ ansible_play_hosts | first }}"
run_once: true run_once: true
@@ -24,5 +24,5 @@
name: keycloak name: keycloak
enabled: true enabled: true
state: restarted state: restarted
become: true become: "{{ keycloak_restart_require_privilege_escalation | default(true) }}"
when: inventory_hostname != ansible_play_hosts | first when: inventory_hostname != ansible_play_hosts | first

View File

@@ -12,9 +12,9 @@
path: "{{ patch_archive }}" path: "{{ patch_archive }}"
register: patch_archive_path register: patch_archive_path
when: sso_patch_version is defined when: sso_patch_version is defined
become: true become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}"
- name: Perform patch download from RHN via JBossNetwork API - name: Perform patch download from RHN via Unified Downloads API
delegate_to: localhost delegate_to: localhost
run_once: true run_once: true
when: when:
@@ -22,7 +22,7 @@
- not keycloak_offline_install - not keycloak_offline_install
- sso_apply_patches - sso_apply_patches
block: block:
- name: Retrieve product download using JBossNetwork API - name: Retrieve product download using Unified Downloads API
middleware_automation.common.product_search: middleware_automation.common.product_search:
client_id: "{{ rhn_username }}" client_id: "{{ rhn_username }}"
client_secret: "{{ rhn_password }}" client_secret: "{{ rhn_password }}"
@@ -36,7 +36,7 @@
- name: Determine patch versions list - name: Determine patch versions list
ansible.builtin.set_fact: ansible.builtin.set_fact:
filtered_versions: "{{ rhn_products.results | map(attribute='file_path') | \ filtered_versions: "{{ rhn_products.results | map(attribute='file_name') | \
select('match', '^[^/]*/rh-sso-.*[0-9]*[.][0-9]*[.][0-9]*.*$') | \ 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 }}" map('regex_replace', '[^/]*/rh-sso-([0-9]*[.][0-9]*[.][0-9]*(-[0-9])?)-.*', '\\1') | list | unique }}"
when: sso_patch_version is not defined or sso_patch_version | length == 0 when: sso_patch_version is not defined or sso_patch_version | length == 0
@@ -52,7 +52,7 @@
- name: Determine install zipfile from search results - name: Determine install zipfile from search results
ansible.builtin.set_fact: ansible.builtin.set_fact:
rhn_filtered_products: "{{ rhn_products.results | selectattr('file_path', 'match', '[^/]*/rh-sso-' + sso_latest_version + '-patch.zip$') }}" rhn_filtered_products: "{{ rhn_products.results | selectattr('file_name', 'match', '[^/]*/rh-sso-' + sso_latest_version + '-patch.zip$') }}"
patch_bundle: "rh-sso-{{ sso_latest_version }}-patch.zip" patch_bundle: "rh-sso-{{ sso_latest_version }}-patch.zip"
patch_version: "{{ sso_latest_version }}" patch_version: "{{ sso_latest_version }}"
when: sso_patch_version is not defined or sso_patch_version | length == 0 when: sso_patch_version is not defined or sso_patch_version | length == 0
@@ -61,7 +61,7 @@
- name: "Determine selected patch from supplied version: {{ sso_patch_version }}" - name: "Determine selected patch from supplied version: {{ sso_patch_version }}"
ansible.builtin.set_fact: ansible.builtin.set_fact:
rhn_filtered_products: "{{ rhn_products.results | selectattr('file_path', 'match', '[^/]*/' + sso_patch_bundle + '$') }}" rhn_filtered_products: "{{ rhn_products.results | selectattr('file_name', 'match', '[^/]*/' + sso_patch_bundle + '$') }}"
patch_bundle: "{{ sso_patch_bundle }}" patch_bundle: "{{ sso_patch_bundle }}"
patch_version: "{{ sso_patch_version }}" patch_version: "{{ sso_patch_version }}"
when: sso_patch_version is defined when: sso_patch_version is defined
@@ -86,7 +86,7 @@
ansible.builtin.stat: ansible.builtin.stat:
path: "{{ patch_archive }}" path: "{{ patch_archive }}"
register: patch_archive_path register: patch_archive_path
become: true become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}"
## copy and unpack ## copy and unpack
- name: Copy patch archive to target nodes - name: Copy patch archive to target nodes
@@ -101,7 +101,7 @@
- not patch_archive_path.stat.exists - not patch_archive_path.stat.exists
- local_archive_path.stat is defined - local_archive_path.stat is defined
- local_archive_path.stat.exists - local_archive_path.stat.exists
become: true become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}"
- name: "Check installed patches" - name: "Check installed patches"
ansible.builtin.include_tasks: rhsso_cli.yml ansible.builtin.include_tasks: rhsso_cli.yml
@@ -109,7 +109,7 @@
cli_query: "patch info" cli_query: "patch info"
args: args:
apply: apply:
become: true become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}"
become_user: "{{ keycloak_service_user }}" become_user: "{{ keycloak_service_user }}"
- name: "Perform patching" - name: "Perform patching"
@@ -124,7 +124,7 @@
cli_query: "patch apply {{ patch_archive }}" cli_query: "patch apply {{ patch_archive }}"
args: args:
apply: apply:
become: true become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}"
become_user: "{{ keycloak_service_user }}" become_user: "{{ keycloak_service_user }}"
- name: "Restart server to ensure patch content is running" - name: "Restart server to ensure patch content is running"
@@ -135,7 +135,7 @@
- cli_result.rc == 0 - cli_result.rc == 0
args: args:
apply: apply:
become: true become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}"
become_user: "{{ keycloak_service_user }}" become_user: "{{ keycloak_service_user }}"
- name: "Wait until {{ keycloak.service_name }} becomes active {{ keycloak.health_url }}" - name: "Wait until {{ keycloak.service_name }} becomes active {{ keycloak.health_url }}"
@@ -152,7 +152,7 @@
cli_query: "patch info" cli_query: "patch info"
args: args:
apply: apply:
become: true become: "{{ keycloak_rhsso_patch_require_privilege_escalation | default(true) }}"
become_user: "{{ keycloak_service_user }}" become_user: "{{ keycloak_service_user }}"
- name: "Verify installed patch version" - name: "Verify installed patch version"

Some files were not shown because too many files have changed in this diff Show More