diff --git a/.ansible-lint b/.ansible-lint index 9c2702e..8e4b5ca 100644 --- a/.ansible-lint +++ b/.ansible-lint @@ -30,10 +30,12 @@ warn_list: - schema[meta] - key-order[task] - blocked_modules + - run-once[task] skip_list: - vars_should_not_be_used - file_is_small_enough + - file_has_valid_name - name[template] - var-naming[no-role-prefix] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0927320..de65d72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main pull_request: + workflow_dispatch: schedule: - cron: '15 6 * * *' @@ -15,4 +16,4 @@ jobs: with: fqcn: 'middleware_automation/keycloak' molecule_tests: >- - [ "default", "overridexml", "https_revproxy", "quarkus", "quarkus-devmode", "quarkus_upgrade", "debian", "quarkus_ha" ] + [ "default", "overridexml", "https_revproxy", "quarkus", "quarkus-devmode", "quarkus_upgrade", "debian", "quarkus_ha" ] diff --git a/.github/workflows/traffic.yml b/.github/workflows/traffic.yml new file mode 100644 index 0000000..d997f4e --- /dev/null +++ b/.github/workflows/traffic.yml @@ -0,0 +1,26 @@ +name: Collect traffic stats +on: + schedule: + - cron: "51 23 * * 0" + workflow_dispatch: + +jobs: + traffic: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: "gh-pages" + + - name: GitHub traffic + uses: sangonzal/repository-traffic-action@v.0.1.6 + env: + TRAFFIC_ACTION_TOKEN: ${{ secrets.TRIGGERING_PAT }} + + - name: Commit changes + uses: EndBug/add-and-commit@v4 + with: + author_name: Ansible Middleware + message: "GitHub traffic" + add: "./traffic/*" + ref: "gh-pages" diff --git a/.gitignore b/.gitignore index f2ffea4..ce41aef 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ docs/_build/ changelogs/.plugin-cache.yaml *.pem *.key +*.p12 diff --git a/.yamllint b/.yamllint index fa1f1fc..10e554e 100644 --- a/.yamllint +++ b/.yamllint @@ -15,7 +15,8 @@ rules: commas: max-spaces-after: -1 level: error - comments: disable + comments: + min-spaces-from-content: 1 comments-indentation: disable document-start: disable empty-lines: @@ -30,4 +31,8 @@ rules: new-lines: type: unix trailing-spaces: disable - truthy: disable \ No newline at end of file + truthy: disable + octal-values: + forbid-implicit-octal: true + forbid-explicit-octal: true + diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f139898..37a3923 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,44 @@ middleware\_automation.keycloak Release Notes This changelog describes changes after version 0.2.6. +v2.4.3 +====== + +Minor Changes +------------- + +- Update keycloak to 24.0.5 `#241 `_ + +v2.4.2 +====== + +Minor Changes +------------- + +- New parameter ``keycloak_quarkus_download_path`` `#239 `_ + +Bugfixes +-------- + +- Add wait_for_port number parameter `#237 `_ + +v2.4.1 +====== + +Release Summary +--------------- + +Internal release, documentation or test changes only. + +v2.4.0 +====== + +Major Changes +------------- + +- Enable by default health check on restart `#234 `_ +- Update minimum ansible-core version > 2.15 `#232 `_ + v2.3.0 ====== diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aee36a6..95b60ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,37 @@ +## Developing + +### Build and install locally + +Clone the repository, checkout the tag you want to build, or pick the main branch for the development version; then: + + ansible-galaxy collection build . + ansible-galaxy collection install middleware_automation-keycloak-*.tar.gz + + +### Development environment + +Make sure your development machine has avilable: + +* python 3.11+ +* virtualenv +* docker (or podman) + +In order to run setup the development environment and run the molecule tests locally, after cloning the repository: + +``` +# create new virtualenv using python 3 +virtualenv $PATH_TO_DEV_VIRTUALENV +# activate the virtual env +source $PATH_TO_DEV_VIRTUALENV/bin/activate +# install ansible and tools onto the virtualenv +pip install yamllint 'molecule>=6.0' 'molecule-plugins[docker]' 'ansible-core>=2.16' ansible-lint +# install collection dependencies +ansible-galaxy collection install -r requirements.yml +# install python dependencies +pip install -r requirements.txt molecule/requirements.txt +# execute the tests (replace --all with -s subdirectory to run a single test) +molecule test --all +``` ## Contributor's Guidelines diff --git a/README.md b/README.md index 0cd3ba0..9e9867d 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,16 @@ [![Build Status](https://github.com/ansible-middleware/keycloak/workflows/CI/badge.svg?branch=main)](https://github.com/ansible-middleware/keycloak/actions/workflows/ci.yml) -> **_NOTE:_ If you are Red Hat customer, install `redhat.sso` (for Red Hat Single Sign-On) or `redhat.rhbk` (for Red Hat Build of Keycloak) 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.** + Collection to install and configure [Keycloak](https://www.keycloak.org/) or [Red Hat Single Sign-On](https://access.redhat.com/products/red-hat-single-sign-on) / [Red Hat Build of Keycloak](https://access.redhat.com/products/red-hat-build-of-keycloak). - + ## Ansible version compatibility -This collection has been tested against following Ansible versions: **>=2.14.0**. +This collection has been tested against following Ansible versions: **>=2.16.0**. Plugins and modules within a collection may be tested with only specific Ansible versions. A collection may contain metadata that identifies these versions. @@ -39,6 +40,7 @@ collections: The keycloak collection also depends on the following python packages to be present on the controller host: * netaddr +* lxml A requirement file is provided to install: @@ -47,9 +49,10 @@ A requirement file is provided to install: ### Included roles -* [`keycloak`](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak/README.md): role for installing the service (keycloak <= 19.0). -* [`keycloak_realm`](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak_realm/README.md): role for configuring a realm, user federation(s), clients and users, in an installed service. -* [`keycloak_quarkus`](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak_quarkus/README.md): role for installing the quarkus variant of keycloak (>= 17.0.0). +* `keycloak_quarkus`: role for installing keycloak (>= 19.0.0, quarkus based). +* `keycloak_realm`: role for configuring a realm, user federation(s), clients and users, in an installed service. +* `keycloak`: role for installing legacy keycloak (<= 19.0, wildfly based). + ## Usage @@ -57,9 +60,9 @@ A requirement file is provided to install: ### Install Playbook -* [`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). * [`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. For full service configuration details, refer to the [keycloak role README](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak/README.md). @@ -90,7 +93,7 @@ Execute the following command from the source root directory ``` ansible-playbook -i -e @rhn-creds.yml playbooks/keycloak.yml -e keycloak_admin_password= -``` +``` - `keycloak_admin_password` Password for the administration console user account. - `ansible_hosts` is the inventory, below is an example inventory for deploying to localhost @@ -100,7 +103,7 @@ ansible-playbook -i -e @rhn-creds.yml playbooks/keycloak.yml -e localhost ansible_connection=local ``` -Note: when deploying clustered configurations, all hosts belonging to the cluster must be present in ansible_play_batch; ie. they must be targeted by the same ansible-playbook execution. +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. ## Configuration @@ -141,4 +144,3 @@ Apache License v2.0 or later See [LICENSE](LICENSE) to view the full text. - diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 773f77d..40a7db3 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -571,3 +571,45 @@ releases: - 229.yaml - 231.yaml release_date: '2024-05-20' + 2.4.0: + changes: + major_changes: + - 'Enable by default health check on restart `#234 `_ + + ' + - 'Update minimum ansible-core version > 2.15 `#232 `_ + + ' + fragments: + - 232.yaml + - 234.yaml + release_date: '2024-06-04' + 2.4.1: + changes: + release_summary: Internal release, documentation or test changes only. + fragments: + - v2.4.1-devel_summary.yaml + release_date: '2024-07-02' + 2.4.2: + changes: + bugfixes: + - 'Add wait_for_port number parameter `#237 `_ + + ' + minor_changes: + - 'New parameter ``keycloak_quarkus_download_path`` `#239 `_ + + ' + fragments: + - 237.yaml + - 239.yaml + release_date: '2024-09-26' + 2.4.3: + changes: + minor_changes: + - 'Update keycloak to 24.0.5 `#241 `_ + + ' + fragments: + - 241.yaml + release_date: '2024-10-16' diff --git a/docs/_gh_include/footer.inc b/docs/_gh_include/footer.inc index 73bac34..11c1cfe 100644 --- a/docs/_gh_include/footer.inc +++ b/docs/_gh_include/footer.inc @@ -7,7 +7,7 @@
-

© Copyright 2022, Red Hat, Inc.

+

© Copyright 2024, Red Hat, Inc.

Built with Sphinx using a theme @@ -18,4 +18,4 @@ - \ No newline at end of file + diff --git a/docs/index.rst b/docs/index.rst index 38dec5b..6c46ab1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,31 +10,25 @@ Welcome to Keycloak Collection documentation README plugins/index roles/index + Changelog .. toctree:: :maxdepth: 2 :caption: Developer documentation - testing - developing - releasing - -.. toctree:: - :maxdepth: 2 - :caption: General - - Changelog + Developing + Testing + Releasing .. toctree:: :maxdepth: 2 :caption: Middleware collections - Infinispan / Red Hat Data Grid Keycloak / Red Hat Single Sign-On + Infinispan / Red Hat Data Grid Wildfly / Red Hat JBoss EAP Tomcat / Red Hat JWS ActiveMQ / Red Hat AMQ Broker Kafka / Red Hat AMQ Streams Ansible Middleware utilities - Red Hat CSP Download JCliff diff --git a/docs/requirements.txt b/docs/requirements.txt index c8f8e2d..303f3a6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ antsibull>=0.17.0 antsibull-docs antsibull-changelog -ansible-core>=2.14.1 +ansible-core>=2.16.0 ansible-pygments sphinx-rtd-theme git+https://github.com/felixfontein/ansible-basic-sphinx-ext diff --git a/docs/testing.md b/docs/testing.md index 1d06d7f..8e773ea 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,24 +4,7 @@ The collection is tested with a [molecule](https://github.com/ansible-community/molecule) setup covering the included roles and verifying correct installation and idempotency. In order to run the molecule tests locally with python 3.9 available, after cloning the repository: - -``` -pip install yamllint 'molecule[docker]~=3.5.2' ansible-core flake8 ansible-lint voluptuous -molecule test --all -``` - - -## Integration testing - -Demo repositories which depend on the collection, and aggregate functionality with other middleware_automation collections, are automatically rebuilt -at every collection release to ensure non-breaking changes and consistent behaviour. - -The repository are: - - - [Flange demo](https://github.com/ansible-middleware/flange-demo) - A deployment of Wildfly cluster integrated with keycloak and infinispan. - - [CrossDC keycloak demo](https://github.com/ansible-middleware/cross-dc-rhsso-demo) - A clustered multi-regional installation of keycloak with infinispan remote caches. +The test scenarios are available on the source code repository each on his own subdirectory under [molecule/](https://github.com/ansible-middleware/keycloak/molecule). ## Test playbooks @@ -29,15 +12,7 @@ The repository are: Sample playbooks are provided in the `playbooks/` directory; to run the playbooks locally (requires a rhel system with python 3.9+, ansible, and systemd) the steps are as follows: ``` -# setup environment -pip install ansible-core -# clone the repository -git clone https://github.com/ansible-middleware/keycloak -cd keycloak -# install collection dependencies -ansible-galaxy collection install -r requirements.yml -# install collection python deps -pip install -r requirements.txt +# setup environment as in developing # create inventory for localhost cat << EOF > inventory [keycloak] diff --git a/galaxy.yml b/galaxy.yml index da8794c..76c46c4 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: middleware_automation name: keycloak -version: "2.3.0" +version: "3.0.0" readme: README.md authors: - Romain Pelisse diff --git a/meta/runtime.yml b/meta/runtime.yml index ce6befd..49c7554 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -1,2 +1,2 @@ --- -requires_ansible: ">=2.14.0" +requires_ansible: ">=2.16.0" diff --git a/molecule/debian/converge.yml b/molecule/debian/converge.yml index e6319b7..e853b38 100644 --- a/molecule/debian/converge.yml +++ b/molecule/debian/converge.yml @@ -3,40 +3,42 @@ hosts: all vars: keycloak_quarkus_show_deprecation_warnings: false - keycloak_quarkus_admin_pass: "remembertochangeme" - keycloak_realm: TestRealm + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" + keycloak_quarkus_hostname: http://instance:8080 keycloak_quarkus_log: file - keycloak_quarkus_frontend_url: 'http://localhost:8080/' - keycloak_quarkus_start_dev: True + keycloak_quarkus_start_dev: true keycloak_quarkus_proxy_mode: none - keycloak_client_default_roles: - - TestRoleAdmin - - TestRoleUser - keycloak_client_users: - - username: TestUser - password: password - client_roles: - - client: TestClient - role: TestRoleUser - - username: TestAdmin - password: password - client_roles: - - client: TestClient - role: TestRoleUser - - client: TestClient - role: TestRoleAdmin - keycloak_clients: - - name: TestClient - roles: "{{ keycloak_client_default_roles }}" - public_client: "{{ keycloak_client_public }}" - web_origins: "{{ keycloak_client_web_origins }}" - users: "{{ keycloak_client_users }}" - client_id: TestClient - attributes: - post.logout.redirect.uris: '/public/logout' roles: - role: keycloak_quarkus - role: keycloak_realm - keycloak_realm: TestRealm - keycloak_admin_password: "remembertochangeme" + keycloak_url: "{{ keycloak_quarkus_hostname }}" keycloak_context: '' + keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}" + keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}" + keycloak_client_users: + - username: TestUser + password: password + client_roles: + - client: TestClient + role: TestRoleUser + realm: "{{ keycloak_realm }}" + - username: TestAdmin + password: password + client_roles: + - client: TestClient + role: TestRoleUser + realm: "{{ keycloak_realm }}" + - client: TestClient + role: TestRoleAdmin + realm: "{{ keycloak_realm }}" + keycloak_realm: TestRealm + keycloak_clients: + - name: TestClient + realm: "{{ keycloak_realm }}" + public_client: "{{ keycloak_client_public }}" + web_origins: "{{ keycloak_client_web_origins }}" + users: "{{ keycloak_client_users }}" + client_id: TestClient + attributes: + post.logout.redirect.uris: '/public/logout' diff --git a/molecule/debian/prepare.yml b/molecule/debian/prepare.yml index 6025ef9..267386e 100644 --- a/molecule/debian/prepare.yml +++ b/molecule/debian/prepare.yml @@ -7,5 +7,6 @@ ansible.builtin.apt: name: - sudo + # - openjdk-21-jdk-headless # this is not available in ghcr.io/hspaans/molecule-containers:debian-11 (neither in debian-12) since the images are using outdated package sources - openjdk-17-jdk-headless state: present diff --git a/molecule/debian/verify.yml b/molecule/debian/verify.yml index 59bf483..863b820 100644 --- a/molecule/debian/verify.yml +++ b/molecule/debian/verify.yml @@ -2,7 +2,7 @@ - name: Verify hosts: all vars: - keycloak_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" keycloak_uri: "http://localhost:{{ 8080 + ( keycloak_jboss_port_offset | default(0) ) }}" keycloak_management_port: "http://localhost:{{ 9990 + ( keycloak_jboss_port_offset | default(0) ) }}" keycloak_jboss_port_offset: 10 diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 07cd724..de97748 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -2,61 +2,45 @@ - name: Converge hosts: all vars: - keycloak_admin_password: "remembertochangeme" - keycloak_jvm_package: java-11-openjdk-headless - keycloak_modcluster_enabled: True - keycloak_modcluster_urls: - - host: myhost1 - port: 16667 - - host: myhost2 - port: 16668 - keycloak_jboss_port_offset: 10 - keycloak_log_target: /tmp/keycloak + keycloak_quarkus_show_deprecation_warnings: false + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" + keycloak_quarkus_hostname: http://instance:8080 + keycloak_quarkus_log: file + keycloak_quarkus_log_level: debug + keycloak_quarkus_log_target: /tmp/keycloak + keycloak_quarkus_start_dev: true + keycloak_quarkus_proxy_mode: none + keycloak_quarkus_offline_install: true + keycloak_quarkus_download_path: /tmp/keycloak/ roles: - - role: keycloak - tasks: - - name: Keycloak Realm Role - ansible.builtin.include_role: - name: keycloak_realm - vars: - keycloak_client_default_roles: - - TestRoleAdmin - - TestRoleUser - keycloak_client_users: - - username: TestUser - password: password - client_roles: - - client: TestClient - role: TestRoleUser - realm: "{{ keycloak_realm }}" - - username: TestAdmin - password: password - client_roles: - - client: TestClient - role: TestRoleUser - realm: "{{ keycloak_realm }}" - - client: TestClient - role: TestRoleAdmin - realm: "{{ keycloak_realm }}" - keycloak_realm: TestRealm - keycloak_clients: - - name: TestClient - roles: "{{ keycloak_client_default_roles }}" - realm: "{{ keycloak_realm }}" - public_client: "{{ keycloak_client_public }}" - web_origins: "{{ keycloak_client_web_origins }}" - users: "{{ keycloak_client_users }}" - client_id: TestClient - attributes: - post.logout.redirect.uris: '/public/logout' - pre_tasks: - - name: "Retrieve assets server from env" - ansible.builtin.set_fact: - assets_server: "{{ lookup('env', 'MIDDLEWARE_DOWNLOAD_RELEASE_SERVER_URL') }}" - - - name: "Set offline when assets server from env is defined" - ansible.builtin.set_fact: - sso_offline_install: True - when: - - assets_server is defined - - assets_server | length > 0 + - role: keycloak_quarkus + - role: keycloak_realm + keycloak_url: "{{ keycloak_quarkus_hostname }}" + keycloak_context: '' + keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}" + keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}" + keycloak_client_users: + - username: TestUser + password: password + client_roles: + - client: TestClient + role: TestRoleUser + realm: "{{ keycloak_realm }}" + - username: TestAdmin + password: password + client_roles: + - client: TestClient + role: TestRoleUser + realm: "{{ keycloak_realm }}" + - client: TestClient + role: TestRoleAdmin + realm: "{{ keycloak_realm }}" + keycloak_realm: TestRealm + keycloak_clients: + - name: TestClient + realm: "{{ keycloak_realm }}" + public_client: "{{ keycloak_client_public }}" + web_origins: "{{ keycloak_client_web_origins }}" + users: "{{ keycloak_client_users }}" + client_id: TestClient diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml index c133eee..011c08e 100644 --- a/molecule/default/molecule.yml +++ b/molecule/default/molecule.yml @@ -3,7 +3,7 @@ driver: name: docker platforms: - name: instance - image: registry.access.redhat.com/ubi8/ubi-init:latest + image: registry.access.redhat.com/ubi9/ubi-init:latest pre_build_image: true privileged: true command: "/usr/sbin/init" @@ -11,6 +11,7 @@ platforms: - "8080/tcp" - "8443/tcp" - "8009/tcp" + - "9000/tcp" provisioner: name: ansible config_options: diff --git a/molecule/default/prepare.yml b/molecule/default/prepare.yml index b707f6c..eefd5cf 100644 --- a/molecule/default/prepare.yml +++ b/molecule/default/prepare.yml @@ -12,18 +12,18 @@ - "{{ assets_server }}/sso/7.6.0/rh-sso-7.6.0-server-dist.zip" - "{{ assets_server }}/sso/7.6.1/rh-sso-7.6.1-patch.zip" - - name: Install JDK8 - become: yes - ansible.builtin.yum: - name: - - java-1.8.0-openjdk - state: present - when: ansible_facts['os_family'] == "RedHat" + - name: Create controller directory for downloads + ansible.builtin.file: # noqa risky-file-permissions delegated, uses controller host user + path: /tmp/keycloak + state: directory + mode: '0750' + delegate_to: localhost + run_once: true - - name: Install JDK8 - become: yes - ansible.builtin.apt: - name: - - openjdk-8-jdk - state: present - when: ansible_facts['os_family'] == "Debian" + - name: Download keycloak archive to controller directory + ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user + url: https://github.com/keycloak/keycloak/releases/download/26.0.7/keycloak-26.0.7.zip + dest: /tmp/keycloak + mode: '0640' + delegate_to: localhost + run_once: true diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml index 39e94c5..ae21396 100644 --- a/molecule/default/verify.yml +++ b/molecule/default/verify.yml @@ -2,11 +2,9 @@ - name: Verify hosts: all vars: - keycloak_admin_password: "remembertochangeme" - keycloak_jvm_package: java-11-openjdk-headless - keycloak_uri: "http://localhost:{{ 8080 + ( keycloak_jboss_port_offset | default(0) ) }}" - keycloak_management_port: "http://localhost:{{ 9990 + ( keycloak_jboss_port_offset | default(0) ) }}" - keycloak_jboss_port_offset: 10 + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" + keycloak_uri: "http://localhost:8080" tasks: - name: Populate service facts ansible.builtin.service_facts: @@ -15,75 +13,13 @@ that: - ansible_facts.services["keycloak.service"]["state"] == "running" - ansible_facts.services["keycloak.service"]["status"] == "enabled" - - name: Verify we are running on requested jvm # noqa blocked_modules command-instead-of-module - ansible.builtin.shell: | - set -o pipefail - ps -ef | grep '/etc/alternatives/jre_11/' | grep -v grep - args: - executable: /bin/bash - changed_when: no - name: Verify token api call ansible.builtin.uri: - url: "{{ keycloak_uri }}/auth/realms/master/protocol/openid-connect/token" + url: "{{ keycloak_uri }}/realms/master/protocol/openid-connect/token" method: POST - body: "client_id=admin-cli&username=admin&password={{ keycloak_admin_password }}&grant_type=password" + body: "client_id=admin-cli&username={{ keycloak_quarkus_bootstrap_admin_user }}&password={{ keycloak_quarkus_bootstrap_admin_user }}&grant_type=password" validate_certs: no register: keycloak_auth_response until: keycloak_auth_response.status == 200 retries: 2 delay: 2 - - name: Fetch openid-connect config - ansible.builtin.uri: - url: "{{ keycloak_uri }}/auth/realms/TestRealm/.well-known/openid-configuration" - method: GET - validate_certs: no - status_code: 200 - register: keycloak_openid_config - - name: Verify expected config - ansible.builtin.assert: - that: - - keycloak_openid_config.json.registration_endpoint == 'http://localhost:8080/auth/realms/TestRealm/clients-registrations/openid-connect' - - name: Get test realm clients - ansible.builtin.uri: - url: "{{ keycloak_uri }}/auth/admin/realms/TestRealm/clients" - method: GET - validate_certs: no - status_code: 200 - headers: - Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" - register: keycloak_query_clients - - name: Verify expected config - ansible.builtin.assert: - that: - - (keycloak_query_clients.json | selectattr('clientId','equalto','TestClient') | first)["attributes"]["post.logout.redirect.uris"] == '/public/logout' - - name: "Privilege escalation as some files/folders may requires it" - become: yes - block: - - name: Check log folder - ansible.builtin.stat: - path: "/tmp/keycloak" - register: keycloak_log_folder - - name: Check that keycloak log folder exists and is a link - ansible.builtin.assert: - that: - - keycloak_log_folder.stat.exists - - not keycloak_log_folder.stat.isdir - - keycloak_log_folder.stat.islnk - - name: Check log file - ansible.builtin.stat: - path: "/tmp/keycloak/server.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 - - name: Check default log folder - ansible.builtin.stat: - path: "/var/log/keycloak" - register: keycloak_default_log_folder - failed_when: false - - name: Check that default keycloak log folder doesn't exist - ansible.builtin.assert: - that: - - not keycloak_default_log_folder.stat.exists diff --git a/molecule/https_revproxy/converge.yml b/molecule/https_revproxy/converge.yml index b490721..92994fa 100644 --- a/molecule/https_revproxy/converge.yml +++ b/molecule/https_revproxy/converge.yml @@ -3,15 +3,14 @@ hosts: all vars: keycloak_quarkus_show_deprecation_warnings: false - keycloak_quarkus_admin_pass: "remembertochangeme" - keycloak_admin_password: "remembertochangeme" - keycloak_realm: TestRealm - keycloak_quarkus_host: instance + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" + keycloak_quarkus_hostname: https://proxy keycloak_quarkus_log: file keycloak_quarkus_http_enabled: True keycloak_quarkus_http_port: 8080 keycloak_quarkus_proxy_mode: edge keycloak_quarkus_http_relative_path: / - keycloak_quarkus_frontend_url: https://proxy/ + keycloak_quarkus_health_check_url: http://proxy:8080/realms/master/.well-known/openid-configuration roles: - role: keycloak_quarkus diff --git a/molecule/https_revproxy/molecule.yml b/molecule/https_revproxy/molecule.yml index 48bf375..7ad8db8 100644 --- a/molecule/https_revproxy/molecule.yml +++ b/molecule/https_revproxy/molecule.yml @@ -3,7 +3,7 @@ driver: name: docker platforms: - name: instance - image: registry.access.redhat.com/ubi8/ubi-init:latest + image: registry.access.redhat.com/ubi9/ubi-init:latest pre_build_image: true privileged: true command: "/usr/sbin/init" @@ -14,7 +14,7 @@ platforms: published_ports: - 0.0.0.0:8080:8080/tcp - name: proxy - image: registry.access.redhat.com/ubi8/ubi-init:latest + image: registry.access.redhat.com/ubi9/ubi-init:latest pre_build_image: true privileged: true command: "/usr/sbin/init" diff --git a/molecule/overridexml/molecule.yml b/molecule/overridexml/molecule.yml index c133eee..011c08e 100644 --- a/molecule/overridexml/molecule.yml +++ b/molecule/overridexml/molecule.yml @@ -3,7 +3,7 @@ driver: name: docker platforms: - name: instance - image: registry.access.redhat.com/ubi8/ubi-init:latest + image: registry.access.redhat.com/ubi9/ubi-init:latest pre_build_image: true privileged: true command: "/usr/sbin/init" @@ -11,6 +11,7 @@ platforms: - "8080/tcp" - "8443/tcp" - "8009/tcp" + - "9000/tcp" provisioner: name: ansible config_options: diff --git a/molecule/quarkus-devmode/converge.yml b/molecule/quarkus-devmode/converge.yml index 2e5d351..6c0b14f 100644 --- a/molecule/quarkus-devmode/converge.yml +++ b/molecule/quarkus-devmode/converge.yml @@ -3,18 +3,21 @@ hosts: all vars: keycloak_quarkus_show_deprecation_warnings: false - keycloak_quarkus_admin_pass: "remembertochangeme" - keycloak_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" keycloak_realm: TestRealm keycloak_quarkus_log: file - keycloak_quarkus_frontend_url: 'http://localhost:8080/' + keycloak_quarkus_hostname: 'http://localhost:8080' keycloak_quarkus_start_dev: True keycloak_quarkus_proxy_mode: none keycloak_quarkus_java_home: /opt/openjdk/ roles: - role: keycloak_quarkus - role: keycloak_realm + keycloak_url: "{{ keycloak_quarkus_hostname }}" keycloak_context: '' + keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}" + keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}" keycloak_client_default_roles: - TestRoleAdmin - TestRoleUser diff --git a/molecule/quarkus-devmode/molecule.yml b/molecule/quarkus-devmode/molecule.yml index 191234d..2da7e01 100644 --- a/molecule/quarkus-devmode/molecule.yml +++ b/molecule/quarkus-devmode/molecule.yml @@ -3,15 +3,17 @@ driver: name: docker platforms: - name: instance - image: registry.access.redhat.com/ubi8/ubi-init:latest + image: registry.access.redhat.com/ubi9/ubi-init:latest pre_build_image: true privileged: true command: "/usr/sbin/init" port_bindings: - "8080/tcp" - "8009/tcp" + - "9000/tcp" published_ports: - 0.0.0.0:8080:8080/tcp + - 0.0.0.0:9000:9000/TCP provisioner: name: ansible config_options: diff --git a/molecule/quarkus/converge.yml b/molecule/quarkus/converge.yml index 7c86756..b7c15e1 100644 --- a/molecule/quarkus/converge.yml +++ b/molecule/quarkus/converge.yml @@ -3,10 +3,10 @@ hosts: all vars: keycloak_quarkus_show_deprecation_warnings: false - keycloak_quarkus_admin_pass: "remembertochangeme" - keycloak_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" keycloak_realm: TestRealm - keycloak_quarkus_host: instance + keycloak_quarkus_hostname: https://instance:8443 keycloak_quarkus_log: file keycloak_quarkus_log_level: debug # needed for the verify step keycloak_quarkus_https_key_file_enabled: true @@ -22,6 +22,7 @@ keycloak_quarkus_systemd_wait_for_timeout: 20 keycloak_quarkus_systemd_wait_for_delay: 2 keycloak_quarkus_systemd_wait_for_log: true + keycloak_quarkus_restart_health_check: false # would fail because of self-signed cert keycloak_quarkus_providers: - id: http-client spi: connections @@ -37,7 +38,7 @@ repository_url: https://repo1.maven.org/maven2/ # https://mvnrepository.com/artifact/org.keycloak/keycloak-kerberos-federation/24.0.4 group_id: org.keycloak artifact_id: keycloak-kerberos-federation - version: 24.0.4 # optional + version: 26.0.7 # optional # username: myUser # optional # password: myPAT # optional # - id: my-static-theme @@ -51,7 +52,10 @@ roles: - role: keycloak_quarkus - role: keycloak_realm + keycloak_url: http://instance:8080 keycloak_context: '' + keycloak_admin_user: "{{ keycloak_quarkus_bootstrap_admin_user }}" + keycloak_admin_password: "{{ keycloak_quarkus_bootstrap_admin_password }}" keycloak_client_default_roles: - TestRoleAdmin - TestRoleUser diff --git a/molecule/quarkus/molecule.yml b/molecule/quarkus/molecule.yml index c04e300..cf975e5 100644 --- a/molecule/quarkus/molecule.yml +++ b/molecule/quarkus/molecule.yml @@ -3,7 +3,7 @@ driver: name: docker platforms: - name: instance - image: registry.access.redhat.com/ubi8/ubi-init:latest + image: registry.access.redhat.com/ubi9/ubi-init:latest pre_build_image: true privileged: true command: "/usr/sbin/init" @@ -11,6 +11,7 @@ platforms: - "8080/tcp" - "8443/tcp" - "8009/tcp" + - "9000/tcp" published_ports: - 0.0.0.0:8443:8443/tcp provisioner: @@ -30,6 +31,7 @@ provisioner: ansible_python_interpreter: "{{ ansible_playbook_python }}" env: ANSIBLE_FORCE_COLOR: "true" + PYTHONHTTPSVERIFY: 0 verifier: name: ansible scenario: diff --git a/molecule/quarkus/prepare.yml b/molecule/quarkus/prepare.yml index 459bafa..d8ef369 100644 --- a/molecule/quarkus/prepare.yml +++ b/molecule/quarkus/prepare.yml @@ -12,19 +12,19 @@ - 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' delegate_to: localhost - changed_when: False + changed_when: false - name: Create vault directory become: true ansible.builtin.file: state: directory path: "/opt/keycloak/vault" - mode: 0755 + 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' }}" + name: "{{ 'java-21-openjdk-headless' if hera_home | length > 0 else 'openjdk-21-jdk-headless' }}" state: present become: true failed_when: false @@ -41,4 +41,4 @@ ansible.builtin.copy: src: keystore.p12 dest: /opt/keycloak/vault/keystore.p12 - mode: 0444 + mode: '0444' diff --git a/molecule/quarkus/verify.yml b/molecule/quarkus/verify.yml index dd8490f..1d9d2c3 100644 --- a/molecule/quarkus/verify.yml +++ b/molecule/quarkus/verify.yml @@ -1,6 +1,9 @@ --- - name: Verify hosts: all + vars: + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" tasks: - name: Populate service facts ansible.builtin.service_facts: @@ -33,10 +36,10 @@ - name: Verify endpoint URLs ansible.builtin.assert: that: - - (openid_config.stdout | from_json)["backchannel_authentication_endpoint"] == 'https://instance/realms/master/protocol/openid-connect/ext/ciba/auth' - - (openid_config.stdout | from_json)['issuer'] == 'https://instance/realms/master' - - (openid_config.stdout | from_json)['authorization_endpoint'] == 'https://instance/realms/master/protocol/openid-connect/auth' - - (openid_config.stdout | from_json)['token_endpoint'] == 'https://instance/realms/master/protocol/openid-connect/token' + - (openid_config.stdout | from_json)["backchannel_authentication_endpoint"] == 'https://instance:8443/realms/master/protocol/openid-connect/ext/ciba/auth' + - (openid_config.stdout | from_json)['issuer'] == 'https://instance:8443/realms/master' + - (openid_config.stdout | from_json)['authorization_endpoint'] == 'https://instance:8443/realms/master/protocol/openid-connect/auth' + - (openid_config.stdout | from_json)['token_endpoint'] == 'https://instance:8443/realms/master/protocol/openid-connect/token' delegate_to: localhost - name: Check log folder @@ -84,3 +87,42 @@ changed_when: false failed_when: slurped_log.rc != 0 register: slurped_log + + - name: Verify token api call + ansible.builtin.uri: + url: "https://instance:8443/realms/master/protocol/openid-connect/token" + method: POST + body: "client_id=admin-cli&username={{ keycloak_quarkus_bootstrap_admin_user }}&password={{ keycloak_quarkus_bootstrap_admin_password}}&grant_type=password" + validate_certs: no + register: keycloak_auth_response + until: keycloak_auth_response.status == 200 + retries: 2 + delay: 2 + + - name: "Get Clients" + ansible.builtin.uri: + url: "https://instance:8443/admin/realms/TestRealm/clients" + validate_certs: false + headers: + Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" + register: keycloak_clients + + - name: Get client uuid + ansible.builtin.set_fact: + keycloak_client_uuid: "{{ ((keycloak_clients.json | selectattr('clientId', '==', 'TestClient')) | first).id }}" + + - name: "Get Client {{ keycloak_client_uuid }}" + ansible.builtin.uri: + url: "https://instance:8443/admin/realms/TestRealm/clients/{{ keycloak_client_uuid }}" + validate_certs: false + headers: + Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" + register: keycloak_test_client + + - name: "Get Client roles" + ansible.builtin.uri: + url: "https://instance:8443/admin/realms/TestRealm/clients/{{ keycloak_client_uuid }}/roles" + validate_certs: false + headers: + Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" + register: keycloak_test_client_roles diff --git a/molecule/quarkus_ha/converge.yml b/molecule/quarkus_ha/converge.yml index 00246b8..fa5314f 100644 --- a/molecule/quarkus_ha/converge.yml +++ b/molecule/quarkus_ha/converge.yml @@ -3,10 +3,9 @@ hosts: keycloak vars: keycloak_quarkus_show_deprecation_warnings: false - keycloak_quarkus_admin_pass: "remembertochangeme" - keycloak_admin_password: "remembertochangeme" - keycloak_realm: TestRealm - keycloak_quarkus_host: "{{ inventory_hostname }}" + 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 @@ -25,6 +24,6 @@ keycloak_quarkus_restart_strategy: restart/serial.yml keycloak_quarkus_db_user: keycloak keycloak_quarkus_db_pass: mysecretpass - keycloak_quarkus_jdbc_url: jdbc:postgresql://postgres:5432/keycloak + keycloak_quarkus_db_url: jdbc:postgresql://postgres:5432/keycloak roles: - role: keycloak_quarkus diff --git a/molecule/quarkus_ha/molecule.yml b/molecule/quarkus_ha/molecule.yml index 8e07e0f..ed09971 100644 --- a/molecule/quarkus_ha/molecule.yml +++ b/molecule/quarkus_ha/molecule.yml @@ -14,6 +14,7 @@ platforms: port_bindings: - "8080/tcp" - "8443/tcp" + - "9000/tcp" - name: instance2 image: registry.access.redhat.com/ubi9/ubi-init:latest pre_build_image: true @@ -26,6 +27,7 @@ platforms: port_bindings: - "8080/tcp" - "8443/tcp" + - "9000/tcp" - name: postgres image: ubuntu/postgres:14-22.04_beta pre_build_image: true @@ -63,6 +65,7 @@ provisioner: ansible_python_interpreter: "{{ ansible_playbook_python }}" env: ANSIBLE_FORCE_COLOR: "true" + PYTHONHTTPSVERIFY: 0 verifier: name: ansible scenario: diff --git a/molecule/quarkus_upgrade/converge.yml b/molecule/quarkus_upgrade/converge.yml index 6025b7c..9b57436 100644 --- a/molecule/quarkus_upgrade/converge.yml +++ b/molecule/quarkus_upgrade/converge.yml @@ -5,6 +5,6 @@ - vars.yml vars: keycloak_quarkus_show_deprecation_warnings: false - keycloak_quarkus_version: 24.0.3 + keycloak_quarkus_version: 26.0.7 roles: - role: keycloak_quarkus diff --git a/molecule/quarkus_upgrade/molecule.yml b/molecule/quarkus_upgrade/molecule.yml index 77f687f..21782e8 100644 --- a/molecule/quarkus_upgrade/molecule.yml +++ b/molecule/quarkus_upgrade/molecule.yml @@ -13,8 +13,10 @@ platforms: privileged: true port_bindings: - 8080:8080 + - "9000/tcp" published_ports: - 0.0.0.0:8080:8080/TCP + - 0.0.0.0:9000:9000/TCP provisioner: name: ansible playbooks: diff --git a/molecule/quarkus_upgrade/prepare.yml b/molecule/quarkus_upgrade/prepare.yml index bebfc68..a6892e3 100644 --- a/molecule/quarkus_upgrade/prepare.yml +++ b/molecule/quarkus_upgrade/prepare.yml @@ -5,7 +5,7 @@ - vars.yml vars: sudo_pkg_name: sudo - keycloak_quarkus_version: 23.0.7 + keycloak_quarkus_version: 24.0.5 pre_tasks: - name: Install sudo ansible.builtin.apt: diff --git a/molecule/quarkus_upgrade/roles b/molecule/quarkus_upgrade/roles new file mode 120000 index 0000000..b741aa3 --- /dev/null +++ b/molecule/quarkus_upgrade/roles @@ -0,0 +1 @@ +../../roles \ No newline at end of file diff --git a/molecule/quarkus_upgrade/vars.yml b/molecule/quarkus_upgrade/vars.yml index 81d56b5..1567ae4 100644 --- a/molecule/quarkus_upgrade/vars.yml +++ b/molecule/quarkus_upgrade/vars.yml @@ -1,9 +1,8 @@ --- keycloak_quarkus_offline_install: false -keycloak_quarkus_admin_password: "remembertochangeme" -keycloak_quarkus_admin_pass: "remembertochangeme" +keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" keycloak_quarkus_realm: TestRealm -keycloak_quarkus_host: instance +keycloak_quarkus_hostname: http://instance:8080 keycloak_quarkus_log: file keycloak_quarkus_https_key_file_enabled: true keycloak_quarkus_log_target: /tmp/keycloak diff --git a/molecule/quarkus_upgrade/verify.yml b/molecule/quarkus_upgrade/verify.yml index f5edfd0..1c4a0ba 100644 --- a/molecule/quarkus_upgrade/verify.yml +++ b/molecule/quarkus_upgrade/verify.yml @@ -2,7 +2,7 @@ - name: Verify hosts: instance vars: - keycloak_quarkus_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" keycloak_quarkus_port: http://localhost:8080 tasks: - name: Populate service facts @@ -17,14 +17,14 @@ - name: Verify we are running on requested jvm ansible.builtin.shell: | set -eo pipefail - ps -ef | grep 'etc/alternatives/.*17' | grep -v grep + ps -ef | grep 'etc/alternatives/.*21' | grep -v grep changed_when: false - name: Verify token api call ansible.builtin.uri: url: "{{ keycloak_quarkus_port }}/realms/master/protocol/openid-connect/token" method: POST - body: "client_id=admin-cli&username=admin&password={{ keycloak_quarkus_admin_password }}&grant_type=password" + body: "client_id=admin-cli&username=admin&password={{ keycloak_quarkus_bootstrap_admin_password }}&grant_type=password" validate_certs: no register: keycloak_auth_response until: keycloak_auth_response.status == 200 diff --git a/playbooks/keycloak_quarkus.yml b/playbooks/keycloak_quarkus.yml index f2649a5..b8aedf2 100644 --- a/playbooks/keycloak_quarkus.yml +++ b/playbooks/keycloak_quarkus.yml @@ -2,8 +2,8 @@ - name: Playbook for Keycloak X Hosts with HTTPS enabled hosts: all vars: - keycloak_quarkus_admin_pass: "remembertochangeme" - keycloak_quarkus_host: localhost + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_hostname: http://localhost keycloak_quarkus_port: 8443 keycloak_quarkus_log: file keycloak_quarkus_proxy_mode: none diff --git a/playbooks/keycloak_quarkus_dev.yml b/playbooks/keycloak_quarkus_dev.yml index 533db79..c8bb54e 100644 --- a/playbooks/keycloak_quarkus_dev.yml +++ b/playbooks/keycloak_quarkus_dev.yml @@ -2,8 +2,8 @@ - name: Playbook for Keycloak X Hosts in develop mode hosts: all vars: - keycloak_admin_password: "remembertochangeme" - keycloak_quarkus_host: localhost + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_hostname: http://localhost keycloak_quarkus_port: 8080 keycloak_quarkus_log: file keycloak_quarkus_start_dev: true diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 15b6657..128b0fe 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -9,6 +9,7 @@ __metaclass__ = type import json import traceback +import copy from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib.parse import urlencode, quote @@ -18,6 +19,7 @@ from ansible.module_utils.common.text.converters import to_native, to_text URL_REALM_INFO = "{url}/realms/{realm}" URL_REALMS = "{url}/admin/realms" URL_REALM = "{url}/admin/realms/{realm}" +URL_REALM_KEYS_METADATA = "{url}/admin/realms/{realm}/keys" URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" @@ -27,6 +29,9 @@ URL_CLIENT_ROLES = "{url}/admin/realms/{realm}/clients/{id}/roles" URL_CLIENT_ROLE = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}" URL_CLIENT_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}/composites" +URL_CLIENT_ROLE_SCOPE_CLIENTS = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/clients/{scopeid}" +URL_CLIENT_ROLE_SCOPE_REALM = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/realm" + URL_REALM_ROLES = "{url}/admin/realms/{realm}/roles" URL_REALM_ROLE = "{url}/admin/realms/{realm}/roles/{name}" URL_REALM_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm" @@ -49,16 +54,36 @@ URL_CLIENTSCOPE = "{url}/admin/realms/{realm}/client-scopes/{id}" URL_CLIENTSCOPE_PROTOCOLMAPPERS = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models" URL_CLIENTSCOPE_PROTOCOLMAPPER = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models/{mapper_id}" +URL_DEFAULT_CLIENTSCOPES = "{url}/admin/realms/{realm}/default-default-client-scopes" +URL_DEFAULT_CLIENTSCOPE = "{url}/admin/realms/{realm}/default-default-client-scopes/{id}" +URL_OPTIONAL_CLIENTSCOPES = "{url}/admin/realms/{realm}/default-optional-client-scopes" +URL_OPTIONAL_CLIENTSCOPE = "{url}/admin/realms/{realm}/default-optional-client-scopes/{id}" + +URL_CLIENT_DEFAULT_CLIENTSCOPES = "{url}/admin/realms/{realm}/clients/{cid}/default-client-scopes" +URL_CLIENT_DEFAULT_CLIENTSCOPE = "{url}/admin/realms/{realm}/clients/{cid}/default-client-scopes/{id}" +URL_CLIENT_OPTIONAL_CLIENTSCOPES = "{url}/admin/realms/{realm}/clients/{cid}/optional-client-scopes" +URL_CLIENT_OPTIONAL_CLIENTSCOPE = "{url}/admin/realms/{realm}/clients/{cid}/optional-client-scopes/{id}" + URL_CLIENT_GROUP_ROLEMAPPINGS = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}" URL_CLIENT_GROUP_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/available" URL_CLIENT_GROUP_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/composite" URL_USERS = "{url}/admin/realms/{realm}/users" +URL_USER = "{url}/admin/realms/{realm}/users/{id}" +URL_USER_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings" +URL_USER_REALM_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm" +URL_USER_CLIENTS_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients" +URL_USER_CLIENT_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client_id}" +URL_USER_GROUPS = "{url}/admin/realms/{realm}/users/{id}/groups" +URL_USER_GROUP = "{url}/admin/realms/{realm}/users/{id}/groups/{group_id}" + URL_CLIENT_SERVICE_ACCOUNT_USER = "{url}/admin/realms/{realm}/clients/{id}/service-account-user" URL_CLIENT_USER_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}" URL_CLIENT_USER_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/available" URL_CLIENT_USER_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/composite" +URL_REALM_GROUP_ROLEMAPPINGS = "{url}/admin/realms/{realm}/groups/{group}/role-mappings/realm" + URL_CLIENTSECRET = "{url}/admin/realms/{realm}/clients/{id}/client-secret" URL_AUTHENTICATION_FLOWS = "{url}/admin/realms/{realm}/authentication/flows" @@ -71,6 +96,9 @@ URL_AUTHENTICATION_EXECUTION_CONFIG = "{url}/admin/realms/{realm}/authentication URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY = "{url}/admin/realms/{realm}/authentication/executions/{id}/raise-priority" URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY = "{url}/admin/realms/{realm}/authentication/executions/{id}/lower-priority" URL_AUTHENTICATION_CONFIG = "{url}/admin/realms/{realm}/authentication/config/{id}" +URL_AUTHENTICATION_REGISTER_REQUIRED_ACTION = "{url}/admin/realms/{realm}/authentication/register-required-action" +URL_AUTHENTICATION_REQUIRED_ACTIONS = "{url}/admin/realms/{realm}/authentication/required-actions" +URL_AUTHENTICATION_REQUIRED_ACTIONS_ALIAS = "{url}/admin/realms/{realm}/authentication/required-actions/{alias}" URL_IDENTITY_PROVIDERS = "{url}/admin/realms/{realm}/identity-provider/instances" URL_IDENTITY_PROVIDER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}" @@ -80,6 +108,23 @@ URL_IDENTITY_PROVIDER_MAPPER = "{url}/admin/realms/{realm}/identity-provider/ins URL_COMPONENTS = "{url}/admin/realms/{realm}/components" URL_COMPONENT = "{url}/admin/realms/{realm}/components/{id}" +URL_AUTHZ_AUTHORIZATION_SCOPE = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/scope/{id}" +URL_AUTHZ_AUTHORIZATION_SCOPES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/scope" + +# This URL is used for: +# - Querying client authorization permissions +# - Removing client authorization permissions +URL_AUTHZ_POLICIES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/policy" +URL_AUTHZ_POLICY = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/policy/{id}" + +URL_AUTHZ_PERMISSION = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/permission/{permission_type}/{id}" +URL_AUTHZ_PERMISSIONS = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/permission/{permission_type}" + +URL_AUTHZ_RESOURCES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/resource" + +URL_AUTHZ_CUSTOM_POLICY = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/policy/{policy_type}" +URL_AUTHZ_CUSTOM_POLICIES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/policy" + def keycloak_argument_spec(): """ @@ -140,8 +185,7 @@ def get_token(module_params): 'password': auth_password, } # Remove empty items, for instance missing client_secret - payload = dict( - (k, v) for k, v in temp_payload.items() if v is not None) + payload = {k: v for k, v in temp_payload.items() if v is not None} try: r = json.loads(to_native(open_url(auth_url, method='POST', validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout, @@ -194,24 +238,30 @@ def is_struct_included(struct1, struct2, exclude=None): Return True if all element of dict 1 are present in dict 2, return false otherwise. """ if isinstance(struct1, list) and isinstance(struct2, list): + if not struct1 and not struct2: + return True for item1 in struct1: if isinstance(item1, (list, dict)): for item2 in struct2: - if not is_struct_included(item1, item2, exclude): - return False + if is_struct_included(item1, item2, exclude): + break + else: + return False else: if item1 not in struct2: return False return True elif isinstance(struct1, dict) and isinstance(struct2, dict): + if not struct1 and not struct2: + return True try: for key in struct1: if not (exclude and key in exclude): if not is_struct_included(struct1[key], struct2[key], exclude): return False - return True except KeyError: return False + return True elif isinstance(struct1, bool) and isinstance(struct2, bool): return struct1 == struct2 else: @@ -247,8 +297,39 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.fail_open_url(e, msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + except Exception as e: + self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + + def get_realm_keys_metadata_by_id(self, realm='master'): + """Obtain realm public info by id + + :param realm: realm id + + :return: None, or a 'KeysMetadataRepresentation' + (https://www.keycloak.org/docs-api/latest/rest-api/index.html#KeysMetadataRepresentation) + -- a dict containing the keys 'active' and 'keys', the former containing a mapping + from algorithms to key-ids, the latter containing a list of dicts with key + information. + """ + realm_keys_metadata_url = URL_REALM_KEYS_METADATA.format(url=self.baseurl, realm=realm) + + try: + return json.loads(to_native(open_url(realm_keys_metadata_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.fail_open_url(e, msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm %s: %s' % (realm, str(e)), exception=traceback.format_exc()) @@ -272,8 +353,8 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.fail_open_url(e, msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm %s: %s' % (realm, str(e)), exception=traceback.format_exc()) @@ -293,8 +374,8 @@ class KeycloakAPI(object): return open_url(realm_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(realmrep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not update realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.fail_open_url(e, msg='Could not update realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) def create_realm(self, realmrep): """ Create a realm in keycloak @@ -307,8 +388,8 @@ class KeycloakAPI(object): return open_url(realm_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(realmrep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not create realm %s: %s' % (realmrep['id'], str(e)), - exception=traceback.format_exc()) + self.fail_open_url(e, msg='Could not create realm %s: %s' % (realmrep['id'], str(e)), + exception=traceback.format_exc()) def delete_realm(self, realm="master"): """ Delete a realm from Keycloak @@ -322,8 +403,8 @@ class KeycloakAPI(object): return open_url(realm_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not delete realm %s: %s' % (realm, str(e)), - exception=traceback.format_exc()) + self.fail_open_url(e, msg='Could not delete realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) def get_clients(self, realm='master', filter=None): """ Obtains client representations for clients in a realm @@ -344,7 +425,7 @@ class KeycloakAPI(object): self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' % (realm, str(e))) except Exception as e: - self.module.fail_json(msg='Could not obtain list of clients for realm %s: %s' + self.fail_open_url(e, msg='Could not obtain list of clients for realm %s: %s' % (realm, str(e))) def get_client_by_clientid(self, client_id, realm='master'): @@ -377,7 +458,7 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg='Could not obtain client %s for realm %s: %s' + self.fail_open_url(e, msg='Could not obtain client %s for realm %s: %s' % (id, realm, str(e))) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client %s for realm %s: %s' @@ -412,7 +493,7 @@ class KeycloakAPI(object): return open_url(client_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(clientrep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not update client %s in realm %s: %s' + self.fail_open_url(e, msg='Could not update client %s in realm %s: %s' % (id, realm, str(e))) def create_client(self, clientrep, realm="master"): @@ -427,7 +508,7 @@ class KeycloakAPI(object): return open_url(client_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(clientrep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not create client %s in realm %s: %s' + self.fail_open_url(e, msg='Could not create client %s in realm %s: %s' % (clientrep['clientId'], realm, str(e))) def delete_client(self, id, realm="master"): @@ -443,7 +524,7 @@ class KeycloakAPI(object): return open_url(client_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not delete client %s in realm %s: %s' + self.fail_open_url(e, msg='Could not delete client %s in realm %s: %s' % (id, realm, str(e))) def get_client_roles_by_id(self, cid, realm="master"): @@ -459,7 +540,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch rolemappings for client %s in realm %s: %s" + self.fail_open_url(e, msg="Could not fetch rolemappings for client %s in realm %s: %s" % (cid, realm, str(e))) def get_client_role_id_by_name(self, cid, name, realm="master"): @@ -494,12 +575,12 @@ class KeycloakAPI(object): if rid == role['id']: return role except Exception as e: - self.module.fail_json(msg="Could not fetch rolemappings for client %s in group %s, realm %s: %s" + self.fail_open_url(e, msg="Could not fetch rolemappings for client %s in group %s, realm %s: %s" % (cid, gid, realm, str(e))) return None def get_client_group_available_rolemappings(self, gid, cid, realm="master"): - """ Fetch the available role of a client in a specified goup on the Keycloak server. + """ Fetch the available role of a client in a specified group on the Keycloak server. :param gid: ID of the group from which to obtain the rolemappings. :param cid: ID of the client from which to obtain the rolemappings. @@ -512,7 +593,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + self.fail_open_url(e, msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" % (cid, gid, realm, str(e))) def get_client_group_composite_rolemappings(self, gid, cid, realm="master"): @@ -529,7 +610,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + self.fail_open_url(e, msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" % (cid, gid, realm, str(e))) def get_role_by_id(self, rid, realm="master"): @@ -545,7 +626,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch role for id %s in realm %s: %s" + self.fail_open_url(e, msg="Could not fetch role for id %s in realm %s: %s" % (rid, realm, str(e))) def get_client_roles_by_id_composite_rolemappings(self, rid, cid, realm="master"): @@ -562,7 +643,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch role for id %s and cid %s in realm %s: %s" + self.fail_open_url(e, msg="Could not fetch role for id %s and cid %s in realm %s: %s" % (rid, cid, realm, str(e))) def add_client_roles_by_id_composite_rolemapping(self, rid, roles_rep, realm="master"): @@ -578,11 +659,43 @@ class KeycloakAPI(object): open_url(available_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(roles_rep), validate_certs=self.validate_certs, timeout=self.connection_timeout) except Exception as e: - self.module.fail_json(msg="Could not assign roles to composite role %s and realm %s: %s" + self.fail_open_url(e, msg="Could not assign roles to composite role %s and realm %s: %s" % (rid, realm, str(e))) + def add_group_realm_rolemapping(self, gid, role_rep, realm="master"): + """ Add the specified realm role to specified group on the Keycloak server. + + :param gid: ID of the group to add the role mapping. + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + url = URL_REALM_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, group=gid) + try: + open_url(url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.fail_open_url(e, msg="Could add realm role mappings for group %s, realm %s: %s" + % (gid, realm, str(e))) + + def delete_group_realm_rolemapping(self, gid, role_rep, realm="master"): + """ Delete the specified realm role from the specified group on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + url = URL_REALM_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, group=gid) + try: + open_url(url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.fail_open_url(e, msg="Could not delete realm role mappings for group %s, realm %s: %s" + % (gid, realm, str(e))) + def add_group_rolemapping(self, gid, cid, role_rep, realm="master"): - """ Fetch the composite role of a client in a specified goup on the Keycloak server. + """ Fetch the composite role of a client in a specified group on the Keycloak server. :param gid: ID of the group from which to obtain the rolemappings. :param cid: ID of the client from which to obtain the rolemappings. @@ -595,7 +708,7 @@ class KeycloakAPI(object): open_url(available_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), validate_certs=self.validate_certs, timeout=self.connection_timeout) except Exception as e: - self.module.fail_json(msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + self.fail_open_url(e, msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" % (cid, gid, realm, str(e))) def delete_group_rolemapping(self, gid, cid, role_rep, realm="master"): @@ -612,7 +725,7 @@ class KeycloakAPI(object): open_url(available_rolemappings_url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), validate_certs=self.validate_certs, timeout=self.connection_timeout) except Exception as e: - self.module.fail_json(msg="Could not delete available rolemappings for client %s in group %s, realm %s: %s" + self.fail_open_url(e, msg="Could not delete available rolemappings for client %s in group %s, realm %s: %s" % (cid, gid, realm, str(e))) def get_client_user_rolemapping_by_id(self, uid, cid, rid, realm='master'): @@ -633,7 +746,7 @@ class KeycloakAPI(object): if rid == role['id']: return role except Exception as e: - self.module.fail_json(msg="Could not fetch rolemappings for client %s and user %s, realm %s: %s" + self.fail_open_url(e, msg="Could not fetch rolemappings for client %s and user %s, realm %s: %s" % (cid, uid, realm, str(e))) return None @@ -651,7 +764,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch effective rolemappings for client %s and user %s, realm %s: %s" + self.fail_open_url(e, msg="Could not fetch effective rolemappings for client %s and user %s, realm %s: %s" % (cid, uid, realm, str(e))) def get_client_user_composite_rolemappings(self, uid, cid, realm="master"): @@ -668,7 +781,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch available rolemappings for user %s of realm %s: %s" + self.fail_open_url(e, msg="Could not fetch available rolemappings for user %s of realm %s: %s" % (uid, realm, str(e))) def get_realm_user_rolemapping_by_id(self, uid, rid, realm='master'): @@ -688,7 +801,7 @@ class KeycloakAPI(object): if rid == role['id']: return role except Exception as e: - self.module.fail_json(msg="Could not fetch rolemappings for user %s, realm %s: %s" + self.fail_open_url(e, msg="Could not fetch rolemappings for user %s, realm %s: %s" % (uid, realm, str(e))) return None @@ -705,7 +818,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch available rolemappings for user %s of realm %s: %s" + self.fail_open_url(e, msg="Could not fetch available rolemappings for user %s of realm %s: %s" % (uid, realm, str(e))) def get_realm_user_composite_rolemappings(self, uid, realm="master"): @@ -721,7 +834,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch effective rolemappings for user %s, realm %s: %s" + self.fail_open_url(e, msg="Could not fetch effective rolemappings for user %s, realm %s: %s" % (uid, realm, str(e))) def get_user_by_username(self, username, realm="master"): @@ -734,13 +847,21 @@ class KeycloakAPI(object): users_url = URL_USERS.format(url=self.baseurl, realm=realm) users_url += '?username=%s&exact=true' % username try: - return json.loads(to_native(open_url(users_url, method='GET', headers=self.restheaders, timeout=self.connection_timeout, - validate_certs=self.validate_certs).read())) + userrep = None + users = json.loads(to_native(open_url(users_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + for user in users: + if user['username'] == username: + userrep = user + break + return userrep + except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain the user for realm %s and username %s: %s' % (realm, username, str(e))) except Exception as e: - self.module.fail_json(msg='Could not obtain the user for realm %s and username %s: %s' + self.fail_open_url(e, msg='Could not obtain the user for realm %s and username %s: %s' % (realm, username, str(e))) def get_service_account_user_by_client_id(self, client_id, realm="master"): @@ -754,13 +875,14 @@ class KeycloakAPI(object): service_account_user_url = URL_CLIENT_SERVICE_ACCOUNT_USER.format(url=self.baseurl, realm=realm, id=cid) try: - return json.loads(to_native(open_url(service_account_user_url, method='GET', headers=self.restheaders, timeout=self.connection_timeout, + return json.loads(to_native(open_url(service_account_user_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain the service-account-user for realm %s and client_id %s: %s' % (realm, client_id, str(e))) except Exception as e: - self.module.fail_json(msg='Could not obtain the service-account-user for realm %s and client_id %s: %s' + self.fail_open_url(e, msg='Could not obtain the service-account-user for realm %s and client_id %s: %s' % (realm, client_id, str(e))) def add_user_rolemapping(self, uid, cid, role_rep, realm="master"): @@ -778,7 +900,7 @@ class KeycloakAPI(object): open_url(user_realm_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), validate_certs=self.validate_certs, timeout=self.connection_timeout) except Exception as e: - self.module.fail_json(msg="Could not map roles to userId %s for realm %s and roles %s: %s" + self.fail_open_url(e, msg="Could not map roles to userId %s for realm %s and roles %s: %s" % (uid, realm, json.dumps(role_rep), str(e))) else: user_client_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid, client=cid) @@ -786,7 +908,7 @@ class KeycloakAPI(object): open_url(user_client_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), validate_certs=self.validate_certs, timeout=self.connection_timeout) except Exception as e: - self.module.fail_json(msg="Could not map roles to userId %s for client %s, realm %s and roles %s: %s" + self.fail_open_url(e, msg="Could not map roles to userId %s for client %s, realm %s and roles %s: %s" % (cid, uid, realm, json.dumps(role_rep), str(e))) def delete_user_rolemapping(self, uid, cid, role_rep, realm="master"): @@ -804,7 +926,7 @@ class KeycloakAPI(object): open_url(user_realm_rolemappings_url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), validate_certs=self.validate_certs, timeout=self.connection_timeout) except Exception as e: - self.module.fail_json(msg="Could not remove roles %s from userId %s, realm %s: %s" + self.fail_open_url(e, msg="Could not remove roles %s from userId %s, realm %s: %s" % (json.dumps(role_rep), uid, realm, str(e))) else: user_client_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid, client=cid) @@ -812,7 +934,7 @@ class KeycloakAPI(object): open_url(user_client_rolemappings_url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), validate_certs=self.validate_certs, timeout=self.connection_timeout) except Exception as e: - self.module.fail_json(msg="Could not remove roles %s for client %s from userId %s, realm %s: %s" + self.fail_open_url(e, msg="Could not remove roles %s for client %s from userId %s, realm %s: %s" % (json.dumps(role_rep), cid, uid, realm, str(e))) def get_client_templates(self, realm='master'): @@ -830,7 +952,7 @@ class KeycloakAPI(object): self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of client templates for realm %s: %s' % (realm, str(e))) except Exception as e: - self.module.fail_json(msg='Could not obtain list of client templates for realm %s: %s' + self.fail_open_url(e, msg='Could not obtain list of client templates for realm %s: %s' % (realm, str(e))) def get_client_template_by_id(self, id, realm='master'): @@ -849,7 +971,7 @@ class KeycloakAPI(object): self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client templates %s for realm %s: %s' % (id, realm, str(e))) except Exception as e: - self.module.fail_json(msg='Could not obtain client template %s for realm %s: %s' + self.fail_open_url(e, msg='Could not obtain client template %s for realm %s: %s' % (id, realm, str(e))) def get_client_template_by_name(self, name, realm='master'): @@ -892,7 +1014,7 @@ class KeycloakAPI(object): return open_url(url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(clienttrep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not update client template %s in realm %s: %s' + self.fail_open_url(e, msg='Could not update client template %s in realm %s: %s' % (id, realm, str(e))) def create_client_template(self, clienttrep, realm="master"): @@ -907,7 +1029,7 @@ class KeycloakAPI(object): return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(clienttrep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not create client template %s in realm %s: %s' + self.fail_open_url(e, msg='Could not create client template %s in realm %s: %s' % (clienttrep['clientId'], realm, str(e))) def delete_client_template(self, id, realm="master"): @@ -923,7 +1045,7 @@ class KeycloakAPI(object): return open_url(url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not delete client template %s in realm %s: %s' + self.fail_open_url(e, msg='Could not delete client template %s in realm %s: %s' % (id, realm, str(e))) def get_clientscopes(self, realm="master"): @@ -941,7 +1063,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch list of clientscopes in realm %s: %s" + self.fail_open_url(e, msg="Could not fetch list of clientscopes in realm %s: %s" % (realm, str(e))) def get_clientscope_by_clientscopeid(self, cid, realm="master"): @@ -963,7 +1085,7 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg="Could not fetch clientscope %s in realm %s: %s" + self.fail_open_url(e, msg="Could not fetch clientscope %s in realm %s: %s" % (cid, realm, str(e))) except Exception as e: self.module.fail_json(msg="Could not clientscope group %s in realm %s: %s" @@ -1004,7 +1126,7 @@ class KeycloakAPI(object): return open_url(clientscopes_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(clientscoperep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg="Could not create clientscope %s in realm %s: %s" + self.fail_open_url(e, msg="Could not create clientscope %s in realm %s: %s" % (clientscoperep['name'], realm, str(e))) def update_clientscope(self, clientscoperep, realm="master"): @@ -1020,7 +1142,7 @@ class KeycloakAPI(object): data=json.dumps(clientscoperep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not update clientscope %s in realm %s: %s' + self.fail_open_url(e, msg='Could not update clientscope %s in realm %s: %s' % (clientscoperep['name'], realm, str(e))) def delete_clientscope(self, name=None, cid=None, realm="master"): @@ -1058,7 +1180,7 @@ class KeycloakAPI(object): validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg="Unable to delete clientscope %s: %s" % (cid, str(e))) + self.fail_open_url(e, msg="Unable to delete clientscope %s: %s" % (cid, str(e))) def get_clientscope_protocolmappers(self, cid, realm="master"): """ Fetch the name and ID of all clientscopes on the Keycloak server. @@ -1076,7 +1198,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch list of protocolmappers in realm %s: %s" + self.fail_open_url(e, msg="Could not fetch list of protocolmappers in realm %s: %s" % (realm, str(e))) def get_clientscope_protocolmapper_by_protocolmapperid(self, pid, cid, realm="master"): @@ -1100,7 +1222,7 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" + self.fail_open_url(e, msg="Could not fetch protocolmapper %s in realm %s: %s" % (pid, realm, str(e))) except Exception as e: self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" @@ -1143,7 +1265,7 @@ class KeycloakAPI(object): return open_url(protocolmappers_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(mapper_rep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg="Could not create protocolmapper %s in realm %s: %s" + self.fail_open_url(e, msg="Could not create protocolmapper %s in realm %s: %s" % (mapper_rep['name'], realm, str(e))) def update_clientscope_protocolmappers(self, cid, mapper_rep, realm="master"): @@ -1160,9 +1282,134 @@ class KeycloakAPI(object): data=json.dumps(mapper_rep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not update protocolmappers for clientscope %s in realm %s: %s' + self.fail_open_url(e, msg='Could not update protocolmappers for clientscope %s in realm %s: %s' % (mapper_rep, realm, str(e))) + def get_default_clientscopes(self, realm, client_id=None): + """Fetch the name and ID of all clientscopes on the Keycloak server. + + To fetch the full data of the client scope, make a subsequent call to + get_clientscope_by_clientscopeid, passing in the ID of the client scope you wish to return. + + :param realm: Realm in which the clientscope resides. + :param client_id: The client in which the clientscope resides. + :return The default clientscopes of this realm or client + """ + url = URL_DEFAULT_CLIENTSCOPES if client_id is None else URL_CLIENT_DEFAULT_CLIENTSCOPES + return self._get_clientscopes_of_type(realm, url, 'default', client_id) + + def get_optional_clientscopes(self, realm, client_id=None): + """Fetch the name and ID of all clientscopes on the Keycloak server. + + To fetch the full data of the client scope, make a subsequent call to + get_clientscope_by_clientscopeid, passing in the ID of the client scope you wish to return. + + :param realm: Realm in which the clientscope resides. + :param client_id: The client in which the clientscope resides. + :return The optional clientscopes of this realm or client + """ + url = URL_OPTIONAL_CLIENTSCOPES if client_id is None else URL_CLIENT_OPTIONAL_CLIENTSCOPES + return self._get_clientscopes_of_type(realm, url, 'optional', client_id) + + def _get_clientscopes_of_type(self, realm, url_template, scope_type, client_id=None): + """Fetch the name and ID of all clientscopes on the Keycloak server. + + To fetch the full data of the client scope, make a subsequent call to + get_clientscope_by_clientscopeid, passing in the ID of the client scope you wish to return. + + :param realm: Realm in which the clientscope resides. + :param url_template the template for the right type + :param scope_type this can be either optional or default + :param client_id: The client in which the clientscope resides. + :return The clientscopes of the specified type of this realm + """ + if client_id is None: + clientscopes_url = url_template.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(clientscopes_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch list of %s clientscopes in realm %s: %s" % (scope_type, realm, str(e))) + else: + cid = self.get_client_id(client_id=client_id, realm=realm) + clientscopes_url = url_template.format(url=self.baseurl, realm=realm, cid=cid) + try: + return json.loads(to_native(open_url(clientscopes_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch list of %s clientscopes in client %s: %s" % (scope_type, client_id, clientscopes_url)) + + def _decide_url_type_clientscope(self, client_id=None, scope_type="default"): + """Decides which url to use. + :param scope_type this can be either optional or default + :param client_id: The client in which the clientscope resides. + """ + if client_id is None: + if scope_type == "default": + return URL_DEFAULT_CLIENTSCOPE + if scope_type == "optional": + return URL_OPTIONAL_CLIENTSCOPE + else: + if scope_type == "default": + return URL_CLIENT_DEFAULT_CLIENTSCOPE + if scope_type == "optional": + return URL_CLIENT_OPTIONAL_CLIENTSCOPE + + def add_default_clientscope(self, id, realm="master", client_id=None): + """Add a client scope as default either on realm or client level. + + :param id: Client scope Id. + :param realm: Realm in which the clientscope resides. + :param client_id: The client in which the clientscope resides. + """ + self._action_type_clientscope(id, client_id, "default", realm, 'add') + + def add_optional_clientscope(self, id, realm="master", client_id=None): + """Add a client scope as optional either on realm or client level. + + :param id: Client scope Id. + :param realm: Realm in which the clientscope resides. + :param client_id: The client in which the clientscope resides. + """ + self._action_type_clientscope(id, client_id, "optional", realm, 'add') + + def delete_default_clientscope(self, id, realm="master", client_id=None): + """Remove a client scope as default either on realm or client level. + + :param id: Client scope Id. + :param realm: Realm in which the clientscope resides. + :param client_id: The client in which the clientscope resides. + """ + self._action_type_clientscope(id, client_id, "default", realm, 'delete') + + def delete_optional_clientscope(self, id, realm="master", client_id=None): + """Remove a client scope as optional either on realm or client level. + + :param id: Client scope Id. + :param realm: Realm in which the clientscope resides. + :param client_id: The client in which the clientscope resides. + """ + self._action_type_clientscope(id, client_id, "optional", realm, 'delete') + + def _action_type_clientscope(self, id=None, client_id=None, scope_type="default", realm="master", action='add'): + """ Delete or add a clientscope of type. + :param name: The name of the clientscope. A lookup will be performed to retrieve the clientscope ID. + :param client_id: The ID of the clientscope (preferred to name). + :param scope_type 'default' or 'optional' + :param realm: The realm in which this group resides, default "master". + """ + cid = None if client_id is None else self.get_client_id(client_id=client_id, realm=realm) + # should have a good cid by here. + clientscope_type_url = self._decide_url_type_clientscope(client_id, scope_type).format(realm=realm, id=id, cid=cid, url=self.baseurl) + try: + method = 'PUT' if action == "add" else 'DELETE' + return open_url(clientscope_type_url, method=method, http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + + except Exception as e: + place = 'realm' if client_id is None else 'client ' + client_id + self.fail_open_url(e, msg="Unable to %s %s clientscope %s @ %s : %s" % (action, scope_type, id, place, str(e))) + def create_clientsecret(self, id, realm="master"): """ Generate a new client secret by id @@ -1173,14 +1420,15 @@ class KeycloakAPI(object): clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id) try: - return json.loads(to_native(open_url(clientsecret_url, method='POST', headers=self.restheaders, timeout=self.connection_timeout, + return json.loads(to_native(open_url(clientsecret_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except HTTPError as e: if e.code == 404: return None else: - self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s' + self.fail_open_url(e, msg='Could not obtain clientsecret of client %s for realm %s: %s' % (id, realm, str(e))) except Exception as e: self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s' @@ -1196,14 +1444,15 @@ class KeycloakAPI(object): clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id) try: - return json.loads(to_native(open_url(clientsecret_url, method='GET', headers=self.restheaders, timeout=self.connection_timeout, + return json.loads(to_native(open_url(clientsecret_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except HTTPError as e: if e.code == 404: return None else: - self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s' + self.fail_open_url(e, msg='Could not obtain clientsecret of client %s for realm %s: %s' % (id, realm, str(e))) except Exception as e: self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s' @@ -1223,7 +1472,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg="Could not fetch list of groups in realm %s: %s" + self.fail_open_url(e, msg="Could not fetch list of groups in realm %s: %s" % (realm, str(e))) def get_group_by_groupid(self, gid, realm="master"): @@ -1244,7 +1493,7 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" + self.fail_open_url(e, msg="Could not fetch group %s in realm %s: %s" % (gid, realm, str(e))) except Exception as e: self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" @@ -1339,7 +1588,7 @@ class KeycloakAPI(object): def get_subgroup_direct_parent(self, parents, realm="master", children_to_resolve=None): """ Get keycloak direct parent group API object for a given chain of parents. - To succesfully work the API for subgroups we actually dont need + To successfully work the API for subgroups we actually don't need to "walk the whole tree" for nested groups but only need to know the ID for the direct predecessor of current subgroup. This method will guarantee us this information getting there with @@ -1391,7 +1640,7 @@ class KeycloakAPI(object): return open_url(groups_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(grouprep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg="Could not create group %s in realm %s: %s" + self.fail_open_url(e, msg="Could not create group %s in realm %s: %s" % (grouprep['name'], realm, str(e))) def create_subgroup(self, parents, grouprep, realm="master"): @@ -1419,7 +1668,7 @@ class KeycloakAPI(object): return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(grouprep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg="Could not create subgroup %s for parent group %s in realm %s: %s" + self.fail_open_url(e, msg="Could not create subgroup %s for parent group %s in realm %s: %s" % (grouprep['name'], parent_id, realm, str(e))) def update_group(self, grouprep, realm="master"): @@ -1434,7 +1683,7 @@ class KeycloakAPI(object): return open_url(group_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(grouprep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not update group %s in realm %s: %s' + self.fail_open_url(e, msg='Could not update group %s in realm %s: %s' % (grouprep['name'], realm, str(e))) def delete_group(self, name=None, groupid=None, realm="master"): @@ -1471,7 +1720,7 @@ class KeycloakAPI(object): return open_url(group_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg="Unable to delete group %s: %s" % (groupid, str(e))) + self.fail_open_url(e, msg="Unable to delete group %s: %s" % (groupid, str(e))) def get_realm_roles(self, realm='master'): """ Obtains role representations for roles in a realm @@ -1488,7 +1737,7 @@ class KeycloakAPI(object): self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of roles for realm %s: %s' % (realm, str(e))) except Exception as e: - self.module.fail_json(msg='Could not obtain list of roles for realm %s: %s' + self.fail_open_url(e, msg='Could not obtain list of roles for realm %s: %s' % (realm, str(e))) def get_realm_role(self, name, realm='master'): @@ -1498,7 +1747,7 @@ class KeycloakAPI(object): :param name: Name of the role to fetch. :param realm: Realm in which the role resides; default 'master'. """ - role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name)) + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name, safe='')) try: return json.loads(to_native(open_url(role_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) @@ -1506,7 +1755,7 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg='Could not fetch role %s in realm %s: %s' + self.fail_open_url(e, msg='Could not fetch role %s in realm %s: %s' % (name, realm, str(e))) except Exception as e: self.module.fail_json(msg='Could not fetch role %s in realm %s: %s' @@ -1520,10 +1769,13 @@ class KeycloakAPI(object): """ roles_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) try: + if "composites" in rolerep: + keycloak_compatible_composites = self.convert_role_composites(rolerep["composites"]) + rolerep["composites"] = keycloak_compatible_composites return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(rolerep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not create role %s in realm %s: %s' + self.fail_open_url(e, msg='Could not create role %s in realm %s: %s' % (rolerep['name'], realm, str(e))) def update_realm_role(self, rolerep, realm='master'): @@ -1532,26 +1784,138 @@ class KeycloakAPI(object): :param rolerep: A RoleRepresentation of the updated role. :return HTTPResponse object on success """ - role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(rolerep['name'])) + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(rolerep['name']), safe='') try: - return open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(rolerep), validate_certs=self.validate_certs) + composites = None + if "composites" in rolerep: + composites = copy.deepcopy(rolerep["composites"]) + del rolerep["composites"] + role_response = open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + if composites is not None: + self.update_role_composites(rolerep=rolerep, composites=composites, realm=realm) + return role_response except Exception as e: - self.module.fail_json(msg='Could not update role %s in realm %s: %s' + self.fail_open_url(e, msg='Could not update role %s in realm %s: %s' % (rolerep['name'], realm, str(e))) + def get_role_composites(self, rolerep, clientid=None, realm='master'): + composite_url = '' + try: + if clientid is not None: + client = self.get_client_by_clientid(client_id=clientid, realm=realm) + cid = client['id'] + composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"], safe='')) + else: + composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe='')) + # Get existing composites + return json.loads(to_native(open_url( + composite_url, + method='GET', + http_agent=self.http_agent, + headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg='Could not get role %s composites in realm %s: %s' + % (rolerep['name'], realm, str(e))) + + def create_role_composites(self, rolerep, composites, clientid=None, realm='master'): + composite_url = '' + try: + if clientid is not None: + client = self.get_client_by_clientid(client_id=clientid, realm=realm) + cid = client['id'] + composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"], safe='')) + else: + composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe='')) + # Get existing composites + # create new composites + return open_url(composite_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(composites), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not create role %s composites in realm %s: %s' + % (rolerep['name'], realm, str(e))) + + def delete_role_composites(self, rolerep, composites, clientid=None, realm='master'): + composite_url = '' + try: + if clientid is not None: + client = self.get_client_by_clientid(client_id=clientid, realm=realm) + cid = client['id'] + composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"], safe='')) + else: + composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"], safe='')) + # Get existing composites + # create new composites + return open_url(composite_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(composites), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not create role %s composites in realm %s: %s' + % (rolerep['name'], realm, str(e))) + + def update_role_composites(self, rolerep, composites, clientid=None, realm='master'): + # Get existing composites + existing_composites = self.get_role_composites(rolerep=rolerep, clientid=clientid, realm=realm) + composites_to_be_created = [] + composites_to_be_deleted = [] + for composite in composites: + composite_found = False + existing_composite_client = None + for existing_composite in existing_composites: + if existing_composite["clientRole"]: + existing_composite_client = self.get_client_by_id(existing_composite["containerId"], realm=realm) + if ("client_id" in composite + and composite['client_id'] is not None + and existing_composite_client["clientId"] == composite["client_id"] + and composite["name"] == existing_composite["name"]): + composite_found = True + break + else: + if (("client_id" not in composite or composite['client_id'] is None) + and composite["name"] == existing_composite["name"]): + composite_found = True + break + if (not composite_found and ('state' not in composite or composite['state'] == 'present')): + if "client_id" in composite and composite['client_id'] is not None: + client_roles = self.get_client_roles(clientid=composite['client_id'], realm=realm) + for client_role in client_roles: + if client_role['name'] == composite['name']: + composites_to_be_created.append(client_role) + break + else: + realm_role = self.get_realm_role(name=composite["name"], realm=realm) + composites_to_be_created.append(realm_role) + elif composite_found and 'state' in composite and composite['state'] == 'absent': + if "client_id" in composite and composite['client_id'] is not None: + client_roles = self.get_client_roles(clientid=composite['client_id'], realm=realm) + for client_role in client_roles: + if client_role['name'] == composite['name']: + composites_to_be_deleted.append(client_role) + break + else: + realm_role = self.get_realm_role(name=composite["name"], realm=realm) + composites_to_be_deleted.append(realm_role) + + if len(composites_to_be_created) > 0: + # create new composites + self.create_role_composites(rolerep=rolerep, composites=composites_to_be_created, clientid=clientid, realm=realm) + if len(composites_to_be_deleted) > 0: + # delete new composites + self.delete_role_composites(rolerep=rolerep, composites=composites_to_be_deleted, clientid=clientid, realm=realm) + def delete_realm_role(self, name, realm='master'): """ Delete a realm role. :param name: The name of the role. :param realm: The realm in which this role resides, default "master". """ - role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name)) + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name, safe='')) try: return open_url(role_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Unable to delete role %s in realm %s: %s' + self.fail_open_url(e, msg='Unable to delete role %s in realm %s: %s' % (name, realm, str(e))) def get_client_roles(self, clientid, realm='master'): @@ -1574,7 +1938,7 @@ class KeycloakAPI(object): self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of roles for client %s in realm %s: %s' % (clientid, realm, str(e))) except Exception as e: - self.module.fail_json(msg='Could not obtain list of roles for client %s in realm %s: %s' + self.fail_open_url(e, msg='Could not obtain list of roles for client %s in realm %s: %s' % (clientid, realm, str(e))) def get_client_role(self, name, clientid, realm='master'): @@ -1590,7 +1954,7 @@ class KeycloakAPI(object): if cid is None: self.module.fail_json(msg='Could not find client %s in realm %s' % (clientid, realm)) - role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name, safe='')) try: return json.loads(to_native(open_url(role_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) @@ -1598,7 +1962,7 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg='Could not fetch role %s in client %s of realm %s: %s' + self.fail_open_url(e, msg='Could not fetch role %s in client %s of realm %s: %s' % (name, clientid, realm, str(e))) except Exception as e: self.module.fail_json(msg='Could not fetch role %s for client %s in realm %s: %s' @@ -1618,12 +1982,30 @@ class KeycloakAPI(object): % (clientid, realm)) roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) try: + if "composites" in rolerep: + keycloak_compatible_composites = self.convert_role_composites(rolerep["composites"]) + rolerep["composites"] = keycloak_compatible_composites return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(rolerep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not create role %s for client %s in realm %s: %s' + self.fail_open_url(e, msg='Could not create role %s for client %s in realm %s: %s' % (rolerep['name'], clientid, realm, str(e))) + def convert_role_composites(self, composites): + keycloak_compatible_composites = { + 'client': {}, + 'realm': [] + } + for composite in composites: + if 'state' not in composite or composite['state'] == 'present': + if "client_id" in composite and composite["client_id"] is not None: + if composite["client_id"] not in keycloak_compatible_composites["client"]: + keycloak_compatible_composites["client"][composite["client_id"]] = [] + keycloak_compatible_composites["client"][composite["client_id"]].append(composite["name"]) + else: + keycloak_compatible_composites["realm"].append(composite["name"]) + return keycloak_compatible_composites + def update_client_role(self, rolerep, clientid, realm="master"): """ Update an existing client role. @@ -1636,12 +2018,19 @@ class KeycloakAPI(object): if cid is None: self.module.fail_json(msg='Could not find client %s in realm %s' % (clientid, realm)) - role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep['name'])) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep['name'], safe='')) try: - return open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(rolerep), validate_certs=self.validate_certs) + composites = None + if "composites" in rolerep: + composites = copy.deepcopy(rolerep["composites"]) + del rolerep['composites'] + update_role_response = open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + if composites is not None: + self.update_role_composites(rolerep=rolerep, clientid=clientid, composites=composites, realm=realm) + return update_role_response except Exception as e: - self.module.fail_json(msg='Could not update role %s for client %s in realm %s: %s' + self.fail_open_url(e, msg='Could not update role %s for client %s in realm %s: %s' % (rolerep['name'], clientid, realm, str(e))) def delete_client_role(self, name, clientid, realm="master"): @@ -1655,12 +2044,12 @@ class KeycloakAPI(object): if cid is None: self.module.fail_json(msg='Could not find client %s in realm %s' % (clientid, realm)) - role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name, safe='')) try: return open_url(role_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Unable to delete role %s for client %s in realm %s: %s' + self.fail_open_url(e, msg='Unable to delete role %s for client %s in realm %s: %s' % (name, clientid, realm, str(e))) def get_authentication_flow_by_alias(self, alias, realm='master'): @@ -1682,7 +2071,7 @@ class KeycloakAPI(object): break return authentication_flow except Exception as e: - self.module.fail_json(msg="Unable get authentication flow %s: %s" % (alias, str(e))) + self.fail_open_url(e, msg="Unable get authentication flow %s: %s" % (alias, str(e))) def delete_authentication_flow_by_id(self, id, realm='master'): """ @@ -1697,8 +2086,8 @@ class KeycloakAPI(object): return open_url(flow_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not delete authentication flow %s in realm %s: %s' - % (id, realm, str(e))) + self.fail_open_url(e, msg='Could not delete authentication flow %s in realm %s: %s' + % (id, realm, str(e))) def copy_auth_flow(self, config, realm='master'): """ @@ -1715,7 +2104,7 @@ class KeycloakAPI(object): URL_AUTHENTICATION_FLOW_COPY.format( url=self.baseurl, realm=realm, - copyfrom=quote(config["copyFrom"])), + copyfrom=quote(config["copyFrom"], safe='')), method='POST', http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(new_name), @@ -1734,8 +2123,8 @@ class KeycloakAPI(object): return flow return None except Exception as e: - self.module.fail_json(msg='Could not copy authentication flow %s in realm %s: %s' - % (config["alias"], realm, str(e))) + self.fail_open_url(e, msg='Could not copy authentication flow %s in realm %s: %s' + % (config["alias"], realm, str(e))) def create_empty_auth_flow(self, config, realm='master'): """ @@ -1774,8 +2163,8 @@ class KeycloakAPI(object): return flow return None except Exception as e: - self.module.fail_json(msg='Could not create empty authentication flow %s in realm %s: %s' - % (config["alias"], realm, str(e))) + self.fail_open_url(e, msg='Could not create empty authentication flow %s in realm %s: %s' + % (config["alias"], realm, str(e))) def update_authentication_executions(self, flowAlias, updatedExec, realm='master'): """ Update authentication executions @@ -1789,12 +2178,15 @@ class KeycloakAPI(object): URL_AUTHENTICATION_FLOW_EXECUTIONS.format( url=self.baseurl, realm=realm, - flowalias=quote(flowAlias)), + flowalias=quote(flowAlias, safe='')), method='PUT', http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(updatedExec), timeout=self.connection_timeout, validate_certs=self.validate_certs) + except HTTPError as e: + self.fail_open_url(e, msg="Unable to update execution '%s': %s: %s %s" + % (flowAlias, repr(e), ";".join([e.url, e.msg, str(e.code), str(e.hdrs)]), str(updatedExec))) except Exception as e: self.module.fail_json(msg="Unable to update executions %s: %s" % (updatedExec, str(e))) @@ -1817,9 +2209,9 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg="Unable to add authenticationConfig %s: %s" % (executionId, str(e))) + self.fail_open_url(e, msg="Unable to add authenticationConfig %s: %s" % (executionId, str(e))) - def create_subflow(self, subflowName, flowAlias, realm='master'): + def create_subflow(self, subflowName, flowAlias, realm='master', flowType='basic-flow'): """ Create new sublow on the flow :param subflowName: name of the subflow to create @@ -1830,19 +2222,19 @@ class KeycloakAPI(object): newSubFlow = {} newSubFlow["alias"] = subflowName newSubFlow["provider"] = "registration-page-form" - newSubFlow["type"] = "basic-flow" + newSubFlow["type"] = flowType open_url( URL_AUTHENTICATION_FLOW_EXECUTIONS_FLOW.format( url=self.baseurl, realm=realm, - flowalias=quote(flowAlias)), + flowalias=quote(flowAlias, safe='')), method='POST', http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(newSubFlow), timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg="Unable to create new subflow %s: %s" % (subflowName, str(e))) + self.fail_open_url(e, msg="Unable to create new subflow %s: %s" % (subflowName, str(e))) def create_execution(self, execution, flowAlias, realm='master'): """ Create new execution on the flow @@ -1859,14 +2251,17 @@ class KeycloakAPI(object): URL_AUTHENTICATION_FLOW_EXECUTIONS_EXECUTION.format( url=self.baseurl, realm=realm, - flowalias=quote(flowAlias)), + flowalias=quote(flowAlias, safe='')), method='POST', http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(newExec), timeout=self.connection_timeout, validate_certs=self.validate_certs) + except HTTPError as e: + self.fail_open_url(e, msg="Unable to create new execution '%s' %s: %s: %s %s" + % (flowAlias, execution["providerId"], repr(e), ";".join([e.url, e.msg, str(e.code), str(e.hdrs)]), str(newExec))) except Exception as e: - self.module.fail_json(msg="Unable to create new execution %s: %s" % (execution["provider"], str(e))) + self.module.fail_json(msg="Unable to create new execution '%s' %s: %s" % (flowAlias, execution["providerId"], repr(e))) def change_execution_priority(self, executionId, diff, realm='master'): """ Raise or lower execution priority of diff time @@ -1900,7 +2295,7 @@ class KeycloakAPI(object): timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg="Unable to change execution priority %s: %s" % (executionId, str(e))) + self.fail_open_url(e, msg="Unable to change execution priority %s: %s" % (executionId, str(e))) def get_executions_representation(self, config, realm='master'): """ @@ -1916,7 +2311,7 @@ class KeycloakAPI(object): URL_AUTHENTICATION_FLOW_EXECUTIONS.format( url=self.baseurl, realm=realm, - flowalias=quote(config["alias"])), + flowalias=quote(config["alias"], safe='')), method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, @@ -1937,8 +2332,121 @@ class KeycloakAPI(object): execution["authenticationConfig"] = execConfig return executions except Exception as e: - self.module.fail_json(msg='Could not get executions for authentication flow %s in realm %s: %s' - % (config["alias"], realm, str(e))) + self.fail_open_url(e, msg='Could not get executions for authentication flow %s in realm %s: %s' + % (config["alias"], realm, str(e))) + + def get_required_actions(self, realm='master'): + """ + Get required actions. + :param realm: Realm name (not id). + :return: List of representations of the required actions. + """ + + try: + required_actions = json.load( + open_url( + URL_AUTHENTICATION_REQUIRED_ACTIONS.format( + url=self.baseurl, + realm=realm + ), + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs + ) + ) + + return required_actions + except Exception: + return None + + def register_required_action(self, rep, realm='master'): + """ + Register required action. + :param rep: JSON containing 'providerId', and 'name' attributes. + :param realm: Realm name (not id). + :return: Representation of the required action. + """ + + data = { + 'name': rep['name'], + 'providerId': rep['providerId'] + } + + try: + return open_url( + URL_AUTHENTICATION_REGISTER_REQUIRED_ACTION.format( + url=self.baseurl, + realm=realm + ), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(data), + timeout=self.connection_timeout, + validate_certs=self.validate_certs + ) + except Exception as e: + self.fail_open_url( + e, + msg='Unable to register required action %s in realm %s: %s' + % (rep["name"], realm, str(e)) + ) + + def update_required_action(self, alias, rep, realm='master'): + """ + Update required action. + :param alias: Alias of required action. + :param rep: JSON describing new state of required action. + :param realm: Realm name (not id). + :return: HTTPResponse object on success. + """ + + try: + return open_url( + URL_AUTHENTICATION_REQUIRED_ACTIONS_ALIAS.format( + url=self.baseurl, + alias=quote(alias, safe=''), + realm=realm + ), + method='PUT', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(rep), + timeout=self.connection_timeout, + validate_certs=self.validate_certs + ) + except Exception as e: + self.fail_open_url( + e, + msg='Unable to update required action %s in realm %s: %s' + % (alias, realm, str(e)) + ) + + def delete_required_action(self, alias, realm='master'): + """ + Delete required action. + :param alias: Alias of required action. + :param realm: Realm name (not id). + :return: HTTPResponse object on success. + """ + + try: + return open_url( + URL_AUTHENTICATION_REQUIRED_ACTIONS_ALIAS.format( + url=self.baseurl, + alias=quote(alias, safe=''), + realm=realm + ), + method='DELETE', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs + ) + except Exception as e: + self.fail_open_url( + e, + msg='Unable to delete required action %s in realm %s: %s' + % (alias, realm, str(e)) + ) def get_identity_providers(self, realm='master'): """ Fetch representations for identity providers in a realm @@ -1953,7 +2461,7 @@ class KeycloakAPI(object): self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of identity providers for realm %s: %s' % (realm, str(e))) except Exception as e: - self.module.fail_json(msg='Could not obtain list of identity providers for realm %s: %s' + self.fail_open_url(e, msg='Could not obtain list of identity providers for realm %s: %s' % (realm, str(e))) def get_identity_provider(self, alias, realm='master'): @@ -1970,7 +2478,7 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg='Could not fetch identity provider %s in realm %s: %s' + self.fail_open_url(e, msg='Could not fetch identity provider %s in realm %s: %s' % (alias, realm, str(e))) except Exception as e: self.module.fail_json(msg='Could not fetch identity provider %s in realm %s: %s' @@ -1987,7 +2495,7 @@ class KeycloakAPI(object): return open_url(idps_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(idprep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not create identity provider %s in realm %s: %s' + self.fail_open_url(e, msg='Could not create identity provider %s in realm %s: %s' % (idprep['alias'], realm, str(e))) def update_identity_provider(self, idprep, realm='master'): @@ -2001,7 +2509,7 @@ class KeycloakAPI(object): return open_url(idp_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(idprep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not update identity provider %s in realm %s: %s' + self.fail_open_url(e, msg='Could not update identity provider %s in realm %s: %s' % (idprep['alias'], realm, str(e))) def delete_identity_provider(self, alias, realm='master'): @@ -2014,7 +2522,7 @@ class KeycloakAPI(object): return open_url(idp_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Unable to delete identity provider %s in realm %s: %s' + self.fail_open_url(e, msg='Unable to delete identity provider %s in realm %s: %s' % (alias, realm, str(e))) def get_identity_provider_mappers(self, alias, realm='master'): @@ -2032,7 +2540,7 @@ class KeycloakAPI(object): self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of identity provider mappers for idp %s in realm %s: %s' % (alias, realm, str(e))) except Exception as e: - self.module.fail_json(msg='Could not obtain list of identity provider mappers for idp %s in realm %s: %s' + self.fail_open_url(e, msg='Could not obtain list of identity provider mappers for idp %s in realm %s: %s' % (alias, realm, str(e))) def get_identity_provider_mapper(self, mid, alias, realm='master'): @@ -2051,7 +2559,7 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg='Could not fetch mapper %s for identity provider %s in realm %s: %s' + self.fail_open_url(e, msg='Could not fetch mapper %s for identity provider %s in realm %s: %s' % (mid, alias, realm, str(e))) except Exception as e: self.module.fail_json(msg='Could not fetch mapper %s for identity provider %s in realm %s: %s' @@ -2069,7 +2577,7 @@ class KeycloakAPI(object): return open_url(mappers_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(mapper), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not create identity provider mapper %s for idp %s in realm %s: %s' + self.fail_open_url(e, msg='Could not create identity provider mapper %s for idp %s in realm %s: %s' % (mapper['name'], alias, realm, str(e))) def update_identity_provider_mapper(self, mapper, alias, realm='master'): @@ -2084,7 +2592,7 @@ class KeycloakAPI(object): return open_url(mapper_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(mapper), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not update mapper %s for identity provider %s in realm %s: %s' + self.fail_open_url(e, msg='Could not update mapper %s for identity provider %s in realm %s: %s' % (mapper['id'], alias, realm, str(e))) def delete_identity_provider_mapper(self, mid, alias, realm='master'): @@ -2098,7 +2606,7 @@ class KeycloakAPI(object): return open_url(mapper_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Unable to delete mapper %s for identity provider %s in realm %s: %s' + self.fail_open_url(e, msg='Unable to delete mapper %s for identity provider %s in realm %s: %s' % (mid, alias, realm, str(e))) def get_components(self, filter=None, realm='master'): @@ -2118,7 +2626,7 @@ class KeycloakAPI(object): self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of components for realm %s: %s' % (realm, str(e))) except Exception as e: - self.module.fail_json(msg='Could not obtain list of components for realm %s: %s' + self.fail_open_url(e, msg='Could not obtain list of components for realm %s: %s' % (realm, str(e))) def get_component(self, cid, realm='master'): @@ -2135,7 +2643,7 @@ class KeycloakAPI(object): if e.code == 404: return None else: - self.module.fail_json(msg='Could not fetch component %s in realm %s: %s' + self.fail_open_url(e, msg='Could not fetch component %s in realm %s: %s' % (cid, realm, str(e))) except Exception as e: self.module.fail_json(msg='Could not fetch component %s in realm %s: %s' @@ -2158,7 +2666,7 @@ class KeycloakAPI(object): return json.loads(to_native(open_url(comp_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs).read())) except Exception as e: - self.module.fail_json(msg='Could not create component in realm %s: %s' + self.fail_open_url(e, msg='Could not create component in realm %s: %s' % (realm, str(e))) def update_component(self, comprep, realm='master'): @@ -2175,7 +2683,7 @@ class KeycloakAPI(object): return open_url(comp_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(comprep), validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Could not update component %s in realm %s: %s' + self.fail_open_url(e, msg='Could not update component %s in realm %s: %s' % (cid, realm, str(e))) def delete_component(self, cid, realm='master'): @@ -2188,5 +2696,496 @@ class KeycloakAPI(object): return open_url(comp_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, validate_certs=self.validate_certs) except Exception as e: - self.module.fail_json(msg='Unable to delete component %s in realm %s: %s' + self.fail_open_url(e, msg='Unable to delete component %s in realm %s: %s' % (cid, realm, str(e))) + + def get_authz_authorization_scope_by_name(self, name, client_id, realm): + url = URL_AUTHZ_AUTHORIZATION_SCOPES.format(url=self.baseurl, client_id=client_id, realm=realm) + search_url = "%s/search?name=%s" % (url, quote(name, safe='')) + + try: + return json.loads(to_native(open_url(search_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception: + return False + + def create_authz_authorization_scope(self, payload, client_id, realm): + """Create an authorization scope for a Keycloak client""" + url = URL_AUTHZ_AUTHORIZATION_SCOPES.format(url=self.baseurl, client_id=client_id, realm=realm) + + try: + return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not create authorization scope %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + + def update_authz_authorization_scope(self, payload, id, client_id, realm): + """Update an authorization scope for a Keycloak client""" + url = URL_AUTHZ_AUTHORIZATION_SCOPE.format(url=self.baseurl, id=id, client_id=client_id, realm=realm) + + try: + return open_url(url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not create update scope %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + + def remove_authz_authorization_scope(self, id, client_id, realm): + """Remove an authorization scope from a Keycloak client""" + url = URL_AUTHZ_AUTHORIZATION_SCOPE.format(url=self.baseurl, id=id, client_id=client_id, realm=realm) + + try: + return open_url(url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not delete scope %s for client %s in realm %s: %s' % (id, client_id, realm, str(e))) + + def get_user_by_id(self, user_id, realm='master'): + """ + Get a User by its ID. + :param user_id: ID of the user. + :param realm: Realm + :return: Representation of the user. + """ + try: + user_url = URL_USER.format( + url=self.baseurl, + realm=realm, + id=user_id) + userrep = json.load( + open_url( + user_url, + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs)) + return userrep + except Exception as e: + self.fail_open_url(e, msg='Could not get user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def create_user(self, userrep, realm='master'): + """ + Create a new User. + :param userrep: Representation of the user to create + :param realm: Realm + :return: Representation of the user created. + """ + try: + if 'attributes' in userrep and isinstance(userrep['attributes'], list): + attributes = copy.deepcopy(userrep['attributes']) + userrep['attributes'] = self.convert_user_attributes_to_keycloak_dict(attributes=attributes) + users_url = URL_USERS.format( + url=self.baseurl, + realm=realm) + open_url(users_url, + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(userrep), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + created_user = self.get_user_by_username( + username=userrep['username'], + realm=realm) + return created_user + except Exception as e: + self.fail_open_url(e, msg='Could not create user %s in realm %s: %s' + % (userrep['username'], realm, str(e))) + + def convert_user_attributes_to_keycloak_dict(self, attributes): + keycloak_user_attributes_dict = {} + for attribute in attributes: + if ('state' not in attribute or attribute['state'] == 'present') and 'name' in attribute: + keycloak_user_attributes_dict[attribute['name']] = attribute['values'] if 'values' in attribute else [] + return keycloak_user_attributes_dict + + def convert_keycloak_user_attributes_dict_to_module_list(self, attributes): + module_attributes_list = [] + for key in attributes: + attr = {} + attr['name'] = key + attr['values'] = attributes[key] + module_attributes_list.append(attr) + return module_attributes_list + + def update_user(self, userrep, realm='master'): + """ + Update a User. + :param userrep: Representation of the user to update. This representation must include the ID of the user. + :param realm: Realm + :return: Representation of the updated user. + """ + try: + if 'attributes' in userrep and isinstance(userrep['attributes'], list): + attributes = copy.deepcopy(userrep['attributes']) + userrep['attributes'] = self.convert_user_attributes_to_keycloak_dict(attributes=attributes) + user_url = URL_USER.format( + url=self.baseurl, + realm=realm, + id=userrep["id"]) + open_url( + user_url, + method='PUT', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(userrep), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + updated_user = self.get_user_by_id( + user_id=userrep['id'], + realm=realm) + return updated_user + except Exception as e: + self.fail_open_url(e, msg='Could not update user %s in realm %s: %s' + % (userrep['username'], realm, str(e))) + + def delete_user(self, user_id, realm='master'): + """ + Delete a User. + :param user_id: ID of the user to be deleted + :param realm: Realm + :return: HTTP response. + """ + try: + user_url = URL_USER.format( + url=self.baseurl, + realm=realm, + id=user_id) + return open_url( + user_url, + method='DELETE', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not delete user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def get_user_groups(self, user_id, realm='master'): + """ + Get groups for a user. + :param user_id: User ID + :param realm: Realm + :return: Representation of the client groups. + """ + try: + groups = [] + user_groups_url = URL_USER_GROUPS.format( + url=self.baseurl, + realm=realm, + id=user_id) + user_groups = json.load( + open_url( + user_groups_url, + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs)) + for user_group in user_groups: + groups.append(user_group["name"]) + return groups + except Exception as e: + self.fail_open_url(e, msg='Could not get groups for user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def add_user_in_group(self, user_id, group_id, realm='master'): + """ + Add a user to a group. + :param user_id: User ID + :param group_id: Group Id to add the user to. + :param realm: Realm + :return: HTTP Response + """ + try: + user_group_url = URL_USER_GROUP.format( + url=self.baseurl, + realm=realm, + id=user_id, + group_id=group_id) + return open_url( + user_group_url, + method='PUT', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not add user %s in group %s in realm %s: %s' + % (user_id, group_id, realm, str(e))) + + def remove_user_from_group(self, user_id, group_id, realm='master'): + """ + Remove a user from a group for a user. + :param user_id: User ID + :param group_id: Group Id to add the user to. + :param realm: Realm + :return: HTTP response + """ + try: + user_group_url = URL_USER_GROUP.format( + url=self.baseurl, + realm=realm, + id=user_id, + group_id=group_id) + return open_url( + user_group_url, + method='DELETE', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not remove user %s from group %s in realm %s: %s' + % (user_id, group_id, realm, str(e))) + + def update_user_groups_membership(self, userrep, groups, realm='master'): + """ + Update user's group membership + :param userrep: Representation of the user. This representation must include the ID. + :param realm: Realm + :return: True if group membership has been changed. False Otherwise. + """ + changed = False + try: + user_existing_groups = self.get_user_groups( + user_id=userrep['id'], + realm=realm) + groups_to_add_and_remove = self.extract_groups_to_add_to_and_remove_from_user(groups) + # If group membership need to be changed + if not is_struct_included(groups_to_add_and_remove['add'], user_existing_groups): + # Get available groups in the realm + realm_groups = self.get_groups(realm=realm) + for realm_group in realm_groups: + if "name" in realm_group and realm_group["name"] in groups_to_add_and_remove['add']: + self.add_user_in_group( + user_id=userrep["id"], + group_id=realm_group["id"], + realm=realm) + changed = True + elif "name" in realm_group and realm_group['name'] in groups_to_add_and_remove['remove']: + self.remove_user_from_group( + user_id=userrep['id'], + group_id=realm_group['id'], + realm=realm) + changed = True + return changed + except Exception as e: + self.module.fail_json(msg='Could not update group membership for user %s in realm %s: %s' + % (userrep['id]'], realm, str(e))) + + def extract_groups_to_add_to_and_remove_from_user(self, groups): + groups_extract = {} + groups_to_add = [] + groups_to_remove = [] + if isinstance(groups, list) and len(groups) > 0: + for group in groups: + group_name = group['name'] if isinstance(group, dict) and 'name' in group else group + if isinstance(group, dict) and ('state' not in group or group['state'] == 'present'): + groups_to_add.append(group_name) + else: + groups_to_remove.append(group_name) + groups_extract['add'] = groups_to_add + groups_extract['remove'] = groups_to_remove + + return groups_extract + + def convert_user_group_list_of_str_to_list_of_dict(self, groups): + list_of_groups = [] + if isinstance(groups, list) and len(groups) > 0: + for group in groups: + if isinstance(group, str): + group_dict = {} + group_dict['name'] = group + list_of_groups.append(group_dict) + return list_of_groups + + def create_authz_custom_policy(self, policy_type, payload, client_id, realm): + """Create a custom policy for a Keycloak client""" + url = URL_AUTHZ_CUSTOM_POLICY.format(url=self.baseurl, policy_type=policy_type, client_id=client_id, realm=realm) + + try: + return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not create permission %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + + def remove_authz_custom_policy(self, policy_id, client_id, realm): + """Remove a custom policy from a Keycloak client""" + url = URL_AUTHZ_CUSTOM_POLICIES.format(url=self.baseurl, client_id=client_id, realm=realm) + delete_url = "%s/%s" % (url, policy_id) + + try: + return open_url(delete_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not delete custom policy %s for client %s in realm %s: %s' % (id, client_id, realm, str(e))) + + def get_authz_permission_by_name(self, name, client_id, realm): + """Get authorization permission by name""" + url = URL_AUTHZ_POLICIES.format(url=self.baseurl, client_id=client_id, realm=realm) + search_url = "%s/search?name=%s" % (url, name.replace(' ', '%20')) + + try: + return json.loads(to_native(open_url(search_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception: + return False + + def create_authz_permission(self, payload, permission_type, client_id, realm): + """Create an authorization permission for a Keycloak client""" + url = URL_AUTHZ_PERMISSIONS.format(url=self.baseurl, permission_type=permission_type, client_id=client_id, realm=realm) + + try: + return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not create permission %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + + def remove_authz_permission(self, id, client_id, realm): + """Create an authorization permission for a Keycloak client""" + url = URL_AUTHZ_POLICY.format(url=self.baseurl, id=id, client_id=client_id, realm=realm) + + try: + return open_url(url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not delete permission %s for client %s in realm %s: %s' % (id, client_id, realm, str(e))) + + def update_authz_permission(self, payload, permission_type, id, client_id, realm): + """Update a permission for a Keycloak client""" + url = URL_AUTHZ_PERMISSION.format(url=self.baseurl, permission_type=permission_type, id=id, client_id=client_id, realm=realm) + + try: + return open_url(url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not create update permission %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + + def get_authz_resource_by_name(self, name, client_id, realm): + """Get authorization resource by name""" + url = URL_AUTHZ_RESOURCES.format(url=self.baseurl, client_id=client_id, realm=realm) + search_url = "%s/search?name=%s" % (url, name.replace(' ', '%20')) + + try: + return json.loads(to_native(open_url(search_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception: + return False + + def get_authz_policy_by_name(self, name, client_id, realm): + """Get authorization policy by name""" + url = URL_AUTHZ_POLICIES.format(url=self.baseurl, client_id=client_id, realm=realm) + search_url = "%s/search?name=%s&permission=false" % (url, name.replace(' ', '%20')) + + try: + return json.loads(to_native(open_url(search_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception: + return False + + def get_client_role_scope_from_client(self, clientid, clientscopeid, realm="master"): + """ Fetch the roles associated with the client's scope for a specific client on the Keycloak server. + :param clientid: ID of the client from which to obtain the associated roles. + :param clientscopeid: ID of the client who owns the roles. + :param realm: Realm from which to obtain the scope. + :return: The client scope of roles from specified client. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format(url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid) + try: + return json.loads(to_native(open_url(client_role_scope_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg='Could not fetch roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + def update_client_role_scope_from_client(self, payload, clientid, clientscopeid, realm="master"): + """ Update and fetch the roles associated with the client's scope on the Keycloak server. + :param payload: List of roles to be added to the scope. + :param clientid: ID of the client to update scope. + :param clientscopeid: ID of the client who owns the roles. + :param realm: Realm from which to obtain the clients. + :return: The client scope of roles from specified client. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format(url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid) + try: + open_url(client_role_scope_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + + except Exception as e: + self.fail_open_url(e, msg='Could not update roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + return self.get_client_role_scope_from_client(clientid, clientscopeid, realm) + + def delete_client_role_scope_from_client(self, payload, clientid, clientscopeid, realm="master"): + """ Delete the roles contains in the payload from the client's scope on the Keycloak server. + :param payload: List of roles to be deleted. + :param clientid: ID of the client to delete roles from scope. + :param clientscopeid: ID of the client who owns the roles. + :param realm: Realm from which to obtain the clients. + :return: The client scope of roles from specified client. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format(url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid) + try: + open_url(client_role_scope_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + + except Exception as e: + self.fail_open_url(e, msg='Could not delete roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + return self.get_client_role_scope_from_client(clientid, clientscopeid, realm) + + def get_client_role_scope_from_realm(self, clientid, realm="master"): + """ Fetch the realm roles from the client's scope on the Keycloak server. + :param clientid: ID of the client from which to obtain the associated realm roles. + :param realm: Realm from which to obtain the clients. + :return: The client realm roles scope. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_REALM.format(url=self.baseurl, realm=realm, id=clientid) + try: + return json.loads(to_native(open_url(client_role_scope_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg='Could not fetch roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + def update_client_role_scope_from_realm(self, payload, clientid, realm="master"): + """ Update and fetch the realm roles from the client's scope on the Keycloak server. + :param payload: List of realm roles to add. + :param clientid: ID of the client to update scope. + :param realm: Realm from which to obtain the clients. + :return: The client realm roles scope. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_REALM.format(url=self.baseurl, realm=realm, id=clientid) + try: + open_url(client_role_scope_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + + except Exception as e: + self.fail_open_url(e, msg='Could not update roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + return self.get_client_role_scope_from_realm(clientid, realm) + + def delete_client_role_scope_from_realm(self, payload, clientid, realm="master"): + """ Delete the realm roles contains in the payload from the client's scope on the Keycloak server. + :param payload: List of realm roles to delete. + :param clientid: ID of the client to delete roles from scope. + :param realm: Realm from which to obtain the clients. + :return: The client realm roles scope. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_REALM.format(url=self.baseurl, realm=realm, id=clientid) + try: + open_url(client_role_scope_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + + except Exception as e: + self.fail_open_url(e, msg='Could not delete roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + return self.get_client_role_scope_from_realm(clientid, realm) + + def fail_open_url(self, e, msg, **kwargs): + try: + if isinstance(e, HTTPError): + msg = "%s: %s" % (msg, to_native(e.read())) + except Exception as ingore: + pass + self.module.fail_json(msg, **kwargs) diff --git a/plugins/modules/keycloak_client.py b/plugins/modules/keycloak_client.py index dc824ca..0afa52b 100644 --- a/plugins/modules/keycloak_client.py +++ b/plugins/modules/keycloak_client.py @@ -40,8 +40,8 @@ options: state: description: - State of the client - - On C(present), the client will be created (or updated if it exists already). - - On C(absent), the client will be removed if it exists + - On V(present), the client will be created (or updated if it exists already). + - On V(absent), the client will be removed if it exists choices: ['present', 'absent'] default: 'present' type: str @@ -55,7 +55,7 @@ options: client_id: description: - Client id of client to be worked on. This is usually an alphanumeric name chosen by - you. Either this or I(id) is required. If you specify both, I(id) takes precedence. + you. Either this or O(id) is required. If you specify both, O(id) takes precedence. This is 'clientId' in the Keycloak REST API. aliases: - clientId @@ -63,13 +63,13 @@ options: id: description: - - Id of client to be worked on. This is usually an UUID. Either this or I(client_id) + - Id of client to be worked on. This is usually an UUID. Either this or O(client_id) is required. If you specify both, this takes precedence. type: str name: description: - - Name of the client (this is not the same as I(client_id)). + - Name of the client (this is not the same as O(client_id)). type: str description: @@ -108,20 +108,21 @@ options: client_authenticator_type: description: - - How do clients authenticate with the auth server? Either C(client-secret) or - C(client-jwt) can be chosen. When using C(client-secret), the module parameter - I(secret) can set it, while for C(client-jwt), you can use the keys C(use.jwks.url), - C(jwks.url), and C(jwt.credential.certificate) in the I(attributes) module parameter - to configure its behavior. - This is 'clientAuthenticatorType' in the Keycloak REST API. - choices: ['client-secret', 'client-jwt'] + - How do clients authenticate with the auth server? Either V(client-secret), + V(client-jwt), or V(client-x509) can be chosen. When using V(client-secret), the module parameter + O(secret) can set it, for V(client-jwt), you can use the keys C(use.jwks.url), + C(jwks.url), and C(jwt.credential.certificate) in the O(attributes) module parameter + to configure its behavior. For V(client-x509) you can use the keys C(x509.allow.regex.pattern.comparison) + and C(x509.subjectdn) in the O(attributes) module parameter to configure which certificate(s) to accept. + - This is 'clientAuthenticatorType' in the Keycloak REST API. + choices: ['client-secret', 'client-jwt', 'client-x509'] aliases: - clientAuthenticatorType type: str secret: description: - - When using I(client_authenticator_type) C(client-secret) (the default), you can + - When using O(client_authenticator_type=client-secret) (the default), you can specify a secret here (otherwise one will be generated if it does not exit). If changing this secret, the module will not register a change currently (but the changed secret will be saved). @@ -246,9 +247,11 @@ options: protocol: description: - - Type of client (either C(openid-connect) or C(saml). + - Type of client. + - At creation only, default value will be V(openid-connect) if O(protocol) is omitted. + - The V(docker-v2) value was added in community.general 8.6.0. type: str - choices: ['openid-connect', 'saml'] + choices: ['openid-connect', 'saml', 'docker-v2'] full_scope_allowed: description: @@ -286,7 +289,7 @@ options: use_template_config: description: - - Whether or not to use configuration from the I(client_template). + - Whether or not to use configuration from the O(client_template). This is 'useTemplateConfig' in the Keycloak REST API. aliases: - useTemplateConfig @@ -294,7 +297,7 @@ options: use_template_scope: description: - - Whether or not to use scope configuration from the I(client_template). + - Whether or not to use scope configuration from the O(client_template). This is 'useTemplateScope' in the Keycloak REST API. aliases: - useTemplateScope @@ -302,7 +305,7 @@ options: use_template_mappers: description: - - Whether or not to use mapper configuration from the I(client_template). + - Whether or not to use mapper configuration from the O(client_template). This is 'useTemplateMappers' in the Keycloak REST API. aliases: - useTemplateMappers @@ -338,6 +341,42 @@ options: description: - Override realm authentication flow bindings. type: dict + suboptions: + browser: + description: + - Flow ID of the browser authentication flow. + - O(authentication_flow_binding_overrides.browser) + and O(authentication_flow_binding_overrides.browser_name) are mutually exclusive. + type: str + + browser_name: + description: + - Flow name of the browser authentication flow. + - O(authentication_flow_binding_overrides.browser) + and O(authentication_flow_binding_overrides.browser_name) are mutually exclusive. + aliases: + - browserName + type: str + version_added: 9.1.0 + + direct_grant: + description: + - Flow ID of the direct grant authentication flow. + - O(authentication_flow_binding_overrides.direct_grant) + and O(authentication_flow_binding_overrides.direct_grant_name) are mutually exclusive. + aliases: + - directGrant + type: str + + direct_grant_name: + description: + - Flow name of the direct grant authentication flow. + - O(authentication_flow_binding_overrides.direct_grant) + and O(authentication_flow_binding_overrides.direct_grant_name) are mutually exclusive. + aliases: + - directGrantName + type: str + version_added: 9.1.0 aliases: - authenticationFlowBindingOverrides version_added: 3.4.0 @@ -391,38 +430,37 @@ options: protocol: description: - - This is either C(openid-connect) or C(saml), this specifies for which protocol this protocol mapper. - is active. - choices: ['openid-connect', 'saml'] + - 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 + - "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 - - C(docker-v2-allow-all-mapper) - - C(oidc-address-mapper) - - C(oidc-full-name-mapper) - - C(oidc-group-membership-mapper) - - C(oidc-hardcoded-claim-mapper) - - C(oidc-hardcoded-role-mapper) - - C(oidc-role-name-mapper) - - C(oidc-script-based-protocol-mapper) - - C(oidc-sha256-pairwise-sub-mapper) - - C(oidc-usermodel-attribute-mapper) - - C(oidc-usermodel-client-role-mapper) - - C(oidc-usermodel-property-mapper) - - C(oidc-usermodel-realm-role-mapper) - - C(oidc-usersessionmodel-note-mapper) - - C(saml-group-membership-mapper) - - C(saml-hardcode-attribute-mapper) - - C(saml-hardcode-role-mapper) - - C(saml-role-list-mapper) - - C(saml-role-name-mapper) - - C(saml-user-attribute-mapper) - - C(saml-user-property-mapper) - - C(saml-user-session-note-mapper) + 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'. @@ -431,10 +469,10 @@ options: config: description: - Dict specifying the configuration options for the protocol mapper; the - contents differ depending on the value of I(protocolMapper) and are not documented + 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 I(existing) field. + protocol mapper configuration through check-mode in the RV(existing) field. type: dict attributes: @@ -478,7 +516,7 @@ options: saml.signature.algorithm: description: - - Signature algorithm used to sign SAML documents. One of C(RSA_SHA256), C(RSA_SHA1), C(RSA_SHA512), or C(DSA_SHA1). + - Signature algorithm used to sign SAML documents. One of V(RSA_SHA256), V(RSA_SHA1), V(RSA_SHA512), or V(DSA_SHA1). saml.signing.certificate: description: @@ -496,22 +534,21 @@ options: description: - SAML Redirect Binding URL for the client's assertion consumer service (login responses). - saml_force_name_id_format: description: - For SAML clients, Boolean specifying whether to ignore requested NameID subject format and using the configured one instead. saml_name_id_format: description: - - For SAML clients, the NameID format to use (one of C(username), C(email), C(transient), or C(persistent)) + - For SAML clients, the NameID format to use (one of V(username), V(email), V(transient), or V(persistent)) saml_signature_canonicalization_method: description: - SAML signature canonicalization method. This is one of four values, namely - C(http://www.w3.org/2001/10/xml-exc-c14n#) for EXCLUSIVE, - C(http://www.w3.org/2001/10/xml-exc-c14n#WithComments) for EXCLUSIVE_WITH_COMMENTS, - C(http://www.w3.org/TR/2001/REC-xml-c14n-20010315) for INCLUSIVE, and - C(http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments) for INCLUSIVE_WITH_COMMENTS. + V(http://www.w3.org/2001/10/xml-exc-c14n#) for EXCLUSIVE, + V(http://www.w3.org/2001/10/xml-exc-c14n#WithComments) for EXCLUSIVE_WITH_COMMENTS, + V(http://www.w3.org/TR/2001/REC-xml-c14n-20010315) for INCLUSIVE, and + V(http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments) for INCLUSIVE_WITH_COMMENTS. saml_single_logout_service_url_post: description: @@ -523,12 +560,12 @@ options: user.info.response.signature.alg: description: - - For OpenID-Connect clients, JWA algorithm for signed UserInfo-endpoint responses. One of C(RS256) or C(unsigned). + - For OpenID-Connect clients, JWA algorithm for signed UserInfo-endpoint responses. One of V(RS256) or V(unsigned). request.object.signature.alg: description: - For OpenID-Connect clients, JWA algorithm which the client needs to use when sending - OIDC request object. One of C(any), C(none), C(RS256). + OIDC request object. One of V(any), V(none), V(RS256). use.jwks.url: description: @@ -544,9 +581,21 @@ options: - For OpenID-Connect clients, client certificate for validating JWT issued by client and signed by its key, base64-encoded. + x509.subjectdn: + description: + - For OpenID-Connect clients, subject which will be used to authenticate the client. + type: str + version_added: 9.5.0 + + x509.allow.regex.pattern.comparison: + description: + - For OpenID-Connect clients, boolean specifying whether to allow C(x509.subjectdn) as regular expression. + type: bool + version_added: 9.5.0 + extends_documentation_fragment: - - middleware_automation.keycloak.keycloak - - middleware_automation.keycloak.attributes + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.attributes author: - Eike Frost (@eikef) @@ -587,6 +636,22 @@ EXAMPLES = ''' delegate_to: localhost +- name: Create or update a Keycloak client (minimal example), with x509 authentication + middleware_automation.keycloak.keycloak_client: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + state: present + client_id: test + client_authenticator_type: client-x509 + attributes: + x509.subjectdn: "CN=client" + x509.allow.regex.pattern.comparison: false + + - name: Create or update a Keycloak client (with all the bells and whistles) middleware_automation.keycloak.keycloak_client: auth_client_id: admin-cli @@ -637,7 +702,7 @@ EXAMPLES = ''' - test01 - test02 authentication_flow_binding_overrides: - browser: 4c90336b-bf1d-4b87-916d-3677ba4e5fbb + browser: 4c90336b-bf1d-4b87-916d-3677ba4e5fbb protocol_mappers: - config: access.token.claim: true @@ -717,11 +782,17 @@ end_state: ''' from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, get_token, KeycloakError + keycloak_argument_spec, get_token, KeycloakError, is_struct_included from ansible.module_utils.basic import AnsibleModule import copy +PROTOCOL_OPENID_CONNECT = 'openid-connect' +PROTOCOL_SAML = 'saml' +PROTOCOL_DOCKER_V2 = 'docker-v2' +CLIENT_META_DATA = ['authorizationServicesEnabled'] + + def normalise_cr(clientrep, remove_ids=False): """ Re-sorts any properties where the order so that diff's is minimised, and adds default values where appropriate so that the the change detection is more effective. @@ -737,6 +808,12 @@ def normalise_cr(clientrep, remove_ids=False): if 'attributes' in clientrep: clientrep['attributes'] = list(sorted(clientrep['attributes'])) + if 'defaultClientScopes' in clientrep: + clientrep['defaultClientScopes'] = list(sorted(clientrep['defaultClientScopes'])) + + if 'optionalClientScopes' in clientrep: + clientrep['optionalClientScopes'] = list(sorted(clientrep['optionalClientScopes'])) + if 'redirectUris' in clientrep: clientrep['redirectUris'] = list(sorted(clientrep['redirectUris'])) @@ -762,11 +839,70 @@ def sanitize_cr(clientrep): if 'secret' in result: result['secret'] = 'no_log' if 'attributes' in result: - if 'saml.signing.private.key' in result['attributes']: - result['attributes']['saml.signing.private.key'] = 'no_log' + attributes = result['attributes'] + if isinstance(attributes, dict) and 'saml.signing.private.key' in attributes: + attributes['saml.signing.private.key'] = 'no_log' return normalise_cr(result) +def get_authentication_flow_id(flow_name, realm, kc): + """ Get the authentication flow ID based on the flow name, realm, and Keycloak client. + + Args: + flow_name (str): The name of the authentication flow. + realm (str): The name of the realm. + kc (KeycloakClient): The Keycloak client instance. + + Returns: + str: The ID of the authentication flow. + + Raises: + KeycloakAPIException: If the authentication flow with the given name is not found in the realm. + """ + flow = kc.get_authentication_flow_by_alias(flow_name, realm) + if flow: + return flow["id"] + kc.module.fail_json(msg='Authentification flow %s not found in realm %s' % (flow_name, realm)) + + +def flow_binding_from_dict_to_model(newClientFlowBinding, realm, kc): + """ Convert a dictionary representing client flow bindings to a model representation. + + Args: + newClientFlowBinding (dict): A dictionary containing client flow bindings. + realm (str): The name of the realm. + kc (KeycloakClient): An instance of the KeycloakClient class. + + Returns: + dict: A dictionary representing the model flow bindings. The dictionary has two keys: + - "browser" (str or None): The ID of the browser authentication flow binding, or None if not provided. + - "direct_grant" (str or None): The ID of the direct grant authentication flow binding, or None if not provided. + + Raises: + KeycloakAPIException: If the authentication flow with the given name is not found in the realm. + + """ + + modelFlow = { + "browser": None, + "direct_grant": None + } + + for k, v in newClientFlowBinding.items(): + if not v: + continue + if k == "browser": + modelFlow["browser"] = v + elif k == "browser_name": + modelFlow["browser"] = get_authentication_flow_id(v, realm, kc) + elif k == "direct_grant": + modelFlow["direct_grant"] = v + elif k == "direct_grant_name": + modelFlow["direct_grant"] = get_authentication_flow_id(v, realm, kc) + + return modelFlow + + def main(): """ Module execution @@ -780,11 +916,18 @@ def main(): consentText=dict(type='str'), id=dict(type='str'), name=dict(type='str'), - protocol=dict(type='str', choices=['openid-connect', 'saml']), + protocol=dict(type='str', choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), protocolMapper=dict(type='str'), config=dict(type='dict'), ) + authentication_flow_spec = dict( + browser=dict(type='str'), + browser_name=dict(type='str', aliases=['browserName']), + direct_grant=dict(type='str', aliases=['directGrant']), + direct_grant_name=dict(type='str', aliases=['directGrantName']), + ) + meta_args = dict( state=dict(default='present', choices=['present', 'absent']), realm=dict(type='str', default='master'), @@ -798,7 +941,7 @@ def main(): base_url=dict(type='str', aliases=['baseUrl']), surrogate_auth_required=dict(type='bool', aliases=['surrogateAuthRequired']), enabled=dict(type='bool'), - client_authenticator_type=dict(type='str', choices=['client-secret', 'client-jwt'], aliases=['clientAuthenticatorType']), + client_authenticator_type=dict(type='str', choices=['client-secret', 'client-jwt', 'client-x509'], aliases=['clientAuthenticatorType']), secret=dict(type='str', no_log=True), registration_access_token=dict(type='str', aliases=['registrationAccessToken'], no_log=True), default_roles=dict(type='list', elements='str', aliases=['defaultRoles']), @@ -814,7 +957,7 @@ def main(): authorization_services_enabled=dict(type='bool', aliases=['authorizationServicesEnabled']), public_client=dict(type='bool', aliases=['publicClient']), frontchannel_logout=dict(type='bool', aliases=['frontchannelLogout']), - protocol=dict(type='str', choices=['openid-connect', 'saml']), + protocol=dict(type='str', choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), attributes=dict(type='dict'), full_scope_allowed=dict(type='bool', aliases=['fullScopeAllowed']), node_re_registration_timeout=dict(type='int', aliases=['nodeReRegistrationTimeout']), @@ -824,7 +967,13 @@ def main(): use_template_scope=dict(type='bool', aliases=['useTemplateScope']), use_template_mappers=dict(type='bool', aliases=['useTemplateMappers']), always_display_in_console=dict(type='bool', aliases=['alwaysDisplayInConsole']), - authentication_flow_binding_overrides=dict(type='dict', aliases=['authenticationFlowBindingOverrides']), + authentication_flow_binding_overrides=dict( + type='dict', + aliases=['authenticationFlowBindingOverrides'], + options=authentication_flow_spec, + required_one_of=[['browser', 'direct_grant', 'browser_name', 'direct_grant_name']], + mutually_exclusive=[['browser', 'browser_name'], ['direct_grant', 'direct_grant_name']], + ), protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec, aliases=['protocolMappers']), authorization_settings=dict(type='dict', aliases=['authorizationSettings']), default_client_scopes=dict(type='list', elements='str', aliases=['defaultClientScopes']), @@ -885,7 +1034,9 @@ def main(): # Unfortunately, the ansible argument spec checker introduces variables with null values when # they are not specified if client_param == 'protocol_mappers': - new_param_value = [dict((k, v) for k, v in x.items() if x[k] is not None) for x in new_param_value] + new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value] + elif client_param == 'authentication_flow_binding_overrides': + new_param_value = flow_binding_from_dict_to_model(new_param_value, realm, kc) changeset[camel(client_param)] = new_param_value @@ -912,6 +1063,8 @@ def main(): if 'clientId' not in desired_client: module.fail_json(msg='client_id needs to be specified when creating a new client') + if 'protocol' not in desired_client: + desired_client['protocol'] = PROTOCOL_OPENID_CONNECT if module._diff: result['diff'] = dict(before='', after=sanitize_cr(desired_client)) @@ -940,7 +1093,7 @@ def main(): if module._diff: result['diff'] = dict(before=sanitize_cr(before_norm), after=sanitize_cr(desired_norm)) - result['changed'] = (before_norm != desired_norm) + result['changed'] = not is_struct_included(desired_norm, before_norm, CLIENT_META_DATA) module.exit_json(**result) diff --git a/plugins/modules/keycloak_realm.py b/plugins/modules/keycloak_realm.py new file mode 100644 index 0000000..a22795b --- /dev/null +++ b/plugins/modules/keycloak_realm.py @@ -0,0 +1,848 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Eike Frost +# Copyright (c) 2021, Christophe Gilles +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_realm + +short_description: Allows administration of Keycloak realm via Keycloak API + +version_added: 3.0.0 + +description: + - This module allows the administration of Keycloak realm via the Keycloak REST API. It + requires access to the REST API via OpenID Connect; the user connecting and the realm being + used must have the requisite access rights. In a default Keycloak installation, admin-cli + and an admin user would work, as would a separate realm definition with the scope tailored + to your needs and a user having the expected roles. + + - The names of module options are snake_cased versions of the camelCase ones found in the + Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html). + Aliases are provided so camelCased versions can be used as well. + + - The Keycloak API does not always sanity check inputs e.g. you can set + SAML-specific settings on an OpenID Connect client for instance and vice versa. Be careful. + If you do not specify a setting, usually a sensible default is chosen. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the realm. + - On V(present), the realm will be created (or updated if it exists already). + - On V(absent), the realm will be removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + + id: + description: + - The realm to create. + type: str + realm: + description: + - The realm name. + type: str + access_code_lifespan: + description: + - The realm access code lifespan. + aliases: + - accessCodeLifespan + type: int + access_code_lifespan_login: + description: + - The realm access code lifespan login. + aliases: + - accessCodeLifespanLogin + type: int + access_code_lifespan_user_action: + description: + - The realm access code lifespan user action. + aliases: + - accessCodeLifespanUserAction + type: int + access_token_lifespan: + description: + - The realm access token lifespan. + aliases: + - accessTokenLifespan + type: int + access_token_lifespan_for_implicit_flow: + description: + - The realm access token lifespan for implicit flow. + aliases: + - accessTokenLifespanForImplicitFlow + type: int + account_theme: + description: + - The realm account theme. + aliases: + - accountTheme + type: str + action_token_generated_by_admin_lifespan: + description: + - The realm action token generated by admin lifespan. + aliases: + - actionTokenGeneratedByAdminLifespan + type: int + action_token_generated_by_user_lifespan: + description: + - The realm action token generated by user lifespan. + aliases: + - actionTokenGeneratedByUserLifespan + type: int + admin_events_details_enabled: + description: + - The realm admin events details enabled. + aliases: + - adminEventsDetailsEnabled + type: bool + admin_events_enabled: + description: + - The realm admin events enabled. + aliases: + - adminEventsEnabled + type: bool + admin_theme: + description: + - The realm admin theme. + aliases: + - adminTheme + type: str + attributes: + description: + - The realm attributes. + type: dict + browser_flow: + description: + - The realm browser flow. + aliases: + - browserFlow + type: str + browser_security_headers: + description: + - The realm browser security headers. + aliases: + - browserSecurityHeaders + type: dict + brute_force_protected: + description: + - The realm brute force protected. + aliases: + - bruteForceProtected + type: bool + client_authentication_flow: + description: + - The realm client authentication flow. + aliases: + - clientAuthenticationFlow + type: str + client_scope_mappings: + description: + - The realm client scope mappings. + aliases: + - clientScopeMappings + type: dict + default_default_client_scopes: + description: + - The realm default default client scopes. + aliases: + - defaultDefaultClientScopes + type: list + elements: str + default_groups: + description: + - The realm default groups. + aliases: + - defaultGroups + type: list + elements: str + default_locale: + description: + - The realm default locale. + aliases: + - defaultLocale + type: str + default_optional_client_scopes: + description: + - The realm default optional client scopes. + aliases: + - defaultOptionalClientScopes + type: list + elements: str + default_roles: + description: + - The realm default roles. + aliases: + - defaultRoles + type: list + elements: str + default_signature_algorithm: + description: + - The realm default signature algorithm. + aliases: + - defaultSignatureAlgorithm + type: str + direct_grant_flow: + description: + - The realm direct grant flow. + aliases: + - directGrantFlow + type: str + display_name: + description: + - The realm display name. + aliases: + - displayName + type: str + display_name_html: + description: + - The realm display name HTML. + aliases: + - displayNameHtml + type: str + docker_authentication_flow: + description: + - The realm docker authentication flow. + aliases: + - dockerAuthenticationFlow + type: str + duplicate_emails_allowed: + description: + - The realm duplicate emails allowed option. + aliases: + - duplicateEmailsAllowed + type: bool + edit_username_allowed: + description: + - The realm edit username allowed option. + aliases: + - editUsernameAllowed + type: bool + email_theme: + description: + - The realm email theme. + aliases: + - emailTheme + type: str + enabled: + description: + - The realm enabled option. + type: bool + enabled_event_types: + description: + - The realm enabled event types. + aliases: + - enabledEventTypes + type: list + elements: str + events_enabled: + description: + - Enables or disables login events for this realm. + aliases: + - eventsEnabled + type: bool + version_added: 3.6.0 + events_expiration: + description: + - The realm events expiration. + aliases: + - eventsExpiration + type: int + events_listeners: + description: + - The realm events listeners. + aliases: + - eventsListeners + type: list + elements: str + failure_factor: + description: + - The realm failure factor. + aliases: + - failureFactor + type: int + internationalization_enabled: + description: + - The realm internationalization enabled option. + aliases: + - internationalizationEnabled + type: bool + login_theme: + description: + - The realm login theme. + aliases: + - loginTheme + type: str + login_with_email_allowed: + description: + - The realm login with email allowed option. + aliases: + - loginWithEmailAllowed + type: bool + max_delta_time_seconds: + description: + - The realm max delta time in seconds. + aliases: + - maxDeltaTimeSeconds + type: int + max_failure_wait_seconds: + description: + - The realm max failure wait in seconds. + aliases: + - maxFailureWaitSeconds + type: int + minimum_quick_login_wait_seconds: + description: + - The realm minimum quick login wait in seconds. + aliases: + - minimumQuickLoginWaitSeconds + type: int + not_before: + description: + - The realm not before. + aliases: + - notBefore + type: int + offline_session_idle_timeout: + description: + - The realm offline session idle timeout. + aliases: + - offlineSessionIdleTimeout + type: int + offline_session_max_lifespan: + description: + - The realm offline session max lifespan. + aliases: + - offlineSessionMaxLifespan + type: int + offline_session_max_lifespan_enabled: + description: + - The realm offline session max lifespan enabled option. + aliases: + - offlineSessionMaxLifespanEnabled + type: bool + otp_policy_algorithm: + description: + - The realm otp policy algorithm. + aliases: + - otpPolicyAlgorithm + type: str + otp_policy_digits: + description: + - The realm otp policy digits. + aliases: + - otpPolicyDigits + type: int + otp_policy_initial_counter: + description: + - The realm otp policy initial counter. + aliases: + - otpPolicyInitialCounter + type: int + otp_policy_look_ahead_window: + description: + - The realm otp policy look ahead window. + aliases: + - otpPolicyLookAheadWindow + type: int + otp_policy_period: + description: + - The realm otp policy period. + aliases: + - otpPolicyPeriod + type: int + otp_policy_type: + description: + - The realm otp policy type. + aliases: + - otpPolicyType + type: str + otp_supported_applications: + description: + - The realm otp supported applications. + aliases: + - otpSupportedApplications + type: list + elements: str + password_policy: + description: + - The realm password policy. + aliases: + - passwordPolicy + type: str + permanent_lockout: + description: + - The realm permanent lockout. + aliases: + - permanentLockout + type: bool + quick_login_check_milli_seconds: + description: + - The realm quick login check in milliseconds. + aliases: + - quickLoginCheckMilliSeconds + type: int + refresh_token_max_reuse: + description: + - The realm refresh token max reuse. + aliases: + - refreshTokenMaxReuse + type: int + registration_allowed: + description: + - The realm registration allowed option. + aliases: + - registrationAllowed + type: bool + registration_email_as_username: + description: + - The realm registration email as username option. + aliases: + - registrationEmailAsUsername + type: bool + registration_flow: + description: + - The realm registration flow. + aliases: + - registrationFlow + type: str + remember_me: + description: + - The realm remember me option. + aliases: + - rememberMe + type: bool + reset_credentials_flow: + description: + - The realm reset credentials flow. + aliases: + - resetCredentialsFlow + type: str + reset_password_allowed: + description: + - The realm reset password allowed option. + aliases: + - resetPasswordAllowed + type: bool + revoke_refresh_token: + description: + - The realm revoke refresh token option. + aliases: + - revokeRefreshToken + type: bool + smtp_server: + description: + - The realm smtp server. + aliases: + - smtpServer + type: dict + ssl_required: + description: + - The realm ssl required option. + choices: ['all', 'external', 'none'] + aliases: + - sslRequired + type: str + sso_session_idle_timeout: + description: + - The realm sso session idle timeout. + aliases: + - ssoSessionIdleTimeout + type: int + sso_session_idle_timeout_remember_me: + description: + - The realm sso session idle timeout remember me. + aliases: + - ssoSessionIdleTimeoutRememberMe + type: int + sso_session_max_lifespan: + description: + - The realm sso session max lifespan. + aliases: + - ssoSessionMaxLifespan + type: int + sso_session_max_lifespan_remember_me: + description: + - The realm sso session max lifespan remember me. + aliases: + - ssoSessionMaxLifespanRememberMe + type: int + supported_locales: + description: + - The realm supported locales. + aliases: + - supportedLocales + type: list + elements: str + user_managed_access_allowed: + description: + - The realm user managed access allowed option. + aliases: + - userManagedAccessAllowed + type: bool + verify_email: + description: + - The realm verify email option. + aliases: + - verifyEmail + type: bool + wait_increment_seconds: + description: + - The realm wait increment in seconds. + aliases: + - waitIncrementSeconds + type: int + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.attributes + +author: + - Christophe Gilles (@kris2kris) +''' + +EXAMPLES = ''' +- name: Create or update Keycloak realm (minimal example) + middleware_automation.keycloak.keycloak_realm: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + id: realm + realm: realm + state: present + +- name: Delete a Keycloak realm + middleware_automation.keycloak.keycloak_realm: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + id: test + state: absent +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Realm testrealm has been updated" + +proposed: + description: Representation of proposed realm. + returned: always + type: dict + sample: { + id: "test" + } + +existing: + description: Representation of existing realm (sample is truncated). + returned: always + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } + +end_state: + description: Representation of realm after module execution (sample is truncated). + returned: on success + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule + + +def normalise_cr(realmrep): + """ Re-sorts any properties where the order is important so that diff's is minimised and the change detection is more effective. + + :param realmrep: the realmrep dict to be sanitized + :return: normalised realmrep dict + """ + # Avoid the dict passed in to be modified + realmrep = realmrep.copy() + + if 'enabledEventTypes' in realmrep: + realmrep['enabledEventTypes'] = list(sorted(realmrep['enabledEventTypes'])) + + if 'otpSupportedApplications' in realmrep: + realmrep['otpSupportedApplications'] = list(sorted(realmrep['otpSupportedApplications'])) + + if 'supportedLocales' in realmrep: + realmrep['supportedLocales'] = list(sorted(realmrep['supportedLocales'])) + + return realmrep + + +def sanitize_cr(realmrep): + """ Removes probably sensitive details from a realm representation. + + :param realmrep: the realmrep dict to be sanitized + :return: sanitized realmrep dict + """ + result = realmrep.copy() + if 'secret' in result: + result['secret'] = '********' + if 'attributes' in result: + if 'saml.signing.private.key' in result['attributes']: + result['attributes'] = result['attributes'].copy() + result['attributes']['saml.signing.private.key'] = '********' + return normalise_cr(result) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(default='present', choices=['present', 'absent']), + + id=dict(type='str'), + realm=dict(type='str'), + access_code_lifespan=dict(type='int', aliases=['accessCodeLifespan']), + access_code_lifespan_login=dict(type='int', aliases=['accessCodeLifespanLogin']), + access_code_lifespan_user_action=dict(type='int', aliases=['accessCodeLifespanUserAction']), + access_token_lifespan=dict(type='int', aliases=['accessTokenLifespan'], no_log=False), + access_token_lifespan_for_implicit_flow=dict(type='int', aliases=['accessTokenLifespanForImplicitFlow'], no_log=False), + account_theme=dict(type='str', aliases=['accountTheme']), + action_token_generated_by_admin_lifespan=dict(type='int', aliases=['actionTokenGeneratedByAdminLifespan'], no_log=False), + action_token_generated_by_user_lifespan=dict(type='int', aliases=['actionTokenGeneratedByUserLifespan'], no_log=False), + admin_events_details_enabled=dict(type='bool', aliases=['adminEventsDetailsEnabled']), + admin_events_enabled=dict(type='bool', aliases=['adminEventsEnabled']), + admin_theme=dict(type='str', aliases=['adminTheme']), + attributes=dict(type='dict'), + browser_flow=dict(type='str', aliases=['browserFlow']), + browser_security_headers=dict(type='dict', aliases=['browserSecurityHeaders']), + brute_force_protected=dict(type='bool', aliases=['bruteForceProtected']), + client_authentication_flow=dict(type='str', aliases=['clientAuthenticationFlow']), + client_scope_mappings=dict(type='dict', aliases=['clientScopeMappings']), + default_default_client_scopes=dict(type='list', elements='str', aliases=['defaultDefaultClientScopes']), + default_groups=dict(type='list', elements='str', aliases=['defaultGroups']), + default_locale=dict(type='str', aliases=['defaultLocale']), + default_optional_client_scopes=dict(type='list', elements='str', aliases=['defaultOptionalClientScopes']), + default_roles=dict(type='list', elements='str', aliases=['defaultRoles']), + default_signature_algorithm=dict(type='str', aliases=['defaultSignatureAlgorithm']), + direct_grant_flow=dict(type='str', aliases=['directGrantFlow']), + display_name=dict(type='str', aliases=['displayName']), + display_name_html=dict(type='str', aliases=['displayNameHtml']), + docker_authentication_flow=dict(type='str', aliases=['dockerAuthenticationFlow']), + duplicate_emails_allowed=dict(type='bool', aliases=['duplicateEmailsAllowed']), + edit_username_allowed=dict(type='bool', aliases=['editUsernameAllowed']), + email_theme=dict(type='str', aliases=['emailTheme']), + enabled=dict(type='bool'), + enabled_event_types=dict(type='list', elements='str', aliases=['enabledEventTypes']), + events_enabled=dict(type='bool', aliases=['eventsEnabled']), + events_expiration=dict(type='int', aliases=['eventsExpiration']), + events_listeners=dict(type='list', elements='str', aliases=['eventsListeners']), + failure_factor=dict(type='int', aliases=['failureFactor']), + internationalization_enabled=dict(type='bool', aliases=['internationalizationEnabled']), + login_theme=dict(type='str', aliases=['loginTheme']), + login_with_email_allowed=dict(type='bool', aliases=['loginWithEmailAllowed']), + max_delta_time_seconds=dict(type='int', aliases=['maxDeltaTimeSeconds']), + max_failure_wait_seconds=dict(type='int', aliases=['maxFailureWaitSeconds']), + minimum_quick_login_wait_seconds=dict(type='int', aliases=['minimumQuickLoginWaitSeconds']), + not_before=dict(type='int', aliases=['notBefore']), + offline_session_idle_timeout=dict(type='int', aliases=['offlineSessionIdleTimeout']), + offline_session_max_lifespan=dict(type='int', aliases=['offlineSessionMaxLifespan']), + offline_session_max_lifespan_enabled=dict(type='bool', aliases=['offlineSessionMaxLifespanEnabled']), + otp_policy_algorithm=dict(type='str', aliases=['otpPolicyAlgorithm']), + otp_policy_digits=dict(type='int', aliases=['otpPolicyDigits']), + otp_policy_initial_counter=dict(type='int', aliases=['otpPolicyInitialCounter']), + otp_policy_look_ahead_window=dict(type='int', aliases=['otpPolicyLookAheadWindow']), + otp_policy_period=dict(type='int', aliases=['otpPolicyPeriod']), + otp_policy_type=dict(type='str', aliases=['otpPolicyType']), + otp_supported_applications=dict(type='list', elements='str', aliases=['otpSupportedApplications']), + password_policy=dict(type='str', aliases=['passwordPolicy'], no_log=False), + permanent_lockout=dict(type='bool', aliases=['permanentLockout']), + quick_login_check_milli_seconds=dict(type='int', aliases=['quickLoginCheckMilliSeconds']), + refresh_token_max_reuse=dict(type='int', aliases=['refreshTokenMaxReuse'], no_log=False), + registration_allowed=dict(type='bool', aliases=['registrationAllowed']), + registration_email_as_username=dict(type='bool', aliases=['registrationEmailAsUsername']), + registration_flow=dict(type='str', aliases=['registrationFlow']), + remember_me=dict(type='bool', aliases=['rememberMe']), + reset_credentials_flow=dict(type='str', aliases=['resetCredentialsFlow']), + reset_password_allowed=dict(type='bool', aliases=['resetPasswordAllowed'], no_log=False), + revoke_refresh_token=dict(type='bool', aliases=['revokeRefreshToken']), + smtp_server=dict(type='dict', aliases=['smtpServer']), + ssl_required=dict(choices=["external", "all", "none"], aliases=['sslRequired']), + sso_session_idle_timeout=dict(type='int', aliases=['ssoSessionIdleTimeout']), + sso_session_idle_timeout_remember_me=dict(type='int', aliases=['ssoSessionIdleTimeoutRememberMe']), + sso_session_max_lifespan=dict(type='int', aliases=['ssoSessionMaxLifespan']), + sso_session_max_lifespan_remember_me=dict(type='int', aliases=['ssoSessionMaxLifespanRememberMe']), + supported_locales=dict(type='list', elements='str', aliases=['supportedLocales']), + user_managed_access_allowed=dict(type='bool', aliases=['userManagedAccessAllowed']), + verify_email=dict(type='bool', aliases=['verifyEmail']), + wait_increment_seconds=dict(type='int', aliases=['waitIncrementSeconds']), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['id', 'realm', 'enabled'], + ['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + 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') + + # convert module parameters to realm representation parameters (if they belong in there) + params_to_ignore = list(keycloak_argument_spec().keys()) + ['state'] + + # Filter and map the parameters names that apply to the role + realm_params = [x for x in module.params + if x not in params_to_ignore and + module.params.get(x) is not None] + + # See whether the realm already exists in Keycloak + before_realm = kc.get_realm_by_id(realm=realm) + + if before_realm is None: + before_realm = {} + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for realm_param in realm_params: + new_param_value = module.params.get(realm_param) + changeset[camel(realm_param)] = new_param_value + + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) + desired_realm = before_realm.copy() + desired_realm.update(changeset) + + result['proposed'] = sanitize_cr(changeset) + before_realm_sanitized = sanitize_cr(before_realm) + result['existing'] = before_realm_sanitized + + # Cater for when it doesn't exist (an empty dict) + if not before_realm: + if state == 'absent': + # Do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['changed'] = False + result['end_state'] = {} + result['msg'] = 'Realm does not exist, doing nothing.' + module.exit_json(**result) + + # Process a creation + result['changed'] = True + + if 'id' not in desired_realm: + module.fail_json(msg='id needs to be specified when creating a new realm') + + if module._diff: + result['diff'] = dict(before='', after=sanitize_cr(desired_realm)) + + if module.check_mode: + module.exit_json(**result) + + # create it + kc.create_realm(desired_realm) + after_realm = kc.get_realm_by_id(desired_realm['id']) + + result['end_state'] = sanitize_cr(after_realm) + + result['msg'] = 'Realm %s has been created.' % desired_realm['id'] + module.exit_json(**result) + + else: + if state == 'present': + # Process an update + + # doing an update + result['changed'] = True + if module.check_mode: + # We can only compare the current realm with the proposed updates we have + before_norm = normalise_cr(before_realm) + desired_norm = normalise_cr(desired_realm) + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_norm), + after=sanitize_cr(desired_norm)) + result['changed'] = (before_norm != desired_norm) + + module.exit_json(**result) + + # do the update + kc.update_realm(desired_realm, realm=realm) + + after_realm = kc.get_realm_by_id(realm=realm) + + if before_realm == after_realm: + result['changed'] = False + + result['end_state'] = sanitize_cr(after_realm) + + if module._diff: + result['diff'] = dict(before=before_realm_sanitized, + after=sanitize_cr(after_realm)) + + result['msg'] = 'Realm %s has been updated.' % desired_realm['id'] + module.exit_json(**result) + + else: + # Process a deletion (because state was not 'present') + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=before_realm_sanitized, after='') + + if module.check_mode: + module.exit_json(**result) + + # delete it + kc.delete_realm(realm=realm) + + result['proposed'] = {} + result['end_state'] = {} + + result['msg'] = 'Realm %s has been deleted.' % before_realm['id'] + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_role.py b/plugins/modules/keycloak_role.py index 558f362..c48e9c9 100644 --- a/plugins/modules/keycloak_role.py +++ b/plugins/modules/keycloak_role.py @@ -40,8 +40,8 @@ options: state: description: - State of the role. - - On C(present), the role will be created if it does not yet exist, or updated with the parameters you provide. - - On C(absent), the role will be removed if it exists. + - On V(present), the role will be created if it does not yet exist, or updated with the parameters you provide. + - On V(absent), the role will be removed if it exists. default: 'present' type: str choices: @@ -77,6 +77,42 @@ options: 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: - middleware_automation.keycloak.keycloak @@ -142,14 +178,14 @@ EXAMPLES = ''' auth_password: PASSWORD name: my-new-role attributes: - attrib1: value1 - attrib2: value2 - attrib3: - - with - - numerous - - individual - - list - - items + attrib1: value1 + attrib2: value2 + attrib3: + - with + - numerous + - individual + - list + - items delegate_to: localhost ''' @@ -198,8 +234,9 @@ end_state: ''' from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, get_token, KeycloakError + keycloak_argument_spec, get_token, KeycloakError, is_struct_included from ansible.module_utils.basic import AnsibleModule +import copy def main(): @@ -210,6 +247,12 @@ def main(): """ argument_spec = keycloak_argument_spec() + composites_spec = dict( + name=dict(type='str', required=True), + client_id=dict(type='str', aliases=['clientId'], required=False), + state=dict(type='str', default='present', choices=['present', 'absent']) + ) + meta_args = dict( state=dict(type='str', default='present', choices=['present', 'absent']), name=dict(type='str', required=True), @@ -217,6 +260,8 @@ def main(): realm=dict(type='str', default='master'), client_id=dict(type='str'), attributes=dict(type='dict'), + composites=dict(type='list', default=[], options=composites_spec, elements='dict'), + composite=dict(type='bool', default=False), ) argument_spec.update(meta_args) @@ -250,7 +295,7 @@ def main(): # Filter and map the parameters names that apply to the role role_params = [x for x in module.params - if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'client_id', 'composites'] and + 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 @@ -269,10 +314,10 @@ def main(): new_param_value = module.params.get(param) old_value = before_role[param] if param in before_role else None if new_param_value != old_value: - changeset[camel(param)] = new_param_value + changeset[camel(param)] = copy.deepcopy(new_param_value) # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) - desired_role = before_role.copy() + desired_role = copy.deepcopy(before_role) desired_role.update(changeset) result['proposed'] = changeset @@ -309,6 +354,9 @@ def main(): kc.create_client_role(desired_role, clientid, realm) after_role = kc.get_client_role(name, clientid, realm) + if after_role['composite']: + after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) + result['end_state'] = after_role result['msg'] = 'Role {name} has been created'.format(name=name) @@ -316,10 +364,25 @@ def main(): else: if state == 'present': + compare_exclude = [] + if 'composites' in desired_role and isinstance(desired_role['composites'], list) and len(desired_role['composites']) > 0: + composites = kc.get_role_composites(rolerep=before_role, clientid=clientid, realm=realm) + before_role['composites'] = [] + for composite in composites: + before_composite = {} + if composite['clientRole']: + composite_client = kc.get_client_by_id(id=composite['containerId'], realm=realm) + before_composite['client_id'] = composite_client['clientId'] + else: + before_composite['client_id'] = None + before_composite['name'] = composite['name'] + before_composite['state'] = 'present' + before_role['composites'].append(before_composite) + else: + compare_exclude.append('composites') # Process an update - # no changes - if desired_role == before_role: + if is_struct_included(desired_role, before_role, exclude=compare_exclude): result['changed'] = False result['end_state'] = desired_role result['msg'] = "No changes required to role {name}.".format(name=name) @@ -341,6 +404,8 @@ def main(): else: kc.update_client_role(desired_role, clientid, realm) after_role = kc.get_client_role(name, clientid, realm) + if after_role['composite']: + after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) result['end_state'] = after_role diff --git a/plugins/modules/keycloak_user_federation.py b/plugins/modules/keycloak_user_federation.py index 36fe440..864cfbc 100644 --- a/plugins/modules/keycloak_user_federation.py +++ b/plugins/modules/keycloak_user_federation.py @@ -36,9 +36,9 @@ options: state: description: - State of the user federation. - - On C(present), the user federation will be created if it does not yet exist, or updated with + - On V(present), the user federation will be created if it does not yet exist, or updated with the parameters you provide. - - On C(absent), the user federation will be removed if it exists. + - On V(absent), the user federation will be removed if it exists. default: 'present' type: str choices: @@ -54,7 +54,7 @@ options: id: description: - The unique ID for this user federation. If left empty, the user federation will be searched - by its I(name). + by its O(name). type: str name: @@ -64,18 +64,15 @@ options: provider_id: description: - - Provider for this user federation. + - Provider for this user federation. Built-in providers are V(ldap), V(kerberos), and V(sssd). + Custom user storage providers can also be used. aliases: - providerId type: str - choices: - - ldap - - kerberos - - sssd provider_type: description: - - Component type for user federation (only supported value is C(org.keycloak.storage.UserStorageProvider)). + - Component type for user federation (only supported value is V(org.keycloak.storage.UserStorageProvider)). aliases: - providerType default: org.keycloak.storage.UserStorageProvider @@ -88,13 +85,37 @@ options: - parentId type: str + remove_unspecified_mappers: + description: + - Remove mappers that are not specified in the configuration for this federation. + - Set to V(false) to keep mappers that are not listed in O(mappers). + type: bool + default: true + + bind_credential_update_mode: + description: + - The value of the config parameter O(config.bindCredential) is redacted in the Keycloak responses. + Comparing the redacted value with the desired value always evaluates to not equal. This means + the before and desired states are never equal if the parameter is set. + - Set to V(always) to include O(config.bindCredential) in the comparison of before and desired state. + Because of the redacted value returned by Keycloak the module will always detect a change + and make an update if a O(config.bindCredential) value is set. + - Set to V(only_indirect) to exclude O(config.bindCredential) when comparing the before state with the + desired state. The value of O(config.bindCredential) will only be updated if there are other changes + to the user federation that require an update. + type: str + default: always + choices: + - always + - only_indirect + config: description: - Dict specifying the configuration options for the provider; the contents differ depending on - the value of I(provider_id). Examples are given below for C(ldap), C(kerberos) and C(sssd). + the value of O(provider_id). Examples are given below for V(ldap), V(kerberos) and V(sssd). It is easiest to obtain valid config values by dumping an already-existing user federation - configuration through check-mode in the I(existing) field. - - The value C(sssd) has been supported since middleware_automation.keycloak 1.0.0. + configuration through check-mode in the RV(existing) field. + - The value V(sssd) has been supported since middleware_automation.keycloak 2.0.0. type: dict suboptions: enabled: @@ -111,15 +132,15 @@ options: importEnabled: description: - - If C(true), LDAP users will be imported into Keycloak DB and synced by the configured + - If V(true), LDAP users will be imported into Keycloak DB and synced by the configured sync policies. default: true type: bool editMode: description: - - C(READ_ONLY) is a read-only LDAP store. C(WRITABLE) means data will be synced back to LDAP - on demand. C(UNSYNCED) means user data will be imported, but not synced back to LDAP. + - V(READ_ONLY) is a read-only LDAP store. V(WRITABLE) means data will be synced back to LDAP + on demand. V(UNSYNCED) means user data will be imported, but not synced back to LDAP. type: str choices: - READ_ONLY @@ -136,13 +157,13 @@ options: vendor: description: - LDAP vendor (provider). - - Use short name. For instance, write C(rhds) for "Red Hat Directory Server". + - Use short name. For instance, write V(rhds) for "Red Hat Directory Server". type: str usernameLDAPAttribute: description: - Name of LDAP attribute, which is mapped as Keycloak username. For many LDAP server - vendors it can be C(uid). For Active directory it can be C(sAMAccountName) or C(cn). + vendors it can be V(uid). For Active directory it can be V(sAMAccountName) or V(cn). The attribute should be filled for all LDAP user records you want to import from LDAP to Keycloak. type: str @@ -151,15 +172,15 @@ options: description: - Name of LDAP attribute, which is used as RDN (top attribute) of typical user DN. Usually it's the same as Username LDAP attribute, however it is not required. For - example for Active directory, it is common to use C(cn) as RDN attribute when - username attribute might be C(sAMAccountName). + example for Active directory, it is common to use V(cn) as RDN attribute when + username attribute might be V(sAMAccountName). type: str uuidLDAPAttribute: description: - Name of LDAP attribute, which is used as unique object identifier (UUID) for objects - in LDAP. For many LDAP server vendors, it is C(entryUUID); however some are different. - For example for Active directory it should be C(objectGUID). If your LDAP server does + in LDAP. For many LDAP server vendors, it is V(entryUUID); however some are different. + For example for Active directory it should be V(objectGUID). If your LDAP server does not support the notion of UUID, you can use any other attribute that is supposed to be unique among LDAP users in tree. type: str @@ -167,7 +188,7 @@ options: userObjectClasses: description: - All values of LDAP objectClass attribute for users in LDAP divided by comma. - For example C(inetOrgPerson, organizationalPerson). Newly created Keycloak users + For example V(inetOrgPerson, organizationalPerson). Newly created Keycloak users will be written to LDAP with all those object classes and existing LDAP user records are found just if they contain all those object classes. type: str @@ -251,8 +272,8 @@ options: useTruststoreSpi: description: - Specifies whether LDAP connection will use the truststore SPI with the truststore - configured in standalone.xml/domain.xml. C(Always) means that it will always use it. - C(Never) means that it will not use it. C(Only for ldaps) means that it will use if + configured in standalone.xml/domain.xml. V(always) means that it will always use it. + V(never) means that it will not use it. V(ldapsOnly) means that it will use if your connection URL use ldaps. Note even if standalone.xml/domain.xml is not configured, the default Java cacerts or certificate specified by C(javax.net.ssl.trustStore) property will be used. @@ -297,7 +318,7 @@ options: connectionPoolingDebug: description: - A string that indicates the level of debug output to produce. Example valid values are - C(fine) (trace connection creation and removal) and C(all) (all debugging information). + V(fine) (trace connection creation and removal) and V(all) (all debugging information). type: str connectionPoolingInitSize: @@ -321,7 +342,7 @@ options: connectionPoolingProtocol: description: - A list of space-separated protocol types of connections that may be pooled. - Valid types are C(plain) and C(ssl). + Valid types are V(plain) and V(ssl). type: str connectionPoolingTimeout: @@ -342,17 +363,26 @@ options: - Name of kerberos realm. type: str + krbPrincipalAttribute: + description: + - Name of the LDAP attribute, which refers to Kerberos principal. + This is used to lookup appropriate LDAP user after successful Kerberos/SPNEGO authentication in Keycloak. + When this is empty, the LDAP user will be looked based on LDAP username corresponding + to the first part of his Kerberos principal. For instance, for principal C(john@KEYCLOAK.ORG), + it will assume that LDAP username is V(john). + type: str + serverPrincipal: description: - Full name of server principal for HTTP service including server and domain name. For - example C(HTTP/host.foo.org@FOO.ORG). Use C(*) to accept any service principal in the + example V(HTTP/host.foo.org@FOO.ORG). Use V(*) to accept any service principal in the KeyTab file. type: str keyTab: description: - Location of Kerberos KeyTab file containing the credentials of server principal. For - example C(/etc/krb5.keytab). + example V(/etc/krb5.keytab). type: str debug: @@ -427,6 +457,16 @@ options: - Max lifespan of cache entry in milliseconds. type: int + referral: + description: + - Specifies if LDAP referrals should be followed or ignored. Please note that enabling + referrals can slow down authentication as it allows the LDAP server to decide which other + LDAP servers to use. This could potentially include untrusted servers. + type: str + choices: + - ignore + - follow + mappers: description: - A list of dicts defining mappers associated with this Identity Provider. @@ -451,7 +491,7 @@ options: providerId: description: - - The mapper type for this mapper (for instance C(user-attribute-ldap-mapper)). + - The mapper type for this mapper (for instance V(user-attribute-ldap-mapper)). type: str providerType: @@ -534,14 +574,14 @@ EXAMPLES = ''' provider_id: kerberos provider_type: org.keycloak.storage.UserStorageProvider config: - priority: 0 - enabled: true - cachePolicy: DEFAULT - kerberosRealm: EXAMPLE.COM - serverPrincipal: HTTP/host.example.com@EXAMPLE.COM - keyTab: keytab - allowPasswordAuthentication: false - updateProfileFirstLogin: false + priority: 0 + enabled: true + cachePolicy: DEFAULT + kerberosRealm: EXAMPLE.COM + serverPrincipal: HTTP/host.example.com@EXAMPLE.COM + keyTab: keytab + allowPasswordAuthentication: false + updateProfileFirstLogin: false - name: Create sssd user federation middleware_automation.keycloak.keycloak_user_federation: @@ -704,16 +744,27 @@ from ansible.module_utils.six.moves.urllib.parse import urlencode from copy import deepcopy +def normalize_kc_comp(comp): + if 'config' in comp: + # kc completely removes the parameter `krbPrincipalAttribute` if it is set to `''`; the unset kc parameter is equivalent to `''`; + # to make change detection and diff more accurate we set it again in the kc responses + if 'krbPrincipalAttribute' not in comp['config']: + comp['config']['krbPrincipalAttribute'] = [''] + + # kc stores a timestamp of the last sync in `lastSync` to time the periodic sync, it is removed to minimize diff/changes + comp['config'].pop('lastSync', None) + + def sanitize(comp): compcopy = deepcopy(comp) if 'config' in compcopy: - compcopy['config'] = dict((k, v[0]) for k, v in compcopy['config'].items()) + compcopy['config'] = {k: v[0] for k, v in compcopy['config'].items()} if 'bindCredential' in compcopy['config']: compcopy['config']['bindCredential'] = '**********' if 'mappers' in compcopy: for mapper in compcopy['mappers']: if 'config' in mapper: - mapper['config'] = dict((k, v[0]) for k, v in mapper['config'].items()) + mapper['config'] = {k: v[0] for k, v in mapper['config'].items()} return compcopy @@ -760,8 +811,10 @@ def main(): priority=dict(type='int', default=0), rdnLDAPAttribute=dict(type='str'), readTimeout=dict(type='int'), + referral=dict(type='str', choices=['ignore', 'follow']), searchScope=dict(type='str', choices=['1', '2'], default='1'), serverPrincipal=dict(type='str'), + krbPrincipalAttribute=dict(type='str'), startTls=dict(type='bool', default=False), syncRegistrations=dict(type='bool', default=False), trustEmail=dict(type='bool', default=False), @@ -792,9 +845,11 @@ def main(): realm=dict(type='str', default='master'), id=dict(type='str'), name=dict(type='str'), - provider_id=dict(type='str', aliases=['providerId'], choices=['ldap', 'kerberos', 'sssd']), + provider_id=dict(type='str', aliases=['providerId']), provider_type=dict(type='str', aliases=['providerType'], default='org.keycloak.storage.UserStorageProvider'), parent_id=dict(type='str', aliases=['parentId']), + remove_unspecified_mappers=dict(type='bool', default=True), + bind_credential_update_mode=dict(type='str', default='always', choices=['always', 'only_indirect']), mappers=dict(type='list', elements='dict', options=mapper_spec), ) @@ -825,19 +880,26 @@ def main(): # Keycloak API expects config parameters to be arrays containing a single string element if config is not None: - module.params['config'] = dict((k, [str(v).lower() if not isinstance(v, str) else v]) - for k, v in config.items() if config[k] is not None) + module.params['config'] = { + k: [str(v).lower() if not isinstance(v, str) else v] + for k, v in config.items() + if config[k] is not None + } if mappers is not None: for mapper in mappers: if mapper.get('config') is not None: - mapper['config'] = dict((k, [str(v).lower() if not isinstance(v, str) else v]) - for k, v in mapper['config'].items() if mapper['config'][k] is not None) + mapper['config'] = { + k: [str(v).lower() if not isinstance(v, str) else v] + for k, v in mapper['config'].items() + if mapper['config'][k] is not None + } # Filter and map the parameters names that apply comp_params = [x for x in module.params - if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'mappers'] and - module.params.get(x) is not None] + if x not in list(keycloak_argument_spec().keys()) + + ['state', 'realm', 'mappers', 'remove_unspecified_mappers', 'bind_credential_update_mode'] + and module.params.get(x) is not None] # See if it already exists in Keycloak if cid is None: @@ -855,7 +917,9 @@ def main(): # if user federation exists, get associated mappers if cid is not None and before_comp: - before_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name')) + before_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name') or '') + + normalize_kc_comp(before_comp) # Build a proposed changeset from parameters given to this module changeset = {} @@ -864,7 +928,7 @@ def main(): new_param_value = module.params.get(param) old_value = before_comp[camel(param)] if camel(param) in before_comp else None if param == 'mappers': - new_param_value = [dict((k, v) for k, v in x.items() if x[k] is not None) for x in new_param_value] + new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value] if new_param_value != old_value: changeset[camel(param)] = new_param_value @@ -873,17 +937,17 @@ def main(): if module.params['provider_id'] in ['kerberos', 'sssd']: module.fail_json(msg='Cannot configure mappers for {type} provider.'.format(type=module.params['provider_id'])) for change in module.params['mappers']: - change = dict((k, v) for k, v in change.items() if change[k] is not None) + change = {k: v for k, v in change.items() if v is not None} if change.get('id') is None and change.get('name') is None: module.fail_json(msg='Either `name` or `id` has to be specified on each mapper.') if cid is None: old_mapper = {} elif change.get('id') is not None: - old_mapper = kc.get_component(change['id'], realm) + old_mapper = next((before_mapper for before_mapper in before_comp.get('mappers', []) if before_mapper["id"] == change['id']), None) if old_mapper is None: old_mapper = {} else: - found = kc.get_components(urlencode(dict(parent=cid, name=change['name'])), realm) + found = [before_mapper for before_mapper in before_comp.get('mappers', []) if before_mapper['name'] == change['name']] if len(found) > 1: module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=change['name'])) if len(found) == 1: @@ -892,10 +956,16 @@ def main(): old_mapper = {} new_mapper = old_mapper.copy() new_mapper.update(change) - if new_mapper != old_mapper: - if changeset.get('mappers') is None: - changeset['mappers'] = list() - changeset['mappers'].append(new_mapper) + # changeset contains all desired mappers: those existing, to update or to create + if changeset.get('mappers') is None: + changeset['mappers'] = list() + changeset['mappers'].append(new_mapper) + changeset['mappers'] = sorted(changeset['mappers'], key=lambda x: x.get('name') or '') + + # to keep unspecified existing mappers we add them to the desired mappers list, unless they're already present + if not module.params['remove_unspecified_mappers'] and 'mappers' in before_comp: + changeset_mapper_ids = [mapper['id'] for mapper in changeset['mappers'] if 'id' in mapper] + changeset['mappers'].extend([mapper for mapper in before_comp['mappers'] if mapper['id'] not in changeset_mapper_ids]) # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) desired_comp = before_comp.copy() @@ -918,50 +988,68 @@ def main(): # Process a creation result['changed'] = True - if module._diff: - result['diff'] = dict(before='', after=sanitize(desired_comp)) - if module.check_mode: + if module._diff: + result['diff'] = dict(before='', after=sanitize(desired_comp)) module.exit_json(**result) # create it - desired_comp = desired_comp.copy() - updated_mappers = desired_comp.pop('mappers', []) + desired_mappers = desired_comp.pop('mappers', []) after_comp = kc.create_component(desired_comp, realm) - cid = after_comp['id'] + updated_mappers = [] + # when creating a user federation, keycloak automatically creates default mappers + default_mappers = kc.get_components(urlencode(dict(parent=cid)), realm) - for mapper in updated_mappers: - found = kc.get_components(urlencode(dict(parent=cid, name=mapper['name'])), realm) + # create new mappers or update existing default mappers + for desired_mapper in desired_mappers: + found = [default_mapper for default_mapper in default_mappers if default_mapper['name'] == desired_mapper['name']] if len(found) > 1: - module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=mapper['name'])) + module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=desired_mapper['name'])) if len(found) == 1: old_mapper = found[0] else: old_mapper = {} new_mapper = old_mapper.copy() - new_mapper.update(mapper) + new_mapper.update(desired_mapper) if new_mapper.get('id') is not None: kc.update_component(new_mapper, realm) + updated_mappers.append(new_mapper) else: if new_mapper.get('parentId') is None: - new_mapper['parentId'] = after_comp['id'] - mapper = kc.create_component(new_mapper, realm) + new_mapper['parentId'] = cid + updated_mappers.append(kc.create_component(new_mapper, realm)) - after_comp['mappers'] = updated_mappers + if module.params['remove_unspecified_mappers']: + # we remove all unwanted default mappers + # we use ids so we dont accidently remove one of the previously updated default mapper + for default_mapper in default_mappers: + if not default_mapper['id'] in [x['id'] for x in updated_mappers]: + kc.delete_component(default_mapper['id'], realm) + + after_comp['mappers'] = kc.get_components(urlencode(dict(parent=cid)), realm) + normalize_kc_comp(after_comp) + if module._diff: + result['diff'] = dict(before='', after=sanitize(after_comp)) result['end_state'] = sanitize(after_comp) - - result['msg'] = "User federation {id} has been created".format(id=after_comp['id']) + result['msg'] = "User federation {id} has been created".format(id=cid) module.exit_json(**result) else: if state == 'present': # Process an update + desired_copy = deepcopy(desired_comp) + before_copy = deepcopy(before_comp) + # exclude bindCredential when checking wether an update is required, therefore + # updating it only if there are other changes + if module.params['bind_credential_update_mode'] == 'only_indirect': + desired_copy.get('config', []).pop('bindCredential', None) + before_copy.get('config', []).pop('bindCredential', None) # no changes - if desired_comp == before_comp: + if desired_copy == before_copy: result['changed'] = False result['end_state'] = sanitize(desired_comp) result['msg'] = "No changes required to user federation {id}.".format(id=cid) @@ -977,22 +1065,33 @@ def main(): module.exit_json(**result) # do the update - desired_comp = desired_comp.copy() - updated_mappers = desired_comp.pop('mappers', []) + desired_mappers = desired_comp.pop('mappers', []) kc.update_component(desired_comp, realm) - after_comp = kc.get_component(cid, realm) - for mapper in updated_mappers: + for before_mapper in before_comp.get('mappers', []): + # remove unwanted existing mappers that will not be updated + if not before_mapper['id'] in [x['id'] for x in desired_mappers if 'id' in x]: + kc.delete_component(before_mapper['id'], realm) + + for mapper in desired_mappers: + if mapper in before_comp.get('mappers', []): + continue if mapper.get('id') is not None: kc.update_component(mapper, realm) else: if mapper.get('parentId') is None: mapper['parentId'] = desired_comp['id'] - mapper = kc.create_component(mapper, realm) - - after_comp['mappers'] = updated_mappers - result['end_state'] = sanitize(after_comp) + kc.create_component(mapper, realm) + after_comp = kc.get_component(cid, realm) + after_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name') or '') + normalize_kc_comp(after_comp) + after_comp_sanitized = sanitize(after_comp) + before_comp_sanitized = sanitize(before_comp) + result['end_state'] = after_comp_sanitized + if module._diff: + result['diff'] = dict(before=before_comp_sanitized, after=after_comp_sanitized) + result['changed'] = before_comp_sanitized != after_comp_sanitized result['msg'] = "User federation {id} has been updated".format(id=cid) module.exit_json(**result) diff --git a/roles/keycloak/defaults/main.yml b/roles/keycloak/defaults/main.yml index cfa9a3f..137111f 100644 --- a/roles/keycloak/defaults/main.yml +++ b/roles/keycloak/defaults/main.yml @@ -118,3 +118,7 @@ keycloak_no_log: true ### logging configuration keycloak_log_target: /var/log/keycloak + +# locations +keycloak_url: "http://{{ keycloak_host }}:{{ keycloak_http_port + keycloak_jboss_port_offset }}" +keycloak_management_url: "http://{{ keycloak_host }}:{{ keycloak_management_http_port + keycloak_jboss_port_offset }}" diff --git a/roles/keycloak/meta/main.yml b/roles/keycloak/meta/main.yml index a70e9f1..59499e6 100644 --- a/roles/keycloak/meta/main.yml +++ b/roles/keycloak/meta/main.yml @@ -12,7 +12,7 @@ galaxy_info: license: Apache License 2.0 - min_ansible_version: "2.14" + min_ansible_version: "2.16" platforms: - name: EL diff --git a/roles/keycloak/tasks/fastpackages.yml b/roles/keycloak/tasks/fastpackages.yml index be34c72..a89f7f6 100644 --- a/roles/keycloak/tasks/fastpackages.yml +++ b/roles/keycloak/tasks/fastpackages.yml @@ -14,7 +14,7 @@ - name: "Install packages: {{ packages_to_install }}" become: true - ansible.builtin.yum: + ansible.builtin.dnf: name: "{{ packages_to_install }}" state: present when: diff --git a/roles/keycloak/tasks/rhsso_cli.yml b/roles/keycloak/tasks/rhsso_cli.yml index fd41dd6..e40dec8 100644 --- a/roles/keycloak/tasks/rhsso_cli.yml +++ b/roles/keycloak/tasks/rhsso_cli.yml @@ -2,12 +2,12 @@ - name: Ensure required params for CLI have been provided ansible.builtin.assert: that: - - query is defined + - cli_query is defined fail_msg: "Missing required parameters to execute CLI." quiet: true -- name: "Execute CLI query: {{ query }}" +- name: "Execute CLI query: {{ cli_query }}" ansible.builtin.command: > - {{ keycloak.cli_path }} --connect --command='{{ query }}' --controller={{ keycloak_host }}:{{ keycloak_management_http_port }} + {{ keycloak.cli_path }} --connect --command='{{ cli_query }}' --controller={{ keycloak_host }}:{{ keycloak_management_http_port }} changed_when: false register: cli_result diff --git a/roles/keycloak/tasks/rhsso_patch.yml b/roles/keycloak/tasks/rhsso_patch.yml index e7ac3f0..23d75bf 100644 --- a/roles/keycloak/tasks/rhsso_patch.yml +++ b/roles/keycloak/tasks/rhsso_patch.yml @@ -106,7 +106,7 @@ - name: "Check installed patches" ansible.builtin.include_tasks: rhsso_cli.yml vars: - query: "patch info" + cli_query: "patch info" args: apply: become: true @@ -121,7 +121,7 @@ - name: "Apply patch {{ patch_version }} to server" ansible.builtin.include_tasks: rhsso_cli.yml vars: - query: "patch apply {{ patch_archive }}" + cli_query: "patch apply {{ patch_archive }}" args: apply: become: true @@ -130,7 +130,7 @@ - name: "Restart server to ensure patch content is running" ansible.builtin.include_tasks: rhsso_cli.yml vars: - query: "shutdown --restart" + cli_query: "shutdown --restart" when: - cli_result.rc == 0 args: @@ -149,7 +149,7 @@ - name: "Query installed patch after restart" ansible.builtin.include_tasks: rhsso_cli.yml vars: - query: "patch info" + cli_query: "patch info" args: apply: become: true diff --git a/roles/keycloak/vars/main.yml b/roles/keycloak/vars/main.yml index 7f7dfd1..fe706db 100644 --- a/roles/keycloak/vars/main.yml +++ b/roles/keycloak/vars/main.yml @@ -1,9 +1,6 @@ --- # internal variables below -# locations -keycloak_url: "http://{{ keycloak_host }}:{{ keycloak_http_port + keycloak_jboss_port_offset }}" -keycloak_management_url: "http://{{ keycloak_host }}:{{ keycloak_management_http_port + keycloak_jboss_port_offset }}" keycloak: diff --git a/roles/keycloak_quarkus/README.md b/roles/keycloak_quarkus/README.md index a20c760..7f93a9f 100644 --- a/roles/keycloak_quarkus/README.md +++ b/roles/keycloak_quarkus/README.md @@ -1,8 +1,8 @@ keycloak_quarkus ================ - + Install [keycloak](https://keycloak.org/) >= 20.0.0 (quarkus) server configurations. - + Requirements ------------ @@ -33,55 +33,38 @@ Role Defaults | Variable | Description | Default | |:---------|:------------|:--------| -|`keycloak_quarkus_version`| keycloak.org package version | `24.0.4` | +|`keycloak_quarkus_version`| keycloak.org package version | `26.0.7` | |`keycloak_quarkus_offline_install` | Perform an offline install | `False`| |`keycloak_quarkus_dest`| Installation root path | `/opt/keycloak` | |`keycloak_quarkus_download_url` | Download URL for keycloak | `https://github.com/keycloak/keycloak/releases/download/{{ keycloak_quarkus_version }}/{{ keycloak_quarkus_archive }}` | +|`keycloak_quarkus_download_path`| Path local to controller for offline/download of install archives | `{{ lookup('env', 'PWD') }}` | #### Service configuration | Variable | Description | Default | |:---------|:------------|:--------| -|`keycloak_quarkus_admin_user`| Administration console user account | `admin` | -|`keycloak_quarkus_bind_address`| Address for binding service ports | `0.0.0.0` | -|`keycloak_quarkus_host`| Hostname for the Keycloak server | `localhost` | -|`keycloak_quarkus_port`| The port used by the proxy when exposing the hostname | `-1` | -|`keycloak_quarkus_path`| This should be set if proxy uses a different context-path for Keycloak | | -|`keycloak_quarkus_http_port`| HTTP listening port | `8080` | -|`keycloak_quarkus_https_port`| TLS HTTP listening port | `8443` | -|`keycloak_quarkus_ajp_port`| AJP port | `8009` | +|`keycloak_quarkus_bootstrap_admin_user`| Administration console user account | `admin` | +|`keycloak_quarkus_admin_user`| Deprecated, use `keycloak_quarkus_bootstrap_admin_user` instead. | | +|`keycloak_quarkus_bind_address`| Deprecated, use `keycloak_quarkus_http_host` instead | `0.0.0.0` | +|`keycloak_quarkus_host`| Deprecated, use `keycloak_quarkus_hostname` instead. | | +|`keycloak_quarkus_port`| Deprecated, use `keycloak_quarkus_hostname` instead. | | +|`keycloak_quarkus_path`| Deprecated, use `keycloak_quarkus_hostname` instead. | | |`keycloak_quarkus_service_user`| Posix account username | `keycloak` | |`keycloak_quarkus_service_group`| Posix account group | `keycloak` | |`keycloak_quarkus_service_restart_always`| systemd restart always behavior activation | `False` | |`keycloak_quarkus_service_restart_on_failure`| systemd restart on-failure behavior activation | `False` | |`keycloak_quarkus_service_restartsec`| systemd RestartSec | `10s` | -|`keycloak_quarkus_jvm_package`| RHEL java package runtime | `java-17-openjdk-headless` | +|`keycloak_quarkus_jvm_package`| RHEL java package runtime | `java-21-openjdk-headless` | |`keycloak_quarkus_java_home`| JAVA_HOME of installed JRE, leave empty for using specified keycloak_quarkus_jvm_package RPM path | `None` | |`keycloak_quarkus_java_heap_opts`| Heap memory JVM setting | `-Xms1024m -Xmx2048m` | |`keycloak_quarkus_java_jvm_opts`| Other JVM settings | same as keycloak | |`keycloak_quarkus_java_opts`| JVM arguments; if overridden, it takes precedence over `keycloak_quarkus_java_*` | `{{ keycloak_quarkus_java_heap_opts + ' ' + keycloak_quarkus_java_jvm_opts }}` | |`keycloak_quarkus_additional_env_vars` | List of additional env variables of { key: str, value: str} to be put in sysconfig file | `[]` | -|`keycloak_quarkus_frontend_url`| Set the base URL for frontend URLs, including scheme, host, port and path | | -|`keycloak_quarkus_admin_url`| Set the base URL for accessing the administration console, including scheme, host, port and path | | -|`keycloak_quarkus_http_relative_path` | Set the path relative to / for serving resources. The path must start with a / | `/` | -|`keycloak_quarkus_http_enabled`| Enable listener on HTTP port | `True` | -|`keycloak_quarkus_health_check_url_path`| Path to the health check endpoint; scheme, host and keycloak_quarkus_http_relative_path will be prepended automatically | `realms/master/.well-known/openid-configuration` | -|`keycloak_quarkus_https_key_file_enabled`| Enable listener on HTTPS port | `False` | -|`keycloak_quarkus_key_file_copy_enabled`| Enable copy of key file to target host | `False` | -|`keycloak_quarkus_key_content`| Content of the TLS private key. Use `"{{ lookup('file', 'server.key.pem') }}"` to lookup a file. | `""` | -|`keycloak_quarkus_key_file`| The file path to a private key in PEM format | `/etc/pki/tls/private/server.key.pem` | -|`keycloak_quarkus_cert_file_copy_enabled`| Enable copy of cert file to target host | `False`| -|`keycloak_quarkus_cert_file_src`| Set the source file path | `""` | -|`keycloak_quarkus_cert_file`| The file path to a server certificate or certificate chain in PEM format | `/etc/pki/tls/certs/server.crt.pem` | -|`keycloak_quarkus_https_key_store_enabled`| Enable configuration of HTTPS via a key store | `False` | -|`keycloak_quarkus_key_store_file`| Deprecated, use `keycloak_quarkus_https_key_store_file` instead. || -|`keycloak_quarkus_key_store_password`| Deprecated, use `keycloak_quarkus_https_key_store_password` instead.|| -|`keycloak_quarkus_https_key_store_file`| The file path to the key store | `{{ keycloak.home }}/conf/key_store.p12` | -|`keycloak_quarkus_https_key_store_password`| Password for the key store | `""` | -|`keycloak_quarkus_https_trust_store_enabled`| Enable configuration of the https trust store | `False` | -|`keycloak_quarkus_https_trust_store_file`| The file path to the trust store | `{{ keycloak.home }}/conf/trust_store.p12` | -|`keycloak_quarkus_https_trust_store_password`| Password for the trust store | `""` | +|`keycloak_quarkus_frontend_url`| Deprecated, use `keycloak_quarkus_hostname` instead. | | +|`keycloak_quarkus_admin_url`| Deprecated, use `keycloak_quarkus_hostname_admin` instead. | | +|`keycloak_quarkus_health_check_url`| Full URL (including scheme, host, path, fragment etc.) used for health check endpoint; keycloak_quarkus_hostname will NOT be prepended; helpful when health checks should happen against http port, but keycloak_quarkus_hostname uses https scheme per default | `` | +|`keycloak_quarkus_health_check_url_path`| Path to the health check endpoint; keycloak_quarkus_hostname will be prepended automatically; Note that keycloak_quarkus_health_check_url takes precedence over this property | `realms/master/.well-known/openid-configuration` | |`keycloak_quarkus_proxy_headers`| Parse reverse proxy headers (`forwarded` or `xforwarded`) | `""` | |`keycloak_quarkus_config_key_store_file`| Path to the configuration key store; only used if `keycloak_quarkus_keystore_password` is not empty | `{{ keycloak.home }}/conf/conf_store.p12` if `keycloak_quarkus_keystore_password != ''`, else `''` | |`keycloak_quarkus_config_key_store_password`| Password of the configuration keystore; if non-empty, `keycloak_quarkus_db_pass` will be saved to the keystore at `keycloak_quarkus_config_key_store_file` instead of being written to the configuration file in clear text | `""` | @@ -98,13 +81,14 @@ Role Defaults |`keycloak_quarkus_db_enabled`| Enable auto configuration for database backend | `True` if `keycloak_quarkus_ha_enabled` is True, else `False` | |`keycloak_quarkus_jgroups_port`| jgroups cluster tcp port | `7800` | |`keycloak_quarkus_systemd_wait_for_port` | Whether systemd unit should wait for keycloak port before returning | `{{ keycloak_quarkus_ha_enabled }}` | +|`keycloak_quarkus_systemd_wait_for_port_number`| Which port the systemd unit should wait for | `{{ keycloak_quarkus_https_port }}` | |`keycloak_quarkus_systemd_wait_for_log` | Whether systemd unit should wait for service to be up in logs | `false` | |`keycloak_quarkus_systemd_wait_for_timeout`| How long to wait for service to be alive (seconds) | `60` | |`keycloak_quarkus_systemd_wait_for_delay`| Activation delay for service systemd unit (seconds) | `10` | |`keycloak_quarkus_restart_strategy`| Strategy task file for restarting in HA (one of provided restart/['serial.yml','none.yml','serial_then_parallel.yml']) or path to file when providing custom strategy | `restart/serial.yml` | -|`keycloak_quarkus_restart_health_check`| Whether to wait for successful health check after restart | `{{ keycloak_quarkus_ha_enabled }}` | +|`keycloak_quarkus_restart_health_check`| Whether to wait for successful health check after restart | `true` | |`keycloak_quarkus_restart_health_check_delay`| Seconds to let pass before starting healch checks | `10` | -|`keycloak_quarkus_restart_health_check_reries`| Number of attempts for successful health check before failing | `25` | +|`keycloak_quarkus_restart_health_check_retries`| Number of attempts for successful health check before failing | `25` | |`keycloak_quarkus_restart_pause`| Seconds to wait between restarts in HA strategy | `15` | @@ -112,49 +96,67 @@ Role Defaults | Variable | Description | Default | |:---------|:------------|:--------| -|`keycloak_quarkus_http_relative_path`| Set the path relative to / for serving resources. The path must start with a / | `/` | +|`keycloak_quarkus_hostname`| Address at which is the server exposed. Can be a full URL, or just a hostname. When only hostname is provided, scheme, port and context path are resolved from the request. | | +|`keycloak_quarkus_hostname_admin`| Set the base URL for accessing the administration console, including scheme, host, port and path | | |`keycloak_quarkus_hostname_strict`| Disables dynamically resolving the hostname from request headers | `true` | -|`keycloak_quarkus_hostname_strict_backchannel`| By default backchannel URLs are dynamically resolved from request headers to allow internal and external applications. If all applications use the public URL this option should be enabled. | `false` | +|`keycloak_quarkus_hostname_backchannel_dynamic`| Enables dynamic resolving of backchannel URLs, including hostname, scheme, port and context path. Set to true if your application accesses Keycloak via a private network. If set to true, hostname option needs to be specified as a full URL. | `false` | +|`keycloak_quarkus_hostname_strict_backchannel`| Deprecated, use (the inverted!)`keycloak_quarkus_hostname_backchannel_dynamic` instead. | | + + +#### HTTP(S) configuration +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_http_relative_path`| Set the path relative to / for serving resources. The path must start with a / | `/` | +|`keycloak_quarkus_http_host`| The http host, ie. the address used to bind the service | `0.0.0.0` | +|`keycloak_quarkus_http_port`| HTTP listening port | `8080` | +|`keycloak_quarkus_https_port`| TLS HTTP listening port | `8443` | +|`keycloak_quarkus_http_management_port`| Port of the management interface. Relevant only when something is exposed on the management interface - see the guide for details. | `9000` | +|`keycloak_quarkus_https_key_store_file`| The file path to the key store | `{{ keycloak.home }}/conf/key_store.p12` | +|`keycloak_quarkus_https_key_store_password`| Password for the key store | `""` | +|`keycloak_quarkus_https_trust_store_enabled`| Enable configuration of the https trust store | `False` | +|`keycloak_quarkus_https_trust_store_file`| The file path to the trust store | `{{ keycloak.home }}/conf/trust_store.p12` | +|`keycloak_quarkus_https_trust_store_password`| Password for the trust store | `""` | +|`keycloak_quarkus_https_key_file_enabled`| Enable listener on HTTPS port | `False` | +|`keycloak_quarkus_key_file_copy_enabled`| Enable copy of key file to target host | `False` | +|`keycloak_quarkus_key_content`| Content of the TLS private key. Use `"{{ lookup('file', 'server.key.pem') }}"` to lookup a file. | `""` | +|`keycloak_quarkus_key_file`| The file path to a private key in PEM format | `/etc/pki/tls/private/server.key.pem` | +|`keycloak_quarkus_cert_file_copy_enabled`| Enable copy of cert file to target host | `False`| +|`keycloak_quarkus_cert_file_src`| Set the source file path | `""` | +|`keycloak_quarkus_cert_file`| The file path to a server certificate or certificate chain in PEM format | `/etc/pki/tls/certs/server.crt.pem` | +|`keycloak_quarkus_https_key_store_enabled`| Enable configuration of HTTPS via a key store | `False` | +|`keycloak_quarkus_key_store_file`| Deprecated, use `keycloak_quarkus_https_key_store_file` instead. || +|`keycloak_quarkus_key_store_password`| Deprecated, use `keycloak_quarkus_https_key_store_password` instead.|| +|`keycloak_quarkus_http_relative_path` | Set the path relative to / for serving resources. The path must start with a / | `/` | +|`keycloak_quarkus_http_management_relative_path` | Set the path relative to / for serving resources from management interface. The path must start with a /. If not given, the value is inherited from HTTP options. Relevant only when something is exposed on the management interface - see the guide for details. | `/` | +|`keycloak_quarkus_http_enabled`| Enable listener on HTTP port | `True` | #### Database configuration | Variable | Description | Default | |:---------|:------------|:--------| -|`keycloak_quarkus_jdbc_engine` | Database engine [mariadb,postres,mssql] | `postgres` | +|`keycloak_quarkus_db_engine` | Database engine [mariadb,postres,mssql] | `postgres` | |`keycloak_quarkus_db_user` | User for database connection | `keycloak-user` | |`keycloak_quarkus_db_pass` | Password for database connection | `keycloak-pass` | -|`keycloak_quarkus_jdbc_url` | JDBC URL for connecting to database | `jdbc:postgresql://localhost:5432/keycloak` | -|`keycloak_quarkus_jdbc_driver_version` | Version for JDBC driver | `9.4.1212` | +|`keycloak_quarkus_db_url` | JDBC URL for connecting to database | `jdbc:postgresql://localhost:5432/keycloak` | +|`keycloak_quarkus_db_driver_version` | Version for JDBC engine driver | `9.4.1212` | -#### Remote caches configuration +#### Cache configuration | Variable | Description | Default | |:---------|:------------|:--------| -|`keycloak_quarkus_ispn_user` | Username for connecting to infinispan | `supervisor` | -|`keycloak_quarkus_ispn_pass` | Password for connecting to infinispan | `supervisor` | -|`keycloak_quarkus_ispn_hosts` | host name/port for connecting to infinispan, eg. host1:11222;host2:11222 | `localhost:11222` | -|`keycloak_quarkus_ispn_sasl_mechanism` | Infinispan auth mechanism | `SCRAM-SHA-512` | -|`keycloak_quarkus_ispn_use_ssl` | Whether infinispan uses TLS connection | `false` | -|`keycloak_quarkus_ispn_trust_store_path` | Path to infinispan server trust certificate | `/etc/pki/java/cacerts` | -|`keycloak_quarkus_ispn_trust_store_password` | Password for infinispan certificate keystore | `changeit` | +|`keycloak_quarkus_cache_remote_username` | Username for connecting to infinispan | `supervisor` | +|`keycloak_quarkus_cache_remote_password` | Password for connecting to infinispan | `supervisor` | +|`keycloak_quarkus_cache_remote_host` | host name/port for connecting to infinispan, eg. host1:11222;host2:11222 | `localhost:11222` | +|`keycloak_quarkus_cache_remote_sasl_mechanism` | Infinispan auth mechanism | `SCRAM-SHA-512` | +|`keycloak_quarkus_cache_remote_tls_enabled` | Whether infinispan uses TLS connection | `false` | -#### Miscellaneous configuration +#### Logging configuration | Variable | Description | Default | |:---------|:------------|:--------| -|`keycloak_quarkus_metrics_enabled`| Whether to enable metrics | `False` | -|`keycloak_quarkus_health_enabled`| If the server should expose health check endpoints | `True` | -|`keycloak_quarkus_archive` | keycloak install archive filename | `keycloak-{{ keycloak_quarkus_version }}.zip` | -|`keycloak_quarkus_installdir` | Installation path | `{{ keycloak_quarkus_dest }}/keycloak-{{ keycloak_quarkus_version }}` | -|`keycloak_quarkus_home` | Installation work directory | `{{ keycloak_quarkus_installdir }}` | -|`keycloak_quarkus_config_dir` | Path for configuration | `{{ keycloak_quarkus_home }}/conf` | -|`keycloak_quarkus_master_realm` | Name for rest authentication realm | `master` | -|`keycloak_auth_client` | Authentication client for configuration REST calls | `admin-cli` | -|`keycloak_force_install` | Remove pre-existing versions of service | `False` | -|`keycloak_url` | URL for configuration rest calls | `http://{{ keycloak_quarkus_host }}:{{ keycloak_http_port }}` | |`keycloak_quarkus_log`| Enable one or more log handlers in a comma-separated list | `file` | |`keycloak_quarkus_log_level`| The log level of the root category or a comma-separated list of individual categories and their levels | `info` | |`keycloak_quarkus_log_file`| Set the log file path and filename relative to keycloak home | `data/log/keycloak.log` | @@ -163,6 +165,21 @@ Role Defaults |`keycloak_quarkus_log_max_file_size`| Set the maximum log file size before a log rotation happens; A size configuration option recognises string in this format (shown as a regular expression): `[0-9]+[KkMmGgTtPpEeZzYy]?`. If no suffix is given, assume bytes. | `10M` | |`keycloak_quarkus_log_max_backup_index`| Set the maximum number of archived log files to keep" | `10` | |`keycloak_quarkus_log_file_suffix`| Set the log file handler rotation file suffix. When used, the file will be rotated based on its suffix; Note: If the suffix ends with `.zip` or `.gz`, the rotation file will also be compressed. | `.yyyy-MM-dd.zip` | + + +#### Miscellaneous configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_metrics_enabled`| Whether to enable metrics | `False` | +|`keycloak_quarkus_health_enabled`| If the server should expose health check endpoints on the management interface | `True` | +|`keycloak_quarkus_archive` | keycloak install archive filename | `keycloak-{{ keycloak_quarkus_version }}.zip` | +|`keycloak_quarkus_installdir` | Installation path | `{{ keycloak_quarkus_dest }}/keycloak-{{ keycloak_quarkus_version }}` | +|`keycloak_quarkus_home` | Installation work directory | `{{ keycloak_quarkus_installdir }}` | +|`keycloak_quarkus_config_dir` | Path for configuration | `{{ keycloak_quarkus_home }}/conf` | +|`keycloak_quarkus_master_realm` | Name for rest authentication realm | `master` | +|`keycloak_auth_client` | Authentication client for configuration REST calls | `admin-cli` | +|`keycloak_force_install` | Remove pre-existing versions of service | `False` | |`keycloak_quarkus_proxy_mode`| The proxy address forwarding mode if the server is behind a reverse proxy | `edge` | |`keycloak_quarkus_start_dev`| Whether to start the service in development mode (start-dev) | `False` | |`keycloak_quarkus_transaction_xa_enabled`| Whether to use XA transactions | `True` | @@ -170,7 +187,7 @@ Role Defaults |`keycloak_quarkus_show_deprecation_warnings`| Whether deprecation warnings should be shown | `True` | -#### Vault SPI +#### Vault configuration | Variable | Description | Default | |:---------|:------------|:--------| @@ -198,14 +215,14 @@ keycloak_quarkus_providers: - id: http-client # required; "{{ id }}.jar" identifies the file name on RHBK spi: connections # required if neither url, local_path nor maven are specified; required for setting properties default: true # optional, whether to set default for spi, default false - restart: true # optional, whether to restart, default true + restart: true # optional, whether to rebuild config and restart the service after deploying, default true url: https://.../.../custom_spi.jar # optional, url for download via http local_path: my_theme_spi.jar # optional, path on local controller for SPI to be uploaded maven: # optional, for download using maven repository_url: https://maven.pkg.github.com/OWNER/REPOSITORY # optional, maven repo url group_id: my.group # optional, maven group id artifact_id: artifact # optional, maven artifact id - version: 24.0.4 # optional, defaults to latest + version: 24.0.5 # optional, defaults to latest username: user # optional, cf. https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-apache-maven-registry#authenticating-to-github-packages password: pat # optional, provide a PAT for accessing Github's Apache Maven registry properties: # optional, list of key-values @@ -241,9 +258,8 @@ Role Variables | Variable | Description | Required | |:---------|:------------|----------| -|`keycloak_quarkus_admin_pass`| Password of console admin account | `yes` | -|`keycloak_quarkus_frontend_url`| Base URL for frontend URLs, including scheme, host, port and path | `no` | -|`keycloak_quarkus_admin_url`| Base URL for accessing the administration console, including scheme, host, port and path | `no` | +|`keycloak_quarkus_bootstrap_admin_password`| Password of console admin account | `yes` | +|`keycloak_quarkus_admin_pass`| Deprecated, use `keycloak_quarkus_bootstrap_admin_password` instead. | | |`keycloak_quarkus_ks_vault_pass`| The password for accessing the keystore vault SPI | `no` | |`keycloak_quarkus_alternate_download_url`| Alternate location with optional authentication for downloading RHBK | `no` | |`keycloak_quarkus_download_user`| Optional username for http authentication | `no*` | @@ -263,7 +279,7 @@ The role uses the following [custom facts](https://docs.ansible.com/ansible/late | Variable | Description | |:---------|:------------| -|`general.bootstrapped` | A custom fact indicating whether this role has been used for bootstrapping keycloak on the respective host before; set to `false` (e.g., when starting off with a new, empty database) ensures that the initial admin user as defined by `keycloak_quarkus_admin_user[_pass]` gets created | +|`general.bootstrapped` | A custom fact indicating whether this role has been used for bootstrapping keycloak on the respective host before; set to `false` (e.g., when starting off with a new, empty database) ensures that the initial admin user as defined by `keycloak_quarkus_bootstrap_admin_user[_password]` gets created | License ------- diff --git a/roles/keycloak_quarkus/defaults/main.yml b/roles/keycloak_quarkus/defaults/main.yml index b3752c5..7fefc5a 100644 --- a/roles/keycloak_quarkus/defaults/main.yml +++ b/roles/keycloak_quarkus/defaults/main.yml @@ -1,6 +1,6 @@ --- ### Configuration specific to keycloak -keycloak_quarkus_version: 24.0.4 +keycloak_quarkus_version: 26.0.7 keycloak_quarkus_archive: "keycloak-{{ keycloak_quarkus_version }}.zip" keycloak_quarkus_download_url: "https://github.com/keycloak/keycloak/releases/download/{{ keycloak_quarkus_version }}/{{ keycloak_quarkus_archive }}" keycloak_quarkus_installdir: "{{ keycloak_quarkus_dest }}/keycloak-{{ keycloak_quarkus_version }}" @@ -15,6 +15,7 @@ keycloak_quarkus_java_home: keycloak_quarkus_dest: /opt/keycloak keycloak_quarkus_home: "{{ keycloak_quarkus_installdir }}" keycloak_quarkus_config_dir: "{{ keycloak_quarkus_home }}/conf" +keycloak_quarkus_download_path: "{{ lookup('env', 'PWD') }}" keycloak_quarkus_start_dev: false keycloak_quarkus_service_user: keycloak keycloak_quarkus_service_group: keycloak @@ -26,19 +27,17 @@ keycloak_quarkus_configure_firewalld: false keycloak_quarkus_configure_iptables: false ### administrator console password -keycloak_quarkus_admin_user: admin -keycloak_quarkus_admin_pass: +keycloak_quarkus_bootstrap_admin_user: admin +keycloak_quarkus_bootstrap_admin_password: keycloak_quarkus_master_realm: master ### Configuration settings -keycloak_quarkus_bind_address: 0.0.0.0 -keycloak_quarkus_host: localhost -keycloak_quarkus_port: -1 -keycloak_quarkus_path: +keycloak_quarkus_bind_address: 0.0.0.0 # deprecated use keycloak_quarkus_http_host +keycloak_quarkus_http_host: 0.0.0.0 keycloak_quarkus_http_enabled: true keycloak_quarkus_http_port: 8080 keycloak_quarkus_https_port: 8443 -keycloak_quarkus_ajp_port: 8009 +keycloak_quarkus_http_management_port: 9000 keycloak_quarkus_jgroups_port: 7800 keycloak_quarkus_java_heap_opts: "-Xms1024m -Xmx2048m" keycloak_quarkus_java_jvm_opts: "-XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 @@ -74,13 +73,14 @@ keycloak_quarkus_ha_discovery: "TCPPING" ### Enable database configuration, must be enabled when HA is configured keycloak_quarkus_db_enabled: "{{ keycloak_quarkus_ha_enabled }}" keycloak_quarkus_systemd_wait_for_port: "{{ keycloak_quarkus_ha_enabled }}" +keycloak_quarkus_systemd_wait_for_port_number: "{{ keycloak_quarkus_https_port }}" keycloak_quarkus_systemd_wait_for_log: false keycloak_quarkus_systemd_wait_for_timeout: 60 keycloak_quarkus_systemd_wait_for_delay: 10 ### keycloak frontend url -keycloak_quarkus_frontend_url: -keycloak_quarkus_admin_url: +keycloak_quarkus_hostname: +keycloak_quarkus_hostname_admin: ### Set the path relative to / for serving resources. The path must start with a / ### (set to `/auth` for retrocompatibility with pre-quarkus releases) @@ -89,9 +89,9 @@ keycloak_quarkus_http_relative_path: / # Disables dynamically resolving the hostname from request headers. # Should always be set to true in production, unless proxy verifies the Host header. keycloak_quarkus_hostname_strict: true -# By default backchannel URLs are dynamically resolved from request headers to allow internal and external applications. -# If all applications use the public URL this option should be enabled. -keycloak_quarkus_hostname_strict_backchannel: false +# Enables dynamic resolving of backchannel URLs, including hostname, scheme, port and context path. +# Set to true if your application accesses Keycloak via a private network. If set to true, keycloak_quarkus_hostname option needs to be specified as a full URL. +keycloak_quarkus_hostname_backchannel_dynamic: false # The proxy headers that should be accepted by the server. ['', 'forwarded', 'xforwarded'] keycloak_quarkus_proxy_headers: "" @@ -120,12 +120,12 @@ keycloak_quarkus_ispn_trust_store_path: /etc/pki/java/cacerts keycloak_quarkus_ispn_trust_store_password: changeit ### database backend engine: values [ 'postgres', 'mariadb' ] -keycloak_quarkus_jdbc_engine: postgres +keycloak_quarkus_db_engine: postgres ### database backend credentials keycloak_quarkus_db_user: keycloak-user keycloak_quarkus_db_pass: keycloak-pass -keycloak_quarkus_jdbc_url: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_jdbc_engine].url }}" -keycloak_quarkus_jdbc_driver_version: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_jdbc_engine].version }}" +keycloak_quarkus_db_url: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].url }}" +keycloak_quarkus_db_driver_version: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].version }}" # override the variables above, following defaults show minimum supported versions keycloak_quarkus_default_jdbc: postgres: @@ -136,9 +136,9 @@ keycloak_quarkus_default_jdbc: version: 2.7.4 mssql: url: 'jdbc:sqlserver://localhost:1433;databaseName=keycloak;' - version: 12.4.2 - driver_jar_url: "https://repo1.maven.org/maven2/com/microsoft/sqlserver/mssql-jdbc/12.4.2.jre11/mssql-jdbc-12.4.2.jre11.jar" - # cf. https://access.redhat.com/documentation/en-us/red_hat_build_of_keycloak/24.0/html/server_guide/db-#db-installing-the-microsoft-sql-server-driver + version: 12.8.1 + driver_jar_url: "https://repo1.maven.org/maven2/com/microsoft/sqlserver/mssql-jdbc/12.8.1.jre11/mssql-jdbc-12.8.1.jre11.jar" + # cf. https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html-single/server_configuration_guide/index#db-installing-the-microsoft-sql-server-driver ### logging configuration keycloak_quarkus_log: file keycloak_quarkus_log_level: info @@ -161,7 +161,7 @@ keycloak_quarkus_supported_policy_types: ['password-blacklists'] # files in restart directory (one of [ 'serial', 'none', 'serial_then_parallel' ]), or path to file when providing custom strategy keycloak_quarkus_restart_strategy: restart/serial.yml -keycloak_quarkus_restart_health_check: "{{ keycloak_quarkus_ha_enabled }}" +keycloak_quarkus_restart_health_check: true keycloak_quarkus_restart_health_check_delay: 10 -keycloak_quarkus_restart_health_check_reries: 25 +keycloak_quarkus_restart_health_check_retries: 25 keycloak_quarkus_restart_pause: 15 diff --git a/roles/keycloak_quarkus/handlers/main.yml b/roles/keycloak_quarkus/handlers/main.yml index 5851a71..eec7789 100644 --- a/roles/keycloak_quarkus/handlers/main.yml +++ b/roles/keycloak_quarkus/handlers/main.yml @@ -1,4 +1,7 @@ --- +- name: "Invalidate {{ keycloak.service_name }} theme cache" + ansible.builtin.include_tasks: invalidate_theme_cache.yml + listen: "invalidate keycloak theme cache" # handler should be invoked anytime a [build configuration](https://www.keycloak.org/server/all-config?f=build) changes - name: "Rebuild {{ keycloak.service_name }} config" ansible.builtin.include_tasks: rebuild_config.yml @@ -10,7 +13,7 @@ ansible.builtin.include_tasks: file: "{{ keycloak_quarkus_restart_strategy if keycloak_quarkus_ha_enabled else 'restart.yml' }}" listen: "restart keycloak" -- name: "Print deprecation warning" +- name: "Display deprecation warning" ansible.builtin.fail: msg: "Deprecation warning: you are using the deprecated variable '{{ deprecated_variable | d('NotSet') }}', check docs on how to upgrade." failed_when: false diff --git a/roles/keycloak_quarkus/meta/argument_specs.yml b/roles/keycloak_quarkus/meta/argument_specs.yml index 659e7f6..51b0d1f 100644 --- a/roles/keycloak_quarkus/meta/argument_specs.yml +++ b/roles/keycloak_quarkus/meta/argument_specs.yml @@ -2,7 +2,7 @@ argument_specs: main: options: keycloak_quarkus_version: - default: "24.0.4" + default: "26.0.7" description: "keycloak.org package version" type: "str" keycloak_quarkus_archive: @@ -22,7 +22,7 @@ argument_specs: description: "Perform an offline install" type: "bool" keycloak_quarkus_jvm_package: - default: "java-11-openjdk-headless" + default: "java-21-openjdk-headless" description: "RHEL java package runtime" type: "str" keycloak_quarkus_java_home: @@ -56,25 +56,25 @@ argument_specs: default: false description: "Ensure firewalld is running and configure keycloak ports" type: "bool" - keycloak_service_restart_always: + keycloak_quarkus_service_restart_always: default: false description: "systemd restart always behavior of service; takes precedence over keycloak_service_restart_on_failure if true" type: "bool" - keycloak_service_restart_on_failure: + keycloak_quarkus_service_restart_on_failure: default: false description: "systemd restart on-failure behavior of service" type: "bool" - keycloak_service_restartsec: + keycloak_quarkus_service_restartsec: default: "10s" description: "systemd RestartSec for service" type: "str" - keycloak_quarkus_admin_user: + keycloak_quarkus_bootstrap_admin_user: default: "admin" - description: "Administration console user account" + description: "Administration user account, only for bootstrapping" type: "str" - keycloak_quarkus_admin_pass: + keycloak_quarkus_bootstrap_admin_password: required: true - description: "Password of console admin account" + description: "Password of admin account, only for bootstrapping" type: "str" keycloak_quarkus_master_realm: default: "master" @@ -82,31 +82,40 @@ argument_specs: type: "str" keycloak_quarkus_bind_address: default: "0.0.0.0" - description: "Address for binding service ports" + description: "Deprecated, use `keycloak_quarkus_http_host`" + type: "str" + keycloak_quarkus_hostname: + description: >- + Address at which is the server exposed. + Can be a full URL, or just a hostname. When only hostname is provided, scheme, port and context path are resolved from the request. type: "str" keycloak_quarkus_host: - default: "localhost" - description: "Hostname for the Keycloak server" + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." type: "str" keycloak_quarkus_port: - default: -1 - description: "The port used by the proxy when exposing the hostname" + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." type: "int" keycloak_quarkus_path: - required: false - description: "This should be set if proxy uses a different context-path for Keycloak" + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." type: "str" keycloak_quarkus_http_enabled: default: true description: "Enable listener on HTTP port" type: "bool" + keycloak_quarkus_http_host: + default: '0.0.0.0' + description: "HTTP host, address for binding service ports" + type: "str" keycloak_quarkus_http_port: default: 8080 description: "HTTP port" type: "int" + keycloak_quarkus_health_check_url: + description: "Full URL (including scheme, host, path, fragment etc.) used for health check endpoint; keycloak_quarkus_hostname will NOT be prepended; helpful when health checks should happen against http port, but keycloak_quarkus_hostname uses https scheme per default" + type: "str" keycloak_quarkus_health_check_url_path: default: "realms/master/.well-known/openid-configuration" - description: "Path to the health check endpoint; scheme, host and keycloak_quarkus_http_relative_path will be prepended automatically" + description: "Path to the health check endpoint; keycloak_quarkus_hostname will be prepended automatically; Note that keycloak_quarkus_health_check_url takes precedence over this property" type: "str" keycloak_quarkus_https_key_file_enabled: default: false @@ -182,9 +191,9 @@ argument_specs: default: 8443 description: "HTTPS port" type: "int" - keycloak_quarkus_ajp_port: - default: 8009 - description: "AJP port" + keycloak_quarkus_http_management_port: + default: 9000 + description: "Port of the management interface. Relevant only when something is exposed on the management interface - see the guide for details." type: "int" keycloak_quarkus_jgroups_port: default: 7800 @@ -226,13 +235,21 @@ argument_specs: default: / description: "Set the path relative to / for serving resources. The path must start with a /" type: "str" + keycloak_quarkus_http_management_relative_path: + required: false + description: "Set the path relative to / for serving resources from management interface. The path must start with a /. If not given, the value is inherited from HTTP options. Relevant only when something is exposed on the management interface - see the guide for details." + type: "str" keycloak_quarkus_frontend_url: required: false - description: "Service public URL" + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." + type: "str" + keycloak_quarkus_hostname_admin: + required: false + description: "Service URL for the admin console" type: "str" keycloak_quarkus_admin_url: required: false - description: "Service URL for the admin console" + description: "Deprecated in v26, use keycloak_quarkus_hostname_admin instead." type: "str" keycloak_quarkus_metrics_enabled: default: false @@ -240,37 +257,29 @@ argument_specs: type: "bool" keycloak_quarkus_health_enabled: default: true - description: "If the server should expose health check endpoints" + description: "If the server should expose health check endpoints on the management interface" type: "bool" - keycloak_quarkus_ispn_user: + keycloak_quarkus_cache_remote_username: default: "supervisor" description: "Username for connecting to infinispan" type: "str" - keycloak_quarkus_ispn_pass: + keycloak_quarkus_cache_remote_password: default: "supervisor" description: "Password for connecting to infinispan" type: "str" - keycloak_quarkus_ispn_hosts: + keycloak_quarkus_cache_remote_host: default: "localhost:11222" description: "host name/port for connecting to infinispan, eg. host1:11222;host2:11222" type: "str" - keycloak_quarkus_ispn_sasl_mechanism: + keycloak_quarkus_cache_remote_sasl_mechanism: default: "SCRAM-SHA-512" description: "Infinispan auth mechanism" type: "str" - keycloak_quarkus_ispn_use_ssl: + keycloak_quarkus_cache_remote_tls_enabled: default: false description: "Whether infinispan uses TLS connection" type: "bool" - keycloak_quarkus_ispn_trust_store_path: - default: "/etc/pki/java/cacerts" - description: "Path to infinispan server trust certificate" - type: "str" - keycloak_quarkus_ispn_trust_store_password: - default: "changeit" - description: "Password for infinispan certificate keystore" - type: "str" - keycloak_quarkus_jdbc_engine: + keycloak_quarkus_db_engine: default: "postgres" description: "Database engine [mariadb,postres,mssql]" type: "str" @@ -282,12 +291,12 @@ argument_specs: default: "keycloak-pass" description: "Password for database connection" type: "str" - keycloak_quarkus_jdbc_url: - default: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_jdbc_engine].url }}" + keycloak_quarkus_db_url: + default: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].url }}" description: "JDBC URL for connecting to database" type: "str" - keycloak_quarkus_jdbc_driver_version: - default: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_jdbc_engine].version }}" + keycloak_quarkus_db_driver_version: + default: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].version }}" description: "Version for JDBC driver" type: "str" keycloak_quarkus_log: @@ -348,24 +357,18 @@ argument_specs: description: > Disables dynamically resolving the hostname from request headers. Should always be set to true in production, unless proxy verifies the Host header. - keycloak_quarkus_hostname_strict_backchannel: + keycloak_quarkus_hostname_backchannel_dynamic: default: false type: "bool" description: > - By default backchannel URLs are dynamically resolved from request headers to allow internal and external applications. If all - applications use the public URL this option should be enabled. + Enables dynamic resolving of backchannel URLs, including hostname, scheme, port and context path. + Set to true if your application accesses Keycloak via a private network. If set to true, hostname option needs to be specified as a full URL. keycloak_quarkus_spi_sticky_session_encoder_infinispan_should_attach_route: default: true type: "bool" description: > If the route should be attached to cookies to reflect the node that owns a particular session. If false, route is not attached to cookies and we rely on the session affinity capabilities from reverse proxy - keycloak_quarkus_hostname_strict_https: - type: "bool" - required: false - description: > - By default, Keycloak requires running using TLS/HTTPS. If the service MUST run without TLS/HTTPS, then set - this option to "true" keycloak_quarkus_ks_vault_enabled: default: false type: "bool" @@ -386,6 +389,10 @@ argument_specs: description: 'Whether systemd unit should wait for keycloak port before returning' default: "{{ keycloak_quarkus_ha_enabled }}" type: "bool" + keycloak_quarkus_systemd_wait_for_port_number: + default: "{{ keycloak_quarkus_https_port }}" + description: "The port the systemd unit should wait for, by default the https port" + type: "int" keycloak_quarkus_systemd_wait_for_log: description: 'Whether systemd unit should wait for service to be up in logs' default: false @@ -432,7 +439,7 @@ argument_specs: description: "Allow the option to ignore invalid certificates when downloading JDBC drivers from a custom URL" type: "bool" keycloak_quarkus_restart_health_check: - default: "{{ keycloak_quarkus_ha_enabled }}" + default: true description: "Whether to wait for successful health check after restart" type: "bool" keycloak_quarkus_restart_strategy: @@ -449,14 +456,22 @@ argument_specs: description: "Seconds to let pass before starting healch checks" default: 10 type: 'int' - keycloak_quarkus_restart_health_check_reries: + keycloak_quarkus_restart_health_check_retries: description: "Number of attempts for successful health check before failing" default: 25 type: 'int' + keycloak_quarkus_show_deprecation_warnings: + default: true + description: "Whether or not deprecation warnings should be shown" + type: "bool" + keycloak_quarkus_download_path: + description: "Path local to controller for offline/download of install archives" + default: "{{ lookup('env', 'PWD') }}" + type: "str" downstream: options: rhbk_version: - default: "24.0.3" + default: "26.0.7" description: "Red Hat Build of Keycloak version" type: "str" rhbk_archive: @@ -483,10 +498,6 @@ argument_specs: default: false description: "Perform an offline install" type: "bool" - keycloak_quarkus_show_deprecation_warnings: - default: true - description: "Whether deprecation warnings should be shown" - type: "bool" rhbk_service_name: default: "rhbk" description: "systemd service name for Red Hat Build of Keycloak" diff --git a/roles/keycloak_quarkus/meta/main.yml b/roles/keycloak_quarkus/meta/main.yml index 0f82003..65b5e50 100644 --- a/roles/keycloak_quarkus/meta/main.yml +++ b/roles/keycloak_quarkus/meta/main.yml @@ -8,7 +8,7 @@ galaxy_info: license: Apache License 2.0 - min_ansible_version: "2.14" + min_ansible_version: "2.16" platforms: - name: EL diff --git a/roles/keycloak_quarkus/tasks/bootstrapped.yml b/roles/keycloak_quarkus/tasks/bootstrapped.yml index 46278ab..3cbc5c4 100644 --- a/roles/keycloak_quarkus/tasks/bootstrapped.yml +++ b/roles/keycloak_quarkus/tasks/bootstrapped.yml @@ -1,5 +1,5 @@ --- -- name: Write ansible custom facts +- name: Save ansible custom facts become: true ansible.builtin.template: src: keycloak.fact.j2 @@ -8,7 +8,7 @@ vars: bootstrapped: true -- name: Re-read custom facts +- name: Refresh custom facts ansible.builtin.setup: filter: ansible_local diff --git a/roles/keycloak_quarkus/tasks/config_store.yml b/roles/keycloak_quarkus/tasks/config_store.yml index 40acc65..2d8b39e 100644 --- a/roles/keycloak_quarkus/tasks/config_store.yml +++ b/roles/keycloak_quarkus/tasks/config_store.yml @@ -8,7 +8,7 @@ - name: "Initialize empty configuration key store" become: true # keytool doesn't allow creating an empty key store, so this is a hacky way around it - ansible.builtin.shell: | + ansible.builtin.shell: | # noqa blocked_modules shell is necessary here set -o nounset # abort on unbound variable set -o pipefail # do not hide errors within pipes set -o errexit # abort on nonzero exit status @@ -19,7 +19,7 @@ creates: "{{ keycloak_quarkus_config_key_store_file }}" - name: "Set configuration key store using keytool" - ansible.builtin.shell: | + ansible.builtin.shell: | # noqa blocked_modules shell is necessary here set -o nounset # abort on unbound variable set -o pipefail # do not hide errors within pipes @@ -36,7 +36,7 @@ fi echo {{ item.value | quote }} | keytool -noprompt -importpass -alias {{ item.key | quote }} -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} -storetype PKCS12 - with_items: "{{ store_items }}" + loop: "{{ store_items }}" no_log: true become: true changed_when: true diff --git a/roles/keycloak_quarkus/tasks/deprecations.yml b/roles/keycloak_quarkus/tasks/deprecations.yml index 27ea6e3..0d370d5 100644 --- a/roles/keycloak_quarkus/tasks/deprecations.yml +++ b/roles/keycloak_quarkus/tasks/deprecations.yml @@ -49,5 +49,114 @@ notify: - print deprecation warning +# https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html-single/upgrading_guide/index#new_hostname_options +- name: Check deprecation of keycloak_quarkus_frontend_url -> keycloak_quarkus_hostname + when: + - keycloak_quarkus_hostname is not defined + - keycloak_quarkus_frontend_url is defined + - keycloak_quarkus_frontend_url != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_hostname: "{{ keycloak_quarkus_frontend_url }}" + deprecated_variable: "keycloak_quarkus_frontend_url" # read in deprecation handler + notify: + - print deprecation warning + +# https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html-single/upgrading_guide/index#new_hostname_options +- name: Check deprecation of keycloak_quarkus_hostname_strict_https + keycloak_quarkus_host + keycloak_quarkus_port + keycloak_quarkus_path -> keycloak_quarkus_hostname + when: + - keycloak_quarkus_hostname is not defined + - keycloak_quarkus_hostname_strict_https is defined or keycloak_quarkus_frontend_url is defined or keycloak_quarkus_port is defined or keycloak_quarkus_path is defined + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_hostname: >- + {% set protocol = '' %} + {% if keycloak_quarkus_hostname_strict_https %} + {% set protocol = 'https://' %} + {% elif keycloak_quarkus_hostname_strict_https is defined and keycloak_quarkus_hostname_strict_https is False %} + {% set protocol = 'http://' %} + {% endif %} + {{ protocol }}{{ keycloak_quarkus_host }}:{{ keycloak_quarkus_port }}/{{ keycloak_quarkus_path }} + deprecated_variable: "keycloak_quarkus_hostname_strict_https or keycloak_quarkus_frontend_url or keycloak_quarkus_frontend_url or keycloak_quarkus_hostname" # read in deprecation handler + notify: + - print deprecation warning + +# https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html-single/upgrading_guide/index#new_hostname_options +- name: Check deprecation of keycloak_quarkus_admin_url -> keycloak_quarkus_hostname_admin + when: + - keycloak_quarkus_hostname_admin is not defined + - keycloak_quarkus_admin_url is defined + - keycloak_quarkus_admin_url != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_hostname_admin: "{{ keycloak_quarkus_admin_url }}" + deprecated_variable: "keycloak_quarkus_admin_url" # read in deprecation handler + notify: + - print deprecation warning + +# https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html-single/upgrading_guide/index#new_hostname_options +- name: Check deprecation of keycloak_quarkus_hostname_strict_backchannel -> keycloak_quarkus_hostname_backchannel_dynamic + when: + - keycloak_quarkus_hostname_backchannel_dynamic is not defined + - keycloak_quarkus_hostname_strict_backchannel is defined + - keycloak_quarkus_hostname_strict_backchannel != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_hostname_backchannel_dynamic: "{{ keycloak_quarkus_hostname_strict_backchannel == False }}" + deprecated_variable: "keycloak_quarkus_hostname_backchannel_dynamic" # read in deprecation handler + notify: + - print deprecation warning + +# https://github.com/keycloak/keycloak/issues/30009 +- name: Check deprecation of keycloak_quarkus_admin_user -> keycloak_quarkus_bootstrap_admin_user + when: + - keycloak_quarkus_bootstrap_admin_user is not defined + - keycloak_quarkus_admin_user is defined + - keycloak_quarkus_admin_user != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_bootstrap_admin_user: "{{ keycloak_quarkus_admin_user }}" + deprecated_variable: "keycloak_quarkus_admin_user" # read in deprecation handler + notify: + - print deprecation warning + +# https://github.com/keycloak/keycloak/issues/30009 +- name: Check deprecation of keycloak_quarkus_admin_pass -> keycloak_quarkus_bootstrap_admin_password + when: + - keycloak_quarkus_bootstrap_admin_password is not defined + - keycloak_quarkus_admin_pass is defined + - keycloak_quarkus_admin_pass != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_bootstrap_admin_user: "{{ keycloak_quarkus_admin_pass }}" + deprecated_variable: "keycloak_quarkus_admin_pass" # read in deprecation handler + notify: + - print deprecation warning + +- name: Check deprecation of keycloak_quarkus_bind_address -> keycloak_quarkus_http_host + when: + - keycloak_quarkus_bind_address is defined + - keycloak_quarkus_bind_address != '0.0.0.0' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_http_host: "{{ keycloak_quarkus_bind_address }}" + deprecated_variable: "keycloak_quarkus_bind_address" # read in deprecation handler + notify: + - print deprecation warning + - name: Flush handlers ansible.builtin.meta: flush_handlers diff --git a/roles/keycloak_quarkus/tasks/firewalld.yml b/roles/keycloak_quarkus/tasks/firewalld.yml index 2c3ef74..2d48124 100644 --- a/roles/keycloak_quarkus/tasks/firewalld.yml +++ b/roles/keycloak_quarkus/tasks/firewalld.yml @@ -12,7 +12,7 @@ enabled: true state: started -- name: "Configure firewall for {{ keycloak.service_name }} ports" +- name: "Configure firewall for {{ keycloak.service_name }} http port" become: true ansible.posix.firewalld: port: "{{ item }}" @@ -21,5 +21,16 @@ immediate: true loop: - "{{ keycloak_quarkus_http_port }}/tcp" + when: keycloak_quarkus_http_enabled | bool + +- name: "Configure firewall for {{ keycloak.service_name }} ports" + become: true + ansible.posix.firewalld: + port: "{{ item }}" + permanent: true + state: enabled + immediate: true + loop: - "{{ keycloak_quarkus_https_port }}/tcp" + - "{{ keycloak_quarkus_http_management_port }}/tcp" - "{{ keycloak_quarkus_jgroups_port }}/tcp" diff --git a/roles/keycloak_quarkus/tasks/install.yml b/roles/keycloak_quarkus/tasks/install.yml index 7745031..c664d05 100644 --- a/roles/keycloak_quarkus/tasks/install.yml +++ b/roles/keycloak_quarkus/tasks/install.yml @@ -8,6 +8,7 @@ - keycloak_quarkus_archive is defined - keycloak_quarkus_download_url is defined - keycloak_quarkus_version is defined + - local_path is defined quiet: true - name: Check for an existing deployment @@ -52,14 +53,6 @@ register: archive_path ## download to controller -- name: Check local download archive path - ansible.builtin.stat: - path: "{{ lookup('env', 'PWD') }}" - register: local_path - delegate_to: localhost - run_once: true - become: false - - name: Download keycloak archive ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user url: "{{ keycloak_quarkus_download_url }}" @@ -209,11 +202,11 @@ - keycloak_quarkus_cert_file_copy_enabled is defined and keycloak_quarkus_cert_file_copy_enabled - keycloak_quarkus_cert_file_src | length > 0 -- name: "Install {{ keycloak_quarkus_jdbc_engine }} JDBC driver" +- name: "Install {{ keycloak_quarkus_db_engine }} JDBC driver" ansible.builtin.include_tasks: jdbc_driver.yml when: - rhbk_enable is defined and rhbk_enable - - keycloak_quarkus_default_jdbc[keycloak_quarkus_jdbc_engine].driver_jar_url is defined + - keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].driver_jar_url is defined - name: "Download custom providers via http" ansible.builtin.get_url: @@ -225,7 +218,7 @@ become: true loop: "{{ keycloak_quarkus_providers }}" when: item.url is defined and item.url | length > 0 - notify: "{{ ['rebuild keycloak config', 'restart keycloak'] if not item.restart is defined or not item.restart else [] }}" + notify: "{{ ['invalidate keycloak theme cache', 'rebuild keycloak config', 'restart keycloak'] if not item.restart is defined or item.restart else [] }}" # this requires the `lxml` package to be installed; we redirect this step to localhost such that we do need to install it on the remote hosts - name: "Download custom providers to localhost using maven" @@ -242,9 +235,9 @@ loop: "{{ keycloak_quarkus_providers }}" when: item.maven is defined no_log: "{{ item.maven.password is defined and item.maven.password | length > 0 | default(false) }}" - notify: "{{ ['rebuild keycloak config', 'restart keycloak'] if not item.restart is defined or not item.restart else [] }}" + notify: "{{ ['invalidate keycloak theme cache', 'rebuild keycloak config', 'restart keycloak'] if not item.restart is defined or item.restart else [] }}" -- name: "Upload local maven providers" +- name: "Copy maven providers" ansible.builtin.copy: src: "{{ local_path.stat.path }}/{{ item.id }}.jar" dest: "{{ keycloak.home }}/providers/{{ item.id }}.jar" @@ -256,7 +249,7 @@ when: item.maven is defined no_log: "{{ item.maven.password is defined and item.maven.password | length > 0 | default(false) }}" -- name: "Upload local providers" +- name: "Copy local providers" ansible.builtin.copy: src: "{{ item.local_path }}" dest: "{{ keycloak.home }}/providers/{{ item.id }}.jar" @@ -266,6 +259,7 @@ become: true loop: "{{ keycloak_quarkus_providers }}" when: item.local_path is defined + notify: "{{ ['invalidate keycloak theme cache', 'rebuild keycloak config', 'restart keycloak'] if not item.restart is defined or item.restart else [] }}" - name: Ensure required folder structure for policies exists ansible.builtin.file: diff --git a/roles/keycloak_quarkus/tasks/invalidate_theme_cache.yml b/roles/keycloak_quarkus/tasks/invalidate_theme_cache.yml new file mode 100644 index 0000000..90ff67f --- /dev/null +++ b/roles/keycloak_quarkus/tasks/invalidate_theme_cache.yml @@ -0,0 +1,11 @@ +--- +# From https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/24.0/html/server_developer_guide/themes#creating_a_theme: +# If you want to manually delete the content of the themes cache, +# you can do so by deleting the data/tmp/kc-gzip-cache directory of the server distribution +# It can be useful for instance if you redeployed custom providers or custom themes without +# disabling themes caching in the previous server executions. +- name: "Delete {{ keycloak.service_name }} theme cache directory" + ansible.builtin.file: + path: "{{ keycloak.home }}/data/tmp/kc-gzip-cache" + state: absent + become: true diff --git a/roles/keycloak_quarkus/tasks/jdbc_driver.yml b/roles/keycloak_quarkus/tasks/jdbc_driver.yml index 880a915..ba3f4b8 100644 --- a/roles/keycloak_quarkus/tasks/jdbc_driver.yml +++ b/roles/keycloak_quarkus/tasks/jdbc_driver.yml @@ -7,9 +7,9 @@ (keycloak_quarkus_jdbc_download_user is undefined and keycloak_quarkus_jdbc_download_pass is not undefined) or (keycloak_quarkus_jdbc_download_pass is undefined and keycloak_quarkus_jdbc_download_user is not undefined) -- name: "Retrieve JDBC Driver from {{ keycloak_jdbc_download_url | default(keycloak_quarkus_default_jdbc[keycloak_quarkus_jdbc_engine].driver_jar_url) }}" +- name: "Retrieve JDBC Driver from {{ keycloak_jdbc_download_url | default(keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].driver_jar_url) }}" ansible.builtin.get_url: - url: "{{ keycloak_quarkus_jdbc_download_url | default(keycloak_quarkus_default_jdbc[keycloak_quarkus_jdbc_engine].driver_jar_url) }}" + url: "{{ keycloak_quarkus_jdbc_download_url | default(keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].driver_jar_url) }}" dest: "{{ keycloak.home }}/providers" owner: "{{ keycloak.service_user }}" group: "{{ keycloak.service_group }}" diff --git a/roles/keycloak_quarkus/tasks/main.yml b/roles/keycloak_quarkus/tasks/main.yml index 2dadf61..cca6764 100644 --- a/roles/keycloak_quarkus/tasks/main.yml +++ b/roles/keycloak_quarkus/tasks/main.yml @@ -91,7 +91,7 @@ register: keycloak_service_status changed_when: false -- name: "Trigger bootstrapped notification: remove `keycloak_quarkus_admin_user[_pass]` env vars" +- name: "Notify to remove `keycloak_quarkus_bootstrap_admin_user[_password]` env vars" when: - not ansible_local.keycloak.general.bootstrapped | default(false) | bool # it was not bootstrapped prior to the current role's execution - keycloak_service_status.status.ActiveState == "active" # but it is now diff --git a/roles/keycloak_quarkus/tasks/prereqs.yml b/roles/keycloak_quarkus/tasks/prereqs.yml index 503b308..9b633f3 100644 --- a/roles/keycloak_quarkus/tasks/prereqs.yml +++ b/roles/keycloak_quarkus/tasks/prereqs.yml @@ -2,12 +2,12 @@ - name: Validate admin console password ansible.builtin.assert: that: - - keycloak_quarkus_admin_pass | length > 12 + - keycloak_quarkus_bootstrap_admin_password | length > 12 quiet: true - fail_msg: "The console administrator password is empty or invalid. Please set the keycloak_quarkus_admin_pass to a 12+ char long string" + fail_msg: "The console administrator password is empty or invalid. Please set the keycloak_quarkus_bootstrap_admin_password to a 12+ char long string" success_msg: "{{ 'Console administrator password OK' }}" -- name: Validate relative path +- name: Validate http_relative_path ansible.builtin.assert: that: - keycloak_quarkus_http_relative_path is regex('^/.*') @@ -15,6 +15,15 @@ fail_msg: "The relative path for keycloak_quarkus_http_relative_path must begin with /" success_msg: "{{ 'Relative path OK' }}" +- name: Validate http_management_relative_path + ansible.builtin.assert: + that: + - keycloak_quarkus_http_management_relative_path is regex('^/.*') + quiet: true + fail_msg: "The relative path for keycloak_quarkus_http_management_relative_path must begin with /" + success_msg: "{{ 'Relative mgmt path OK' }}" + when: keycloak_quarkus_http_management_relative_path is defined + - name: Validate configuration ansible.builtin.assert: that: @@ -43,10 +52,50 @@ vars: packages_list: "{{ keycloak_quarkus_prereq_package_list }}" +- name: Check local download archive path + ansible.builtin.stat: + path: "{{ keycloak_quarkus_download_path }}" + register: local_path + delegate_to: localhost + run_once: true + become: false + +- name: Validate local download path + ansible.builtin.assert: + that: + - local_path.stat.exists + - local_path.stat.readable + - keycloak_quarkus_offline_install or local_path.stat.writeable + quiet: true + fail_msg: "Defined controller path for downloading resources is incorrect or unreadable: {{ keycloak_quarkus_download_path }}" + success_msg: "Will download resource to controller path: {{ keycloak_quarkus_download_path }}" + delegate_to: localhost + run_once: true + +- name: Check downloaded archive if offline + ansible.builtin.stat: + path: "{{ local_path.stat.path }}/{{ keycloak.bundle }}" + when: keycloak_quarkus_offline_install + register: local_archive_path_check + delegate_to: localhost + run_once: true + +- name: Validate local downloaded archive if offline + ansible.builtin.assert: + that: + - local_archive_path_check.stat.exists + - local_archive_path_check.stat.readable + quiet: true + fail_msg: "Configured for offline install but install archive not found at: {{ local_path.stat.path }}/{{ keycloak.bundle }}" + success_msg: "Will install offline with expected archive: {{ local_path.stat.path }}/{{ keycloak.bundle }}" + when: keycloak_quarkus_offline_install + delegate_to: localhost + run_once: true + - name: "Validate keytool" when: keycloak_quarkus_config_key_store_password | length > 0 block: - - name: "Attempt to run keytool" + - name: "Check run keytool" changed_when: false ansible.builtin.command: keytool -help register: keytool_check diff --git a/roles/keycloak_quarkus/tasks/rebuild_config.yml b/roles/keycloak_quarkus/tasks/rebuild_config.yml index 5d2247d..ac78504 100644 --- a/roles/keycloak_quarkus/tasks/rebuild_config.yml +++ b/roles/keycloak_quarkus/tasks/rebuild_config.yml @@ -1,7 +1,7 @@ --- # cf. https://www.keycloak.org/server/configuration#_optimize_the_keycloak_startup - name: "Rebuild {{ keycloak.service_name }} config" - ansible.builtin.shell: | + ansible.builtin.shell: | # noqa blocked_modules shell is necessary here {{ keycloak.home }}/bin/kc.sh build environment: PATH: "{{ keycloak_quarkus_java_home | default(keycloak_quarkus_pkg_java_home, true) }}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" diff --git a/roles/keycloak_quarkus/tasks/restart.yml b/roles/keycloak_quarkus/tasks/restart.yml index 2a1fabd..3aa97f6 100644 --- a/roles/keycloak_quarkus/tasks/restart.yml +++ b/roles/keycloak_quarkus/tasks/restart.yml @@ -12,11 +12,11 @@ url: "{{ keycloak.health_url }}" register: keycloak_status until: keycloak_status.status == 200 - retries: "{{ keycloak_quarkus_restart_health_check_reries }}" + retries: "{{ keycloak_quarkus_restart_health_check_retries }}" delay: "{{ keycloak_quarkus_restart_health_check_delay }}" when: internal_force_health_check | default(keycloak_quarkus_restart_health_check) -- name: Pause to give distributed ispn caches time to (re-)replicate back onto first host +- name: Wait to give distributed ispn caches time to (re-)replicate back onto first host ansible.builtin.pause: seconds: "{{ keycloak_quarkus_restart_pause }}" when: diff --git a/roles/keycloak_quarkus/tasks/start.yml b/roles/keycloak_quarkus/tasks/start.yml index a640e89..5a3ad5f 100644 --- a/roles/keycloak_quarkus/tasks/start.yml +++ b/roles/keycloak_quarkus/tasks/start.yml @@ -14,3 +14,4 @@ until: keycloak_status.status == 200 retries: 25 delay: 10 + when: internal_force_health_check | default(keycloak_quarkus_restart_health_check) diff --git a/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 b/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 index fb11cda..e546ab8 100644 --- a/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 +++ b/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 @@ -18,15 +18,15 @@ + xsi:schemaLocation="urn:infinispan:config:15.0 http://www.infinispan.org/schemas/infinispan-config-15.0.xsd" + xmlns="urn:infinispan:config:15.0"> {% set stack_expression='' %} {% if keycloak_quarkus_ha_enabled and keycloak_quarkus_ha_discovery == 'TCPPING' %} {% set stack_expression='stack="tcpping"' %} - + + + + + @@ -98,4 +102,4 @@ - \ No newline at end of file + diff --git a/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 b/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 index 2c260a3..244d9aa 100644 --- a/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 +++ b/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 @@ -1,7 +1,7 @@ {{ ansible_managed | comment }} {% if not ansible_local.keycloak.general.bootstrapped | default(false) | bool %} -KEYCLOAK_ADMIN={{ keycloak_quarkus_admin_user }} -KEYCLOAK_ADMIN_PASSWORD='{{ keycloak_quarkus_admin_pass }}' +KC_BOOTSTRAP_ADMIN_USERNAME={{ keycloak_quarkus_bootstrap_admin_user }} +KC_BOOTSTRAP_ADMIN_PASSWORD='{{ keycloak_quarkus_bootstrap_admin_password }}' {% else %} {{ keycloak.bootstrap_mnemonic }} {% endif %} diff --git a/roles/keycloak_quarkus/templates/keycloak.conf.j2 b/roles/keycloak_quarkus/templates/keycloak.conf.j2 index ab4024b..99790c3 100644 --- a/roles/keycloak_quarkus/templates/keycloak.conf.j2 +++ b/roles/keycloak_quarkus/templates/keycloak.conf.j2 @@ -2,26 +2,18 @@ {% if keycloak_quarkus_db_enabled %} # Database -db={{ keycloak_quarkus_jdbc_engine }} -db-url={{ keycloak_quarkus_jdbc_url }} +db={{ keycloak_quarkus_db_engine }} +db-url={{ keycloak_quarkus_db_url }} db-username={{ keycloak_quarkus_db_user }} {% if not keycloak.config_key_store_enabled %} db-password={{ keycloak_quarkus_db_pass }} {% endif %} {% endif %} -{% if keycloak_quarkus_hostname_strict_https is defined and keycloak_quarkus_hostname_strict_https is sameas true -%} -hostname-strict-https=true -{% endif -%} -{% if keycloak_quarkus_hostname_strict_https is defined and keycloak_quarkus_hostname_strict_https is sameas false -%} -hostname-strict-https=false -{% endif -%} - {% if keycloak.config_key_store_enabled %} # Config store config-keystore={{ keycloak_quarkus_config_key_store_file }} config-keystore-password={{ keycloak_quarkus_config_key_store_password }} -config-keystore-type=PKCS12 {% endif %} # Observability @@ -30,8 +22,17 @@ health-enabled={{ keycloak_quarkus_health_enabled | lower }} # HTTP http-enabled={{ keycloak_quarkus_http_enabled | lower }} +{% if keycloak_quarkus_http_enabled %} http-port={{ keycloak_quarkus_http_port }} +{% endif %} http-relative-path={{ keycloak_quarkus_http_relative_path }} +http-host={{ keycloak_quarkus_http_host }} + +# Management +http-management-port={{ keycloak_quarkus_http_management_port }} +{% if keycloak_quarkus_http_management_relative_path is defined and keycloak_quarkus_http_management_relative_path | length > 0 %} +http-management-relative-path={{ keycloak_quarkus_http_management_relative_path }} +{% endif %} # HTTPS https-port={{ keycloak_quarkus_https_port }} @@ -49,16 +50,10 @@ https-trust-store-password={{ keycloak_quarkus_https_trust_store_password }} {% endif %} # Client URL configuration -{% if keycloak_quarkus_frontend_url %} -hostname-url={{ keycloak_quarkus_frontend_url }} -{% else %} -hostname={{ keycloak_quarkus_host }} -hostname-port={{ keycloak_quarkus_port }} -hostname-path={{ keycloak_quarkus_path }} -{% endif %} -hostname-admin-url={{ keycloak_quarkus_admin_url }} +hostname={{ keycloak_quarkus_hostname }} +hostname-admin={{ keycloak_quarkus_hostname_admin }} hostname-strict={{ keycloak_quarkus_hostname_strict | lower }} -hostname-strict-backchannel={{ keycloak_quarkus_hostname_strict_backchannel | lower }} +hostname-backchannel-dynamic={{ keycloak_quarkus_hostname_backchannel_dynamic | lower }} # Cluster {% if keycloak_quarkus_ha_enabled %} diff --git a/roles/keycloak_quarkus/templates/keycloak.service.j2 b/roles/keycloak_quarkus/templates/keycloak.service.j2 index 9cabb69..96207ed 100644 --- a/roles/keycloak_quarkus/templates/keycloak.service.j2 +++ b/roles/keycloak_quarkus/templates/keycloak.service.j2 @@ -23,7 +23,7 @@ RestartSec={{ keycloak_quarkus_service_restartsec }} AmbientCapabilities=CAP_NET_BIND_SERVICE {% endif %} {% if keycloak_quarkus_systemd_wait_for_port %} -ExecStartPost=/usr/bin/timeout {{ keycloak_quarkus_systemd_wait_for_timeout }} sh -c 'while ! ss -H -t -l -n sport = :{{ keycloak_quarkus_https_port }} | grep -q "^LISTEN.*:{{ keycloak_quarkus_https_port }}"; do sleep 1; done && /bin/sleep {{ keycloak_quarkus_systemd_wait_for_delay }}' +ExecStartPost=/usr/bin/timeout {{ keycloak_quarkus_systemd_wait_for_timeout }} sh -c 'while ! ss -H -t -l -n sport = :{{ keycloak_quarkus_systemd_wait_for_port_number }} | grep -q "^LISTEN.*:{{ keycloak_quarkus_systemd_wait_for_port_number }}"; do sleep 1; done && /bin/sleep {{ keycloak_quarkus_systemd_wait_for_delay }}' {% endif %} {% if keycloak_quarkus_systemd_wait_for_log %} ExecStartPost=/usr/bin/timeout {{ keycloak_quarkus_systemd_wait_for_timeout }} sh -c 'cat {{ keycloak.log.file }} | sed "/Profile.*activated/ q" && /bin/sleep {{ keycloak_quarkus_systemd_wait_for_delay }}' diff --git a/roles/keycloak_quarkus/vars/main.yml b/roles/keycloak_quarkus/vars/main.yml index e59af55..997d7dc 100644 --- a/roles/keycloak_quarkus/vars/main.yml +++ b/roles/keycloak_quarkus/vars/main.yml @@ -4,8 +4,7 @@ keycloak: # noqa var-naming this is an internal dict of interpolated values config_dir: "{{ keycloak_quarkus_config_dir }}" bundle: "{{ keycloak_quarkus_archive }}" service_name: "keycloak" - health_url: "{{ 'https' if keycloak_quarkus_http_enabled == False else 'http' }}://{{ keycloak_quarkus_host }}:{{ keycloak_quarkus_https_port if keycloak_quarkus_http_enabled == False else keycloak_quarkus_http_port }}{{ keycloak_quarkus_http_relative_path }}{{ '/' \ - if keycloak_quarkus_http_relative_path | length > 1 else '' }}{{ keycloak_quarkus_health_check_url_path | default('realms/master/.well-known/openid-configuration') }}" + health_url: "{{ keycloak_quarkus_health_check_url | default(keycloak_quarkus_hostname ~ '/' ~ (keycloak_quarkus_health_check_url_path | default('realms/master/.well-known/openid-configuration'))) }}" cli_path: "{{ keycloak_quarkus_home }}/bin/kcadm.sh" service_user: "{{ keycloak_quarkus_service_user }}" service_group: "{{ keycloak_quarkus_service_group }}" diff --git a/roles/keycloak_quarkus/vars/redhat.yml b/roles/keycloak_quarkus/vars/redhat.yml index c311321..9af167f 100644 --- a/roles/keycloak_quarkus/vars/redhat.yml +++ b/roles/keycloak_quarkus/vars/redhat.yml @@ -1,5 +1,5 @@ --- -keycloak_quarkus_varjvm_package: "{{ keycloak_quarkus_jvm_package | default('java-17-openjdk-headless') }}" +keycloak_quarkus_varjvm_package: "{{ keycloak_quarkus_jvm_package | default('java-21-openjdk-headless') }}" keycloak_quarkus_prereq_package_list: - "{{ keycloak_quarkus_varjvm_package }}" - unzip diff --git a/roles/keycloak_realm/README.md b/roles/keycloak_realm/README.md index 73d823f..179784e 100644 --- a/roles/keycloak_realm/README.md +++ b/roles/keycloak_realm/README.md @@ -1,8 +1,9 @@ keycloak_realm ============== + Create realms and clients in [keycloak](https://keycloak.org/) or [Red Hat Single Sign-On](https://access.redhat.com/products/red-hat-single-sign-on) services. - + Role Defaults ------------- @@ -18,7 +19,7 @@ Role Defaults |`keycloak_management_http_port`| Management port | `9990` | |`keycloak_auth_client`| Authentication client for configuration REST calls | `admin-cli` | |`keycloak_client_public`| Configure a public realm client | `True` | -|`keycloak_client_web_origins`| Web origins for realm client | `+` | +|`keycloak_client_web_origins`| Web origins for realm client | `/*` | |`keycloak_url`| URL for configuration rest calls | `http://{{ keycloak_host }}:{{ keycloak_http_port }}` | |`keycloak_management_url`| URL for management console rest calls | `http://{{ keycloak_host }}:{{ keycloak_management_http_port }}` | @@ -136,4 +137,4 @@ Author Information ------------------ * [Guido Grazioli](https://github.com/guidograzioli) -* [Romain Pelisse](https://github.com/rpelisse) \ No newline at end of file +* [Romain Pelisse](https://github.com/rpelisse) diff --git a/roles/keycloak_realm/defaults/main.yml b/roles/keycloak_realm/defaults/main.yml index e488207..a294cbe 100644 --- a/roles/keycloak_realm/defaults/main.yml +++ b/roles/keycloak_realm/defaults/main.yml @@ -43,7 +43,7 @@ keycloak_client_default_roles: [] keycloak_client_public: true # allowed web origins for the client -keycloak_client_web_origins: '+' +keycloak_client_web_origins: '/*' # list of user and role mappings to create in the client # Each user has the form: @@ -54,3 +54,7 @@ keycloak_client_users: [] ### List of Keycloak User Federation keycloak_user_federation: [] + +# other settings +keycloak_url: "http://{{ keycloak_host }}:{{ keycloak_http_port + (keycloak_jboss_port_offset | default(0)) }}" +keycloak_management_url: "http://{{ keycloak_host }}:{{ keycloak_management_http_port + (keycloak_jboss_port_offset | default(0)) }}" diff --git a/roles/keycloak_realm/meta/argument_specs.yml b/roles/keycloak_realm/meta/argument_specs.yml index b124be2..7c24a7c 100644 --- a/roles/keycloak_realm/meta/argument_specs.yml +++ b/roles/keycloak_realm/meta/argument_specs.yml @@ -53,7 +53,7 @@ argument_specs: type: "bool" keycloak_client_web_origins: # line 42 of keycloak_realm/defaults/main.yml - default: "+" + default: "/*" description: "Web origins for realm client" type: "str" keycloak_client_users: diff --git a/roles/keycloak_realm/meta/main.yml b/roles/keycloak_realm/meta/main.yml index 915f62c..97c69d2 100644 --- a/roles/keycloak_realm/meta/main.yml +++ b/roles/keycloak_realm/meta/main.yml @@ -8,7 +8,7 @@ galaxy_info: license: Apache License 2.0 - min_ansible_version: "2.14" + min_ansible_version: "2.16" platforms: - name: EL diff --git a/roles/keycloak_realm/tasks/main.yml b/roles/keycloak_realm/tasks/main.yml index 016ab55..7595ba3 100644 --- a/roles/keycloak_realm/tasks/main.yml +++ b/roles/keycloak_realm/tasks/main.yml @@ -15,6 +15,7 @@ ansible.builtin.uri: url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ keycloak_realm }}" method: GET + validate_certs: false status_code: - 200 - 404 @@ -82,7 +83,7 @@ base_url: "{{ item.base_url | default('') }}" enabled: "{{ item.enabled | default(True) }}" redirect_uris: "{{ item.redirect_uris | default(omit) }}" - web_origins: "{{ item.web_origins | default('+') }}" + web_origins: "{{ item.web_origins | default(omit) }}" bearer_only: "{{ item.bearer_only | default(omit) }}" standard_flow_enabled: "{{ item.standard_flow_enabled | default(omit) }}" implicit_flow_enabled: "{{ item.implicit_flow_enabled | default(omit) }}" @@ -92,7 +93,7 @@ protocol: "{{ item.protocol | default(omit) }}" attributes: "{{ item.attributes | default(omit) }}" state: present - no_log: "{{ keycloak_no_log | default('True') }}" + no_log: "{{ keycloak_no_log | default('false') }}" register: create_client_result loop: "{{ keycloak_clients | flatten }}" when: (item.name is defined and item.client_id is defined) or (item.name is defined and item.id is defined) @@ -110,3 +111,6 @@ loop_control: loop_var: client when: "'users' in client" + +- name: Provide Access token lifespan + ansible.builtin.include_tasks: manage_token_lifespan.yml diff --git a/roles/keycloak_realm/tasks/manage_token_lifespan.yml b/roles/keycloak_realm/tasks/manage_token_lifespan.yml new file mode 100644 index 0000000..f16b938 --- /dev/null +++ b/roles/keycloak_realm/tasks/manage_token_lifespan.yml @@ -0,0 +1,14 @@ +--- +- name: "Update Access token lifespan" + ansible.builtin.uri: + url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ keycloak_realm }}" + method: PUT + body: + accessTokenLifespan: 300 + validate_certs: false + body_format: json + status_code: + - 200 + - 204 + headers: + Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" diff --git a/roles/keycloak_realm/tasks/manage_user_client_roles.yml b/roles/keycloak_realm/tasks/manage_user_client_roles.yml index 3884f42..f9e0329 100644 --- a/roles/keycloak_realm/tasks/manage_user_client_roles.yml +++ b/roles/keycloak_realm/tasks/manage_user_client_roles.yml @@ -3,6 +3,7 @@ ansible.builtin.uri: url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ client_role.realm | default(keycloak_realm) }}" method: GET + validate_certs: false status_code: - 200 headers: @@ -16,6 +17,7 @@ default(keycloak_realm) }}/users/{{ (keycloak_user.json | first).id }}/role-mappings/clients/{{ (create_client_result.results | \ selectattr('end_state.clientId', 'equalto', client_role.client) | list | first).end_state.id }}/available" method: GET + validate_certs: false status_code: - 200 headers: diff --git a/roles/keycloak_realm/vars/main.yml b/roles/keycloak_realm/vars/main.yml index 7664f8c..ad9bd8e 100644 --- a/roles/keycloak_realm/vars/main.yml +++ b/roles/keycloak_realm/vars/main.yml @@ -3,7 +3,3 @@ # name of the realm to create, this is a required variable keycloak_realm: - -# other settings -keycloak_url: "http://{{ keycloak_host }}:{{ keycloak_http_port + (keycloak_jboss_port_offset | default(0)) }}" -keycloak_management_url: "http://{{ keycloak_host }}:{{ keycloak_management_http_port + (keycloak_jboss_port_offset | default(0)) }}"