diff --git a/.ansible-lint b/.ansible-lint index f777db8..8e4b5ca 100644 --- a/.ansible-lint +++ b/.ansible-lint @@ -21,11 +21,23 @@ warn_list: - experimental - ignore-errors - no-handler - - fqcn-builtins - no-log-password + - jinja[spacing] + - jinja[invalid] + - meta-no-tags + - name[casing] + - fqcn[action] + - 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] use_default_rules: true parseable: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c96b1d..a622526 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,51 +1,28 @@ --- name: CI -"on": +on: push: branches: - main pull_request: + workflow_dispatch: + inputs: + debug_verbosity: + description: 'ANSIBLE_VERBOSITY envvar value' + required: false + schedule: + - cron: '15 6 * * *' jobs: ci: - runs-on: ubuntu-latest - strategy: - matrix: - python_version: ["3.9"] - steps: - - name: Check out code - uses: actions/checkout@v2 - with: - path: ansible_collections/middleware_automation/keycloak - - - name: Set up Python ${{ matrix.python_version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python_version }} - - - name: Install yamllint, ansible and molecule - run: | - python -m pip install --upgrade pip - pip install yamllint 'molecule[docker]~=3.5.2' ansible-core flake8 ansible-lint voluptuous - pip install -r ansible_collections/middleware_automation/keycloak/requirements.txt - - - name: Install ansible-lint custom rules - uses: actions/checkout@v2 - with: - repository: ansible-middleware/ansible-lint-custom-rules - path: ansible_collections/ansible-lint-custom-rules/ - - - name: Create default collection path - run: | - mkdir -p /home/runner/.ansible/collections/ansible_collections - - - name: Run sanity tests - run: ansible-test sanity --docker -v --color --python ${{ matrix.python_version }} --exclude changelogs/fragments/.gitignore - working-directory: ./ansible_collections/middleware_automation/keycloak - - - name: Run molecule test - run: molecule test --all - working-directory: ./ansible_collections/middleware_automation/keycloak - env: - PY_COLORS: '1' - ANSIBLE_FORCE_COLOR: '1' + uses: ansible-middleware/github-actions/.github/workflows/cish.yml@main + secrets: inherit + with: + fqcn: 'middleware_automation/keycloak' + debug_verbosity: "${{ github.event.inputs.debug_verbosity }}" + molecule_tests: >- + [ "debian", "quarkus", "quarkus_ha", "quarkus_ha_remote" ] + podman_tests_current: >- + [ "default", "quarkus_devmode", "quarkus_upgrade" ] + podman_tests_next: >- + [ "default", "quarkus_devmode", "quarkus_upgrade" ] diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7345971..540fe4f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,54 +8,11 @@ on: - "[0-9]+.[0-9]+.[0-9]+" workflow_dispatch: -env: - COLORTERM: 'yes' - TERM: 'xterm-256color' - PYTEST_ADDOPTS: '--color=yes' - jobs: docs: - runs-on: ubuntu-latest - if: github.repository == 'ansible-middleware/keycloak' - permissions: - actions: write - checks: write - contents: write - deployments: write - packages: write - pages: write - steps: - - name: Check out code - uses: actions/checkout@v2 - with: - path: ansible_collections/middleware_automation/keycloak - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.9 - - - name: Install doc dependencies - run: | - python -m pip install --upgrade pip - pip install -r ansible_collections/middleware_automation/keycloak/docs/requirements.txt - pip install -r ansible_collections/middleware_automation/keycloak/requirements.txt - sudo apt install -y sed hub - - - name: Create default collection path - run: | - mkdir -p /home/runner/.ansible/collections/ansible_collections - - - name: Create changelog and documentation - uses: ansible-middleware/collection-docs-action@main - with: - collection_fqcn: middleware_automation.keycloak - collection_repo: ansible-middleware/keycloak - dependencies: false - commit_changelog: false - commit_ghpages: true - changelog_release: false - generate_docs: true - path: ansible_collections/middleware_automation/keycloak - token: ${{ secrets.GITHUB_TOKEN }} + uses: ansible-middleware/github-actions/.github/workflows/docs.yml@main + secrets: inherit + with: + fqcn: 'middleware_automation/keycloak' + collection_fqcn: 'middleware_automation.keycloak' + historical_docs: 'false' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8cdffe..d0d14d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,96 +2,27 @@ name: Release collection on: workflow_dispatch: + inputs: + release_summary: + description: 'Optional release summary for changelogs' + required: false jobs: release: - runs-on: ubuntu-latest - if: github.repository == 'ansible-middleware/keycloak' - permissions: - actions: write - checks: write - contents: write - deployments: write - packages: write - pages: write - outputs: - tag_version: ${{ steps.get_version.outputs.TAG_VERSION }} - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 0 - token: ${{ secrets.TRIGGERING_PAT }} - - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: "3.x" - - - name: Get current version - id: get_version - run: echo "::set-output name=TAG_VERSION::$(grep version galaxy.yml | awk -F'"' '{ print $2 }')" - - - name: Check if tag exists - id: check_tag - run: echo "::set-output name=TAG_EXISTS::$(git tag | grep ${{ steps.get_version.outputs.TAG_VERSION }})" - - - name: Fail if tag exists - if: ${{ steps.get_version.outputs.TAG_VERSION == steps.check_tag.outputs.TAG_EXISTS }} - uses: actions/github-script@v3 - with: - script: | - core.setFailed('Release tag already exists') - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install ansible-core antsibull - sudo apt install -y sed hub - - - name: Build collection - run: | - ansible-galaxy collection build . - - - name: Create changelog and documentation - uses: ansible-middleware/collection-docs-action@main - with: - collection_fqcn: middleware_automation.keycloak - collection_repo: ansible-middleware/keycloak - dependencies: false - commit_changelog: true - commit_ghpages: false - changelog_release: true - generate_docs: false - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish collection - env: - ANSIBLE_GALAXY_API_KEY: ${{ secrets.ANSIBLE_GALAXY_API_KEY }} - run: | - ansible-galaxy collection publish *.tar.gz --api-key $ANSIBLE_GALAXY_API_KEY - - - name: Create release tag - run: | - git config user.name github-actions - git config user.email github-actions@github.com - git tag -a ${{ steps.get_version.outputs.TAG_VERSION }} -m "Release v${{ steps.get_version.outputs.TAG_VERSION }}" || true - git push origin --tags - - - name: Publish Release - uses: softprops/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.get_version.outputs.TAG_VERSION }} - files: "*.tar.gz" - body_path: gh-release.md + uses: ansible-middleware/github-actions/.github/workflows/release.yml@main + with: + collection_fqcn: 'middleware_automation.keycloak' + downstream_name: 'rhbk' + release_summary: "${{ github.event.inputs.release_summary }}" + secrets: + galaxy_token: ${{ secrets.ANSIBLE_GALAXY_API_KEY }} + jira_webhook: ${{ secrets.JIRA_WEBHOOK_CREATE_VERSION }} dispatch: needs: release strategy: matrix: - repo: ['ansible-middleware/cross-dc-rhsso-demo', 'ansible-middleware/flange-demo', 'ansible-middleware/ansible-middleware-ee'] + repo: ['ansible-middleware/ansible-middleware-ee'] runs-on: ubuntu-latest steps: - name: Repository Dispatch 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 9cc2eb2..ce41aef 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ *.zip .tmp .cache +.vscode/ +__pycache__/ docs/plugins/ docs/roles/ docs/_build/ @@ -10,3 +12,5 @@ docs/_build/ *.retry 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 f67ba17..b290328 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,11 +1,401 @@ -============================================ -middleware_automation.keycloak Release Notes -============================================ +============================================= +middleware\_automation.keycloak Release Notes +============================================= .. contents:: Topics This changelog describes changes after version 0.2.6. +v3.0.1 +====== + +Minor Changes +------------- + +- Version update to 26.0.8 / rhbk 26.0.11 `#277 `_ + +Bugfixes +-------- + +- Trigger rebuild handler on envvars file change `#276 `_ + +v3.0.0 +====== + +Minor Changes +------------- + +- Add theme cache invalidation handler `#252 `_ +- keycloak_realm: change url variables to defaults `#268 `_ + +Breaking Changes / Porting Guide +-------------------------------- + +- Bump major and ansible-core versions `#266 `_ +- Rename parameters to follow upstream `#270 `_ +- Update for keycloak v26 `#254 `_ + +Bugfixes +-------- + +- Access token lifespan is too short for ansible run `#251 `_ +- Load environment vars during kc rebuild `#274 `_ +- Rebuild config and restart service for local providers `#250 `_ +- Rename and honour parameter ``keycloak_quarkus_http_host`` `#271 `_ + +New Modules +----------- + +- middleware_automation.keycloak.keycloak_realm - Allows administration of Keycloak realm via Keycloak API + +v2.4.3 +====== + +Minor Changes +------------- + +- Update keycloak to 24.0.5 `#241 `_ + +v2.4.2 +====== + +Minor Changes +------------- + +- New parameter ``keycloak_quarkus_download_path`` `#239 `_ + +Bugfixes +-------- + +- Add wait_for_port number parameter `#237 `_ + +v2.4.1 +====== + +Release Summary +--------------- + +Internal release, documentation or test changes only. + +v2.4.0 +====== + +Major Changes +------------- + +- Enable by default health check on restart `#234 `_ +- Update minimum ansible-core version > 2.15 `#232 `_ + +v2.3.0 +====== + +Major Changes +------------- + +- Allow for custom providers hosted on maven repositories `#223 `_ +- Restart handler strategy behaviour `#231 `_ + +Minor Changes +------------- + +- Add support for policy files `#225 `_ +- Allow to add extra custom env vars in sysconfig file `#229 `_ +- Download from alternate URL with optional http authentication `#220 `_ +- Update Keycloak to version 24.0.4 `#218 `_ +- ``proxy-header`` enhancement `#227 `_ + +Bugfixes +-------- + +- ``kc.sh build`` uses configured jdk `#211 `_ + +v2.2.2 +====== + +Minor Changes +------------- + +- Copying of key material for TLS configuration `#210 `_ +- Validate certs parameter for JDBC driver downloads `#207 `_ + +Bugfixes +-------- + +- Turn off controller privilege escalation `#209 `_ + +v2.2.1 +====== + +Release Summary +--------------- + +Internal release, documentation or test changes only. + +Bugfixes +-------- + +- JDBC provider: fix clause in argument validation `#204 `_ + +v2.2.0 +====== + +Major Changes +------------- + +- Support java keystore for configuration of sensitive options `#189 `_ + +Minor Changes +------------- + +- Add ``wait_for_port`` and ``wait_for_log`` systemd unit logic `#199 `_ +- Customize jdbc driver downloads, optional authentication `#202 `_ +- Keystore-based vault SPI configuration `#196 `_ +- New ``keycloak_quarkus_hostname_strict_https`` parameter `#195 `_ +- Providers config and custom providers `#201 `_ +- Remove administrator credentials from files once keycloak is bootstrapped `#197 `_ +- Update keycloak to 24.0 `#194 `_ + +v2.1.2 +====== + +Release Summary +--------------- + +Internal release, documentation or test changes only. + +v2.1.1 +====== + +Minor Changes +------------- + +- Add reverse ``proxy_headers`` config, supersedes ``proxy_mode`` `#187 `_ +- Debian/Ubuntu compatibility `#178 `_ +- Use ``keycloak_realm`` as default for sub-entities `#180 `_ + +Bugfixes +-------- + +- Fix permissions on controller-side downloaded artifacts `#184 `_ +- JVM args moved to ``JAVA_OPTS`` envvar (instead of JAVA_OPTS_APPEND) `#186 `_ +- Unrelax configuration file permissions `#191 `_ +- Utilize comment filter for ``ansible_managed`` annotations `#176 `_ + +v2.1.0 +====== + +Major Changes +------------- + +- Implement infinispan TCPPING discovery protocol `#159 `_ + +Minor Changes +------------- + +- Set enable-recovery when xa transactions are enabled `#167 `_ +- keycloak_quarkus: Allow configuring log rotate options in quarkus configuration `#161 `_ +- keycloak_quarkus: ``sticky-session`` for infinispan routes `#163 `_ + +Breaking Changes / Porting Guide +-------------------------------- + +- keycloak_quarkus: renamed infinispan host list configuration `#157 `_ + +Bugfixes +-------- + +- keycloak_quarkus: fix custom JAVA_HOME parameter name `#171 `_ + +v2.0.2 +====== + +Minor Changes +------------- + +- keycloak_quarkus: Add support for sqlserver jdbc driver `#148 `_ +- keycloak_quarkus: allow configuration of ``hostname-strict-backchannel`` `#152 `_ +- keycloak_quarkus: systemd restart behavior `#145 `_ + +Bugfixes +-------- + +- keycloak_quarkus: Use ``keycloak_quarkus_java_opts`` `#154 `_ +- keycloak_quarkus: allow ports <1024 (e.g. :443) in systemd unit `#150 `_ + +v2.0.1 +====== + +Minor Changes +------------- + +- keycloak_quarkus: add hostname-strict parameter `#139 `_ +- keycloak_quarkus: update to version 23.0.1 `#133 `_ + +Bugfixes +-------- + +- keycloak_quarkus: template requires lowercase boolean values `#138 `_ + +v2.0.0 +====== + +Minor Changes +------------- + +- Add new parameter for port offset configuration `#124 `_ +- Update Keycloak to version 22.0.5 `#122 `_ + +Breaking Changes / Porting Guide +-------------------------------- + +- Add support for more http-related configs `#115 `_ +- Update minimum ansible-core version > 2.14 `#119 `_ +- keycloak_quarkus: enable config of key store and trust store `#116 `_ + +v1.3.0 +====== + +Major Changes +------------- + +- Run service as ``keycloak_service_user`` `#106 `_ + +Minor Changes +------------- + +- keycloak_quarkus: Update Keycloak to version 22.0.3 `#112 `_ +- keycloak_quarkus: fix admin console redirect when running locally `#111 `_ +- keycloak_quarkus: skip proxy config if ``keycloak_quarkus_proxy_mode`` is ``none`` `#109 `_ + +Bugfixes +-------- + +- keycloak_quarkus: fix validation failure upon port configuration change `#113 `_ + +v1.2.8 +====== + +Minor Changes +------------- + +- keycloak_quarkus: set openjdk 17 as default `#103 `_ +- keycloak_quarkus: update to version 22.0.1 `#107 `_ + +Bugfixes +-------- + +- Fix incorrect checks for ``keycloak_jgroups_subnet`` `#98 `_ +- Undefine ``keycloak_db_valid_conn_sql`` default `#91 `_ +- Update bindep.txt package python3-devel to support RHEL9 `#105 `_ + +v1.2.7 +====== + +Minor Changes +------------- + +- Allow to override jgroups subnet `#93 `_ +- keycloak-quarkus: update keycloakx to v21.1.1 `#92 `_ + +v1.2.6 +====== + +Minor Changes +------------- + +- Add profile features enabling/disabling `#87 `_ +- Improve service restart behavior configuration `#88 `_ +- Update default xa_datasource_class value for mariadb jdbc configuration `#89 `_ + +Bugfixes +-------- + +- Handle WFLYCTL0117 when background validation millis is 0 `#90 `_ + +v1.2.5 +====== + +Minor Changes +------------- + +- Add configuration for database connection pool validation `#85 `_ +- Allow to configure administration endpoint URL `#86 `_ +- Allow to force backend URLs to frontend URLs `#84 `_ +- Introduce systemd unit restart behavior `#81 `_ + +v1.2.4 +====== + +Minor Changes +------------- + +- Add ``sqlserver`` to keycloak role jdbc configurations `#78 `_ +- Add configurability for XA transactions `#73 `_ + +Bugfixes +-------- + +- Fix deprecation warning for ``ipaddr`` `#77 `_ +- Fix undefined facts when offline patching sso `#71 `_ + +v1.2.1 +====== + +Minor Changes +------------- + +- Allow to setup keycloak HA cluster without remote cache store `#68 `_ + +Bugfixes +-------- + +- Pass attributes to realm clients `#69 `_ + +v1.2.0 +====== + +Major Changes +------------- + +- Provide config for multiple modcluster proxies `#60 `_ + +Minor Changes +------------- + +- Allow to configure TCPPING for cluster discovery `#62 `_ +- Drop community.general from dependencies `#61 `_ +- Switch middleware_automation.redhat_csp_download for middleware_automation.common `#63 `_ +- Switch to middleware_automation.common for rh-sso patching `#64 `_ + +v1.1.1 +====== + +Bugfixes +-------- + +- keycloak-quarkus: fix ``cache-config-file`` path in keycloak.conf.j2 template `#53 `_ + +v1.1.0 +====== + +Minor Changes +------------- + +- Update keycloak to 18.0.2 - sso to 7.6.1 `#46 `_ +- Variable ``keycloak_no_log`` controls ansible ``no_log`` parameter (for debugging purposes) `#47 `_ +- Variables to override service start retries and delay `#51 `_ +- keycloak_quarkus: variable to enable development mode `#45 `_ + +Breaking Changes / Porting Guide +-------------------------------- + +- Rename variables from ``infinispan_`` prefix to ``keycloak_infinispan_`` `#42 `_ + +Bugfixes +-------- + +- keycloak_quarkus: fix /var/log/keycloak symlink to keycloak log directory `#44 `_ + v1.0.7 ====== @@ -39,6 +429,11 @@ Minor Changes v1.0.4 ====== +Release Summary +--------------- + +Internal release, documentation or test changes only. + v1.0.3 ====== @@ -79,7 +474,6 @@ Release Summary Minor enhancements, bug and documentation fixes. - Major Changes ------------- @@ -97,4 +491,3 @@ Release Summary --------------- This is the first stable release of the ``middleware_automation.keycloak`` collection. - 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 d341f58..9e9867d 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,18 @@ # Ansible Collection - middleware_automation.keycloak + [![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.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). - + + +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.9.10**. +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. @@ -16,12 +20,15 @@ Plugins and modules within a collection may be tested with only specific Ansible ## Installation + ### Installing the Collection from Ansible Galaxy Before using the collection, you need to install it with the Ansible Galaxy CLI: ansible-galaxy collection install middleware_automation.keycloak + + You can also include it in a `requirements.yml` file and install it via `ansible-galaxy collection install -r requirements.yml`, using the format: ```yaml @@ -33,93 +40,60 @@ 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: pip install -r requirements.txt - + ### Included roles -* [`keycloak`](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak/README.md): role for installing the service. -* [`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 ### Install Playbook - -* [`playbooks/keycloak.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak.yml) installs the upstream(Keycloak) based on the defined variables. -* [`playbooks/rhsso.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/rhsso.yml) installs Red Hat Single Sign-On(RHSSO) based on defined variables. + +* [`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). + +#### Install from controller node (offline) -### Choosing between upstream project (Keycloak) and Red Hat Single Sign-On (RHSSO) - -The general flag `keycloak_rhsso_enable` controls what to install between upstream (Keycloak, when `False`) or Red Hat Single Sign-On (when `True`). -The default value for the flag if `True` when Red Hat Network credentials are defined, `False` otherwise. - - -#### Install upstream (Keycloak) from keycloak releases - -This is the default approach when RHN credentials are not defined. Keycloak is downloaded from keycloak builds (hosted on github.com) locally, and distributed to target nodes. - - -#### Install RHSSO from the Red Hat Customer Support Portal - -Define the credentials as follows, and the default behaviour is to download a fresh archive of RHSSO on the controller node, then distribute to target nodes. +Making the keycloak zip archive available to the playbook working directory, and setting `keycloak_offline_install` to `true`, allows to skip +the download tasks. The local path for the archive does match the downloaded archive path, so that it is also used as a cache when multiple hosts are provisioned in a cluster. ```yaml -rhn_username: '' -rhn_password: '' -# (keycloak_rhsso_enable defaults to True) +keycloak_offline_install: true ``` -#### Install from controller node (local source) - -Making the keycloak zip archive (or the RHSSO zip archive), available to the playbook repository root directory, and setting `keycloak_offline_install` to `True`, allows to skip -the download tasks. The local path for the archive matches the downloaded archive path, so it is also used as a cache when multiple hosts are provisioned in a cluster. - -```yaml -keycloak_offline_install: True -``` - -And depending on `keycloak_rhsso_enable`: - -* `True`: install RHSSO using file rh-sso-x.y.z-server-dist.zip -* `False`: install keycloak using file keycloak-x.y.zip + + #### Install from alternate sources (like corporate Nexus, artifactory, proxy, etc) -For RHSSO: - -```yaml -keycloak_rhsso_enable: True -keycloak_rhsso_download_url: "https://///rh-sso-x.y.z-server-dist.zip" -``` - -For keycloak: - -```yaml -keycloak_rhsso_enable: False -keycloak_download_url: "https://///keycloak-x.y.zip" -``` +It is possible to perform downloads from alternate sources, using the `keycloak_download_url` variable; make sure the final downloaded filename matches with the source filename (ie. keycloak-legacy-x.y.zip or rh-sso-x.y.z-server-dist.zip). ### Example installation command -Execute the following command from the source root directory +Execute the following command from the source root directory ``` ansible-playbook -i -e @rhn-creds.yml playbooks/keycloak.yml -e keycloak_admin_password= -``` +``` - `keycloak_admin_password` Password for the administration console user account. - `ansible_hosts` is the inventory, below is an example inventory for deploying to localhost @@ -129,14 +103,16 @@ 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. + ## Configuration ### Config Playbook - + [`playbooks/keycloak_realm.yml`](https://github.com/ansible-middleware/keycloak/blob/main/playbooks/keycloak_realm.yml) creates or updates provided realm, user federation(s), client(s), client role(s) and client user(s). - + ### Example configuration command @@ -154,16 +130,17 @@ ansible-playbook -i playbooks/keycloak_realm.yml -e keycloak_adm [keycloak] localhost ansible_connection=local ``` - + For full configuration details, refer to the [keycloak_realm role README](https://github.com/ansible-middleware/keycloak/blob/main/roles/keycloak_realm/README.md). + -## Support + + -Keycloak collection v1.0.0 is a Beta release and for [Technical Preview](https://access.redhat.com/support/offerings/techpreview). If you have any issues or questions related to collection, please don't hesitate to contact us on Ansible-middleware-core@redhat.com or open an issue on https://github.com/ansible-middleware/keycloak/issues ## License Apache License v2.0 or later - + See [LICENSE](LICENSE) to view the full text. - + diff --git a/bindep.txt b/bindep.txt index 641ec85..0014f47 100644 --- a/bindep.txt +++ b/bindep.txt @@ -1,7 +1,9 @@ -python39-devel [platform:rpm compile] -git-lfs [platform:rpm] -python3-netaddr [platform:rpm] -python3-lxml [platform:rpm] -python3-jmespath [platform:rpm] -python3-requests [platform:rpm] +python3-dev [compile platform:dpkg] +python3-devel [compile platform:rpm] +python39-devel [compile platform:centos-8 platform:rhel-8] +git-lfs [platform:rpm platform:dpkg] +python3-netaddr [platform:rpm platform:dpkg] +python3-lxml [platform:rpm platform:dpkg] +python3-jmespath [platform:rpm platform:dpkg] +python3-requests [platform:rpm platform:dpkg] diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index bde3179..9b09e13 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -59,6 +59,10 @@ releases: - 31.yaml release_date: '2022-05-09' 1.0.4: + changes: + release_summary: 'Internal release, documentation or test changes only. + + ' release_date: '2022-05-11' 1.0.5: changes: @@ -96,3 +100,577 @@ releases: - 38.yaml - 39.yaml release_date: '2022-07-06' + 1.1.0: + changes: + breaking_changes: + - 'Rename variables from ``infinispan_`` prefix to ``keycloak_infinispan_`` + `#42 `_ + + ' + bugfixes: + - 'keycloak_quarkus: fix /var/log/keycloak symlink to keycloak log directory + `#44 `_ + + ' + minor_changes: + - 'Update keycloak to 18.0.2 - sso to 7.6.1 `#46 `_ + + ' + - 'Variable ``keycloak_no_log`` controls ansible ``no_log`` parameter (for debugging + purposes) `#47 `_ + + ' + - 'Variables to override service start retries and delay `#51 `_ + + ' + - 'keycloak_quarkus: variable to enable development mode `#45 `_ + + ' + fragments: + - 42.yaml + - 44.yaml + - 45.yaml + - 46.yaml + - 47.yaml + - 51.yaml + release_date: '2023-01-09' + 1.1.1: + changes: + bugfixes: + - 'keycloak-quarkus: fix ``cache-config-file`` path in keycloak.conf.j2 template + `#53 `_ + + ' + fragments: + - 53.yaml + release_date: '2023-03-07' + 1.2.0: + changes: + major_changes: + - 'Provide config for multiple modcluster proxies `#60 `_ + + ' + minor_changes: + - 'Allow to configure TCPPING for cluster discovery `#62 `_ + + ' + - 'Drop community.general from dependencies `#61 `_ + + ' + - 'Switch middleware_automation.redhat_csp_download for middleware_automation.common + `#63 `_ + + ' + - 'Switch to middleware_automation.common for rh-sso patching `#64 `_ + + ' + fragments: + - 60.yaml + - 61.yaml + - 62.yaml + - 63.yaml + - 64.yaml + release_date: '2023-03-16' + 1.2.1: + changes: + bugfixes: + - 'Pass attributes to realm clients `#69 `_ + + ' + minor_changes: + - 'Allow to setup keycloak HA cluster without remote cache store `#68 `_ + + ' + fragments: + - 68.yaml + - 69.yaml + release_date: '2023-04-11' + 1.2.4: + changes: + bugfixes: + - 'Fix deprecation warning for ``ipaddr`` `#77 `_ + + ' + - 'Fix undefined facts when offline patching sso `#71 `_ + + ' + minor_changes: + - 'Add ``sqlserver`` to keycloak role jdbc configurations `#78 `_ + + ' + - 'Add configurability for XA transactions `#73 `_ + + ' + fragments: + - 71.yaml + - 73.yaml + - 77.yaml + - 78.yaml + release_date: '2023-05-09' + 1.2.5: + changes: + minor_changes: + - 'Add configuration for database connection pool validation `#85 `_ + + ' + - 'Allow to configure administration endpoint URL `#86 `_ + + ' + - 'Allow to force backend URLs to frontend URLs `#84 `_ + + ' + - 'Introduce systemd unit restart behavior `#81 `_ + + ' + fragments: + - 81.yaml + - 84.yaml + - 85.yaml + - 86.yaml + release_date: '2023-05-26' + 1.2.6: + changes: + bugfixes: + - 'Handle WFLYCTL0117 when background validation millis is 0 `#90 `_ + + ' + minor_changes: + - 'Add profile features enabling/disabling `#87 `_ + + ' + - 'Improve service restart behavior configuration `#88 `_ + + ' + - 'Update default xa_datasource_class value for mariadb jdbc configuration `#89 + `_ + + ' + fragments: + - 87.yaml + - 88.yaml + - 89.yaml + - 90.yaml + release_date: '2023-06-07' + 1.2.7: + changes: + minor_changes: + - 'Allow to override jgroups subnet `#93 `_ + + ' + - 'keycloak-quarkus: update keycloakx to v21.1.1 `#92 `_ + + ' + fragments: + - 92.yaml + - 93.yaml + release_date: '2023-06-19' + 1.2.8: + changes: + bugfixes: + - 'Fix incorrect checks for ``keycloak_jgroups_subnet`` `#98 `_ + + ' + - 'Undefine ``keycloak_db_valid_conn_sql`` default `#91 `_ + + ' + - 'Update bindep.txt package python3-devel to support RHEL9 `#105 `_ + + ' + minor_changes: + - 'keycloak_quarkus: set openjdk 17 as default `#103 `_ + + ' + - 'keycloak_quarkus: update to version 22.0.1 `#107 `_ + + ' + fragments: + - 103.yaml + - 105.yaml + - 107.yaml + - 91.yaml + - 98.yaml + release_date: '2023-08-28' + 1.3.0: + changes: + bugfixes: + - 'keycloak_quarkus: fix validation failure upon port configuration change `#113 + `_ + + ' + major_changes: + - 'Run service as ``keycloak_service_user`` `#106 `_ + + ' + minor_changes: + - 'keycloak_quarkus: Update Keycloak to version 22.0.3 `#112 `_ + + ' + - 'keycloak_quarkus: fix admin console redirect when running locally `#111 `_ + + ' + - 'keycloak_quarkus: skip proxy config if ``keycloak_quarkus_proxy_mode`` is + ``none`` `#109 `_ + + ' + fragments: + - 106.yaml + - 109.yaml + - 111.yaml + - 112.yaml + - 113.yaml + release_date: '2023-09-25' + 2.0.0: + changes: + breaking_changes: + - 'Add support for more http-related configs `#115 `_ + + ' + - 'Update minimum ansible-core version > 2.14 `#119 `_ + + ' + - 'keycloak_quarkus: enable config of key store and trust store `#116 `_ + + ' + minor_changes: + - 'Add new parameter for port offset configuration `#124 `_ + + ' + - 'Update Keycloak to version 22.0.5 `#122 `_ + + ' + fragments: + - 115.yaml + - 116.yaml + - 119.yaml + - 122.yaml + - 124.yaml + release_date: '2023-11-20' + 2.0.1: + changes: + bugfixes: + - 'keycloak_quarkus: template requires lowercase boolean values `#138 `_ + + ' + minor_changes: + - 'keycloak_quarkus: add hostname-strict parameter `#139 `_ + + ' + - 'keycloak_quarkus: update to version 23.0.1 `#133 `_ + + ' + fragments: + - 133.yaml + - 138.yaml + - 139.yaml + release_date: '2023-12-07' + 2.0.2: + changes: + bugfixes: + - 'keycloak_quarkus: Use ``keycloak_quarkus_java_opts`` `#154 `_ + + ' + - 'keycloak_quarkus: allow ports <1024 (e.g. :443) in systemd unit `#150 `_ + + ' + minor_changes: + - 'keycloak_quarkus: Add support for sqlserver jdbc driver `#148 `_ + + ' + - 'keycloak_quarkus: allow configuration of ``hostname-strict-backchannel`` + `#152 `_ + + ' + - 'keycloak_quarkus: systemd restart behavior `#145 `_ + + ' + fragments: + - 145.yaml + - 148.yaml + - 150.yaml + - 152.yaml + - 154.yaml + release_date: '2024-01-17' + 2.1.0: + changes: + breaking_changes: + - 'keycloak_quarkus: renamed infinispan host list configuration `#157 `_ + + ' + bugfixes: + - 'keycloak_quarkus: fix custom JAVA_HOME parameter name `#171 `_ + + ' + major_changes: + - 'Implement infinispan TCPPING discovery protocol `#159 `_ + + ' + minor_changes: + - 'Set enable-recovery when xa transactions are enabled `#167 `_ + + ' + - 'keycloak_quarkus: Allow configuring log rotate options in quarkus configuration + `#161 `_ + + ' + - 'keycloak_quarkus: ``sticky-session`` for infinispan routes `#163 `_ + + ' + fragments: + - 157.yaml + - 159.yaml + - 161.yaml + - 163.yaml + - 167.yaml + - 171.yaml + release_date: '2024-02-28' + 2.1.1: + changes: + bugfixes: + - 'Fix permissions on controller-side downloaded artifacts `#184 `_ + + ' + - 'JVM args moved to ``JAVA_OPTS`` envvar (instead of JAVA_OPTS_APPEND) `#186 + `_ + + ' + - 'Unrelax configuration file permissions `#191 `_ + + ' + - 'Utilize comment filter for ``ansible_managed`` annotations `#176 `_ + + ' + minor_changes: + - 'Add reverse ``proxy_headers`` config, supersedes ``proxy_mode`` `#187 `_ + + ' + - 'Debian/Ubuntu compatibility `#178 `_ + + ' + - 'Use ``keycloak_realm`` as default for sub-entities `#180 `_ + + ' + fragments: + - 176.yaml + - 178.yaml + - 180.yaml + - 184.yaml + - 186.yaml + - 187.yaml + - 191.yaml + release_date: '2024-04-17' + 2.1.2: + changes: + release_summary: 'Internal release, documentation or test changes only. + + ' + release_date: '2024-04-17' + 2.2.0: + changes: + major_changes: + - 'Support java keystore for configuration of sensitive options `#189 `_ + + ' + minor_changes: + - 'Add ``wait_for_port`` and ``wait_for_log`` systemd unit logic `#199 `_ + + ' + - 'Customize jdbc driver downloads, optional authentication `#202 `_ + + ' + - 'Keystore-based vault SPI configuration `#196 `_ + + ' + - 'New ``keycloak_quarkus_hostname_strict_https`` parameter `#195 `_ + + ' + - 'Providers config and custom providers `#201 `_ + + ' + - 'Remove administrator credentials from files once keycloak is bootstrapped + `#197 `_ + + ' + - 'Update keycloak to 24.0 `#194 `_ + + ' + fragments: + - 189.yaml + - 194.yaml + - 195.yaml + - 196.yaml + - 197.yaml + - 199.yaml + - 201.yaml + - 202.yaml + release_date: '2024-05-01' + 2.2.1: + changes: + bugfixes: + - 'JDBC provider: fix clause in argument validation `#204 `_ + + ' + release_summary: Internal release, documentation or test changes only. + fragments: + - 204.yaml + - v2.2.1-devel_summary.yaml + release_date: '2024-05-02' + 2.2.2: + changes: + bugfixes: + - 'Turn off controller privilege escalation `#209 `_ + + ' + minor_changes: + - 'Copying of key material for TLS configuration `#210 `_ + + ' + - 'Validate certs parameter for JDBC driver downloads `#207 `_ + + ' + fragments: + - 207.yaml + - 209.yaml + - 210.yaml + release_date: '2024-05-06' + 2.3.0: + changes: + bugfixes: + - '``kc.sh build`` uses configured jdk `#211 `_ + + ' + major_changes: + - 'Allow for custom providers hosted on maven repositories `#223 `_ + + ' + - 'Restart handler strategy behaviour `#231 `_ + + ' + minor_changes: + - 'Add support for policy files `#225 `_ + + ' + - 'Allow to add extra custom env vars in sysconfig file `#229 `_ + + ' + - 'Download from alternate URL with optional http authentication `#220 `_ + + ' + - 'Update Keycloak to version 24.0.4 `#218 `_ + + ' + - '``proxy-header`` enhancement `#227 `_ + + ' + fragments: + - 211.yaml + - 218.yaml + - 220.yaml + - 223.yaml + - 225.yaml + - 227.yaml + - 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' + 3.0.0: + changes: + breaking_changes: + - 'Bump major and ansible-core versions `#266 `_ + + ' + - 'Rename parameters to follow upstream `#270 `_ + + ' + - 'Update for keycloak v26 `#254 `_ + + ' + bugfixes: + - 'Access token lifespan is too short for ansible run `#251 `_ + + ' + - 'Load environment vars during kc rebuild `#274 `_ + + ' + - 'Rebuild config and restart service for local providers `#250 `_ + + ' + - 'Rename and honour parameter ``keycloak_quarkus_http_host`` `#271 `_ + + ' + minor_changes: + - 'Add theme cache invalidation handler `#252 `_ + + ' + - 'keycloak_realm: change url variables to defaults `#268 `_ + + ' + fragments: + - 250.yaml + - 251.yaml + - 252.yaml + - 254.yaml + - 266.yaml + - 268.yaml + - 270.yaml + - 271.yaml + - 274.yaml + modules: + - description: Allows administration of Keycloak realm via Keycloak API + name: keycloak_realm + namespace: '' + release_date: '2025-04-23' + 3.0.1: + changes: + bugfixes: + - 'Trigger rebuild handler on envvars file change `#276 `_ + + ' + minor_changes: + - 'Version update to 26.0.8 / rhbk 26.0.11 `#277 `_ + + ' + fragments: + - 276.yaml + - 277.yaml + release_date: '2025-05-02' diff --git a/changelogs/config.yaml b/changelogs/config.yaml index 374ae65..3c7fb7e 100644 --- a/changelogs/config.yaml +++ b/changelogs/config.yaml @@ -11,22 +11,22 @@ notesdir: fragments prelude_section_name: release_summary prelude_section_title: Release Summary sections: -- - major_changes - - Major Changes -- - minor_changes - - Minor Changes -- - breaking_changes - - Breaking Changes / Porting Guide -- - deprecated_features - - Deprecated Features -- - removed_features - - Removed Features -- - security_fixes - - Security Fixes -- - bugfixes - - Bugfixes -- - known_issues - - Known Issues + - - major_changes + - Major Changes + - - minor_changes + - Minor Changes + - - breaking_changes + - Breaking Changes / Porting Guide + - - deprecated_features + - Deprecated Features + - - removed_features + - Removed Features + - - security_fixes + - Security Fixes + - - bugfixes + - Bugfixes + - - known_issues + - Known Issues title: middleware_automation.keycloak trivial_section_name: trivial use_fqcn: true 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/_gh_include/header.inc b/docs/_gh_include/header.inc index f9cd101..d97c7f1 100644 --- a/docs/_gh_include/header.inc +++ b/docs/_gh_include/header.inc @@ -21,6 +21,20 @@ +
diff --git a/docs/conf.py b/docs/conf.py index 31502f3..c1b24a9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,6 +43,7 @@ extensions = [ 'myst_parser', 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', + 'sphinx_antsibull_ext', 'ansible_basic_sphinx_ext', ] @@ -71,7 +72,7 @@ language = None exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '.tmp'] # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = 'ansible' highlight_language = 'YAML+Jinja' diff --git a/docs/index.rst b/docs/index.rst index d9bfa5f..6c46ab1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,23 +10,25 @@ Welcome to Keycloak Collection documentation README plugins/index roles/index + Changelog .. toctree:: :maxdepth: 2 :caption: Developer documentation - testing - developing - releasing + Developing + Testing + Releasing .. toctree:: :maxdepth: 2 - :caption: General + :caption: Middleware collections - Changelog - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`search` + Keycloak / Red Hat Single Sign-On + Infinispan / Red Hat Data Grid + Wildfly / Red Hat JBoss EAP + Tomcat / Red Hat JWS + ActiveMQ / Red Hat AMQ Broker + Kafka / Red Hat AMQ Streams + Ansible Middleware utilities + JCliff diff --git a/docs/requirements.txt b/docs/requirements.txt index 72a7d48..303f3a6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,8 @@ antsibull>=0.17.0 -ansible-base>=2.10.12 +antsibull-docs +antsibull-changelog +ansible-core>=2.16.0 +ansible-pygments sphinx-rtd-theme git+https://github.com/felixfontein/ansible-basic-sphinx-ext myst-parser 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 6a9e648..006207e 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -1,12 +1,13 @@ --- namespace: middleware_automation name: keycloak -version: "1.0.7" +version: "3.0.2" readme: README.md authors: - Romain Pelisse - Guido Grazioli - Pavan Kumar Motaparthi + - Helmut Wolf description: Install and configure a keycloak, or Red Hat Single Sign-on, service. license_file: "LICENSE" tags: @@ -20,16 +21,26 @@ tags: - security - infrastructure - authentication + - java + - runtimes + - middleware + - a4mw dependencies: - "middleware_automation.redhat_csp_download": ">=1.2.1" - "middleware_automation.wildfly": ">=1.0.0" + "middleware_automation.common": ">=1.2.1" + "ansible.posix": ">=1.4.0" repository: https://github.com/ansible-middleware/keycloak documentation: https://ansible-middleware.github.io/keycloak homepage: https://github.com/ansible-middleware/keycloak issues: https://github.com/ansible-middleware/keycloak/issues build_ignore: - - molecule + - .gitignore - .github + - .yamllint - '*.tar.gz' - '*.zip' - - changelogs/fragments/.gitignore + - molecule + - changelogs + - docs/_gh_include + - docs/conf.py + - docs/roles.rst.template + - docs/requirements.yml diff --git a/meta/runtime.yml b/meta/runtime.yml index 9baaad6..49c7554 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -1,2 +1,2 @@ --- -requires_ansible: ">=2.9.10" \ No newline at end of file +requires_ansible: ">=2.16.0" diff --git a/molecule/debian/converge.yml b/molecule/debian/converge.yml new file mode 100644 index 0000000..e853b38 --- /dev/null +++ b/molecule/debian/converge.yml @@ -0,0 +1,44 @@ +--- +- name: Converge + hosts: all + vars: + 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_start_dev: true + keycloak_quarkus_proxy_mode: none + 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_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/molecule.yml b/molecule/debian/molecule.yml new file mode 100644 index 0000000..8cccc25 --- /dev/null +++ b/molecule/debian/molecule.yml @@ -0,0 +1,48 @@ +--- +driver: + name: docker +platforms: + - name: instance + image: ghcr.io/hspaans/molecule-containers:debian-13 + pre_build_image: true + privileged: true + port_bindings: + - "8080/tcp" + - "8443/tcp" + - "8009/tcp" + cgroupns_mode: host + command: "/lib/systemd/systemd" + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw +provisioner: + name: ansible + config_options: + defaults: + interpreter_python: auto_silent + ssh_connection: + pipelining: false + playbooks: + prepare: prepare.yml + converge: converge.yml + verify: verify.yml + inventory: + host_vars: + localhost: + ansible_python_interpreter: /usr/bin/python3 + env: + ANSIBLE_FORCE_COLOR: "true" + ANSIBLE_REMOTE_TMP: /tmp/.ansible/tmp +verifier: + name: ansible +scenario: + test_sequence: + - cleanup + - destroy + - create + - prepare + - converge + - idempotence + - side_effect + - verify + - cleanup + - destroy diff --git a/molecule/debian/prepare.yml b/molecule/debian/prepare.yml new file mode 100644 index 0000000..7cab507 --- /dev/null +++ b/molecule/debian/prepare.yml @@ -0,0 +1,11 @@ +--- +- name: Prepare + hosts: all + gather_facts: yes + tasks: + - name: Install sudo + ansible.builtin.apt: + name: + - sudo + - openjdk-21-jdk-headless + - iproute2 diff --git a/molecule/debian/roles b/molecule/debian/roles new file mode 120000 index 0000000..b741aa3 --- /dev/null +++ b/molecule/debian/roles @@ -0,0 +1 @@ +../../roles \ No newline at end of file diff --git a/molecule/debian/verify.yml b/molecule/debian/verify.yml new file mode 100644 index 0000000..863b820 --- /dev/null +++ b/molecule/debian/verify.yml @@ -0,0 +1,40 @@ +--- +- name: Verify + hosts: all + vars: + 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 + tasks: + - name: Populate service facts + ansible.builtin.service_facts: + + - name: Check if keycloak service started + ansible.builtin.assert: + that: + - ansible_facts.services["keycloak.service"]["state"] == "running" + - ansible_facts.services["keycloak.service"]["status"] == "enabled" + + - name: Verify openid config + block: + - name: Fetch openID config # noqa blocked_modules command-instead-of-module + ansible.builtin.shell: | + set -o pipefail + curl http://localhost:8080/realms/master/.well-known/openid-configuration -k | jq . + args: + executable: /bin/bash + delegate_to: localhost + register: openid_config + changed_when: False + - name: Verify endpoint URLs + ansible.builtin.assert: + that: + - (openid_config.stdout | from_json)["backchannel_authentication_endpoint"] == 'http://localhost:8080/realms/master/protocol/openid-connect/ext/ciba/auth' + - (openid_config.stdout | from_json)['issuer'] == 'http://localhost:8080/realms/master' + - (openid_config.stdout | from_json)['authorization_endpoint'] == 'http://localhost:8080/realms/master/protocol/openid-connect/auth' + - (openid_config.stdout | from_json)['token_endpoint'] == 'http://localhost:8080/realms/master/protocol/openid-connect/token' + delegate_to: localhost + when: + - hera_home is defined + - hera_home | length == 0 diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 7e73d70..e617b59 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -1,41 +1,47 @@ --- - name: Converge hosts: all - vars: - keycloak_admin_password: "remembertochangeme" - keycloak_jvm_package: java-11-openjdk-headless + vars: + 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/ + keycloak_quarkus_java_heap_opts: "-Xms640m -Xmx640m " 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 + - 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 ea8ad61..587a3c8 100644 --- a/molecule/default/molecule.yml +++ b/molecule/default/molecule.yml @@ -1,22 +1,17 @@ --- -dependency: - name: shell - command: ansible-galaxy collection install -r molecule/default/requirements.yml -p $HOME/.ansible/collections --force-with-deps driver: - name: docker -lint: | - ansible-lint --version - ansible-lint -v + name: podman 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" - "8443/tcp" - - "8009/tcp" + - "8009/tcp" + - "9000/tcp" provisioner: name: ansible config_options: @@ -33,16 +28,15 @@ provisioner: localhost: ansible_python_interpreter: "{{ ansible_playbook_python }}" env: - ANSIBLE_FORCE_COLOR: "true" + ANSIBLE_FORCE_COLOR: "true" + PROXY: "${PROXY}" + NO_PROXY: "${NO_PROXY}" verifier: name: ansible scenario: test_sequence: - - dependency - - lint - cleanup - destroy - - syntax - create - prepare - converge diff --git a/molecule/default/prepare.yml b/molecule/default/prepare.yml index 40923c9..44d4a91 100644 --- a/molecule/default/prepare.yml +++ b/molecule/default/prepare.yml @@ -1,10 +1,25 @@ --- - name: Prepare hosts: all + gather_facts: yes + vars: + sudo_pkg_name: sudo tasks: - - name: Install sudo - ansible.builtin.yum: - name: - - sudo - - java-1.8.0-openjdk - state: present + - name: "Run preparation common to all scenario" + ansible.builtin.include_tasks: ../prepare.yml + + - 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: 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.2.4/keycloak-26.2.4.zip + dest: /tmp/keycloak + mode: '0640' + delegate_to: localhost + run_once: true diff --git a/molecule/default/requirements.yml b/molecule/default/requirements.yml deleted file mode 100644 index 9aa3437..0000000 --- a/molecule/default/requirements.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -collections: - - name: middleware_automation.redhat_csp_download - version: ">=1.2.1" - - name: middleware_automation.wildfly - version: ">=0.0.5" - - name: community.general - - name: community.docker - version: ">=1.9.1" - diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml index 07acf4d..ae21396 100644 --- a/molecule/default/verify.yml +++ b/molecule/default/verify.yml @@ -2,10 +2,9 @@ - name: Verify hosts: all vars: - keycloak_admin_password: "remembertochangeme" - keycloak_jvm_package: java-11-openjdk-headless - keycloak_port: http://localhost:8080 - keycloak_management_port: http://localhost:9990 + 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: @@ -14,16 +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 - shell: | - ps -ef | grep /usr/lib/jvm/java-11 | grep -v grep - name: Verify token api call ansible.builtin.uri: - url: "{{ keycloak_port }}/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 \ No newline at end of file + delay: 2 diff --git a/molecule/https_revproxy/converge.yml b/molecule/https_revproxy/converge.yml new file mode 100644 index 0000000..92994fa --- /dev/null +++ b/molecule/https_revproxy/converge.yml @@ -0,0 +1,16 @@ +--- +- name: Converge + hosts: all + vars: + keycloak_quarkus_show_deprecation_warnings: false + 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_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 new file mode 100644 index 0000000..7ad8db8 --- /dev/null +++ b/molecule/https_revproxy/molecule.yml @@ -0,0 +1,57 @@ +--- +driver: + name: docker +platforms: + - name: instance + image: registry.access.redhat.com/ubi9/ubi-init:latest + pre_build_image: true + privileged: true + command: "/usr/sbin/init" + networks: + - name: keycloak + port_bindings: + - "8080/tcp" + published_ports: + - 0.0.0.0:8080:8080/tcp + - name: proxy + image: registry.access.redhat.com/ubi9/ubi-init:latest + pre_build_image: true + privileged: true + command: "/usr/sbin/init" + networks: + - name: keycloak + port_bindings: + - "443/tcp" + published_ports: + - 0.0.0.0:443:443/tcp +provisioner: + name: ansible + config_options: + defaults: + interpreter_python: auto_silent + ssh_connection: + pipelining: false + playbooks: + prepare: prepare.yml + converge: converge.yml + verify: verify.yml + inventory: + host_vars: + localhost: + ansible_python_interpreter: "{{ ansible_playbook_python }}" + env: + ANSIBLE_FORCE_COLOR: "true" +verifier: + name: ansible +scenario: + test_sequence: + - cleanup + - destroy + - create + - prepare + - converge + - idempotence + - side_effect + - verify + - cleanup + - destroy diff --git a/molecule/https_revproxy/prepare.yml b/molecule/https_revproxy/prepare.yml new file mode 100644 index 0000000..44018be --- /dev/null +++ b/molecule/https_revproxy/prepare.yml @@ -0,0 +1,49 @@ +--- +- name: Prepare + hosts: all + tasks: + - name: Install sudo + ansible.builtin.dnf: + name: sudo + state: present + + - name: "Display hera_home if defined." + ansible.builtin.set_fact: + hera_home: "{{ lookup('env', 'HERA_HOME') }}" + +- name: Prepare proxy + hosts: proxy + vars: + nginx_proxy: | + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://instance:8080; + } + roles: + - elan.simple_nginx_reverse_proxy + pre_tasks: + - name: Create certificate request + ansible.builtin.command: openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes -subj '/CN=proxy' + delegate_to: localhost + changed_when: false + - name: Make certificate directory + ansible.builtin.file: + path: /etc/nginx/tls + state: directory + mode: 0755 + - name: Copy certificates + ansible.builtin.copy: + src: "{{ item.name }}" + dest: "{{ item.dest }}" + mode: 0444 + become: true + loop: + - { name: 'cert.pem', dest: '/etc/nginx/tls/certificate.crt' } + - { name: 'key.pem', dest: '/etc/nginx/tls/certificate.key' } + - name: Update CA trust + ansible.builtin.command: update-ca-trust + changed_when: false + become: true diff --git a/molecule/https_revproxy/roles b/molecule/https_revproxy/roles new file mode 120000 index 0000000..b741aa3 --- /dev/null +++ b/molecule/https_revproxy/roles @@ -0,0 +1 @@ +../../roles \ No newline at end of file diff --git a/molecule/https_revproxy/verify.yml b/molecule/https_revproxy/verify.yml new file mode 100644 index 0000000..4a69cb2 --- /dev/null +++ b/molecule/https_revproxy/verify.yml @@ -0,0 +1,28 @@ +--- +- name: Verify + hosts: instance + tasks: + - name: Populate service facts + ansible.builtin.service_facts: + + - name: Check if keycloak service started + ansible.builtin.assert: + that: + - ansible_facts.services["keycloak.service"]["state"] == "running" + - ansible_facts.services["keycloak.service"]["status"] == "enabled" + + - name: Verify openid config + block: + - name: Fetch openID config # noqa blocked_modules command-instead-of-module + ansible.builtin.uri: + url: http://localhost:8080/realms/master/.well-known/openid-configuration + validate_certs: false + headers: + Host: proxy + register: openid_config + changed_when: False + - name: Verify endpoint URLs + ansible.builtin.assert: + that: + - openid_config.json['issuer'] == 'https://proxy/realms/master' + - openid_config.json['authorization_endpoint'] == 'https://proxy/realms/master/protocol/openid-connect/auth' diff --git a/molecule/overridexml/converge.yml b/molecule/overridexml/converge.yml index 9304eba..7537684 100644 --- a/molecule/overridexml/converge.yml +++ b/molecule/overridexml/converge.yml @@ -1,43 +1,11 @@ --- - name: Converge hosts: all - vars: + vars: keycloak_admin_password: "remembertochangeme" keycloak_config_override_template: custom.xml.j2 keycloak_http_port: 8081 keycloak_management_http_port: 19990 + keycloak_service_runas: True 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 diff --git a/molecule/overridexml/molecule.yml b/molecule/overridexml/molecule.yml index ea8ad61..011c08e 100644 --- a/molecule/overridexml/molecule.yml +++ b/molecule/overridexml/molecule.yml @@ -1,22 +1,17 @@ --- -dependency: - name: shell - command: ansible-galaxy collection install -r molecule/default/requirements.yml -p $HOME/.ansible/collections --force-with-deps driver: name: docker -lint: | - ansible-lint --version - ansible-lint -v 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" - "8443/tcp" - - "8009/tcp" + - "8009/tcp" + - "9000/tcp" provisioner: name: ansible config_options: @@ -33,16 +28,13 @@ provisioner: localhost: ansible_python_interpreter: "{{ ansible_playbook_python }}" env: - ANSIBLE_FORCE_COLOR: "true" + ANSIBLE_FORCE_COLOR: "true" verifier: name: ansible scenario: test_sequence: - - dependency - - lint - cleanup - destroy - - syntax - create - prepare - converge diff --git a/molecule/overridexml/prepare.yml b/molecule/overridexml/prepare.yml index 03433c0..26245be 100644 --- a/molecule/overridexml/prepare.yml +++ b/molecule/overridexml/prepare.yml @@ -1,12 +1,12 @@ --- - name: Prepare hosts: all + gather_facts: yes + vars: + sudo_pkg_name: sudo tasks: - - name: Disable beta repos - ansible.builtin.command: yum config-manager --disable '*beta*' - ignore_errors: yes - - - name: Install sudo - ansible.builtin.yum: - name: sudo - state: present + - name: "Run preparation common to all scenario" + ansible.builtin.include_tasks: ../prepare.yml + vars: + assets: + - "{{ assets_server }}/sso/7.6.0/rh-sso-7.6.0-server-dist.zip" diff --git a/molecule/overridexml/requirements.yml b/molecule/overridexml/requirements.yml deleted file mode 100644 index 9aa3437..0000000 --- a/molecule/overridexml/requirements.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -collections: - - name: middleware_automation.redhat_csp_download - version: ">=1.2.1" - - name: middleware_automation.wildfly - version: ">=0.0.5" - - name: community.general - - name: community.docker - version: ">=1.9.1" - diff --git a/molecule/overridexml/templates/custom.xml.j2 b/molecule/overridexml/templates/custom.xml.j2 index a59561a..ec801d3 100644 --- a/molecule/overridexml/templates/custom.xml.j2 +++ b/molecule/overridexml/templates/custom.xml.j2 @@ -1,5 +1,5 @@ - + @@ -15,7 +15,6 @@ - @@ -30,31 +29,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - @@ -69,8 +43,8 @@ - - + + @@ -205,6 +179,9 @@ + + + @@ -278,6 +255,13 @@ + + + + + + + @@ -497,8 +481,8 @@ default - - + + @@ -513,41 +497,9 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -568,10 +520,11 @@ - + + - + @@ -581,20 +534,25 @@ + + + - + - + + + diff --git a/molecule/overridexml/verify.yml b/molecule/overridexml/verify.yml index ef973cd..b267fa1 100644 --- a/molecule/overridexml/verify.yml +++ b/molecule/overridexml/verify.yml @@ -1,6 +1,10 @@ --- - name: Verify hosts: all + vars: + keycloak_uri: "http://localhost:8081" + keycloak_management_port: "http://localhost:19990" + keycloak_admin_password: "remembertochangeme" tasks: - name: Populate service facts ansible.builtin.service_facts: @@ -9,3 +13,20 @@ 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_1.8.0/' | 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" + method: POST + body: "client_id=admin-cli&username=admin&password={{ keycloak_admin_password }}&grant_type=password" + validate_certs: no + register: keycloak_auth_response + until: keycloak_auth_response.status == 200 + retries: 2 + delay: 2 diff --git a/molecule/prepare.yml b/molecule/prepare.yml new file mode 100644 index 0000000..27486a3 --- /dev/null +++ b/molecule/prepare.yml @@ -0,0 +1,58 @@ +--- +- name: Display Ansible version + ansible.builtin.debug: + msg: "Ansible version is {{ ansible_version.full }}" + +- name: "Set package name for sudo" + ansible.builtin.set_fact: + sudo_pkg_name: sudo + +- name: "Ensure {{ sudo_pkg_name }} is installed (if user is root)." + ansible.builtin.yum: + name: "{{ sudo_pkg_name }}" + state: present + when: + - ansible_user_id == 'root' + +- name: Gather the package facts + ansible.builtin.package_facts: + manager: auto + +- name: "Check if sudo is installed." + ansible.builtin.assert: + that: + - sudo_pkg_name in ansible_facts.packages + fail_msg: "sudo is not installed on target system" + +- name: "Install iproute" + become: true + ansible.builtin.yum: + name: + - iproute + state: present + +- name: "Retrieve assets server from env" + ansible.builtin.set_fact: + assets_server: "{{ lookup('env', 'MIDDLEWARE_DOWNLOAD_RELEASE_SERVER_URL') }}" + +- name: "Download artefacts only if assets_server is set" + when: + - assets_server is defined + - assets_server | length > 0 + - assets is defined + - assets | length > 0 + block: + - name: "Set offline when assets server from env is defined" + ansible.builtin.set_fact: + sso_offline_install: True + + - name: "Download and deploy zips from {{ assets_server }}" + ansible.builtin.get_url: + url: "{{ asset }}" + dest: "{{ lookup('env', 'PWD') }}" + validate_certs: no + mode: '0644' + delegate_to: localhost + loop: "{{ assets }}" + loop_control: + loop_var: asset diff --git a/molecule/quarkus/converge.yml b/molecule/quarkus/converge.yml index cb84edb..fa2d70f 100644 --- a/molecule/quarkus/converge.yml +++ b/molecule/quarkus/converge.yml @@ -1,20 +1,69 @@ --- - name: Converge hosts: all - vars: - keycloak_quarkus_admin_pass: "remembertochangeme" - keycloak_admin_password: "remembertochangeme" + vars: + keycloak_quarkus_show_deprecation_warnings: false + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" keycloak_realm: TestRealm - keycloak_quarkus_host: instance:8443 - keycloak_quarkus_http_relative_path: '' + keycloak_quarkus_hostname: https://instance:8443 keycloak_quarkus_log: file - keycloak_quarkus_https_enabled: True - keycloak_quarkus_key_file: "{{ keycloak.home }}/conf/key.pem" - keycloak_quarkus_cert_file: "{{ keycloak.home }}/conf/cert.pem" + keycloak_quarkus_log_level: debug # needed for the verify step + keycloak_quarkus_https_key_file_enabled: true + keycloak_quarkus_key_file_copy_enabled: true + keycloak_quarkus_key_content: "{{ lookup('file', 'key.pem') }}" + keycloak_quarkus_cert_file_copy_enabled: true + keycloak_quarkus_cert_file_src: cert.pem + keycloak_quarkus_log_target: /tmp/keycloak + keycloak_quarkus_ks_vault_enabled: true + keycloak_quarkus_ks_vault_file: "/opt/keycloak/vault/keystore.p12" + keycloak_quarkus_ks_vault_pass: keystorepassword + keycloak_quarkus_systemd_wait_for_port: true + keycloak_quarkus_systemd_wait_for_timeout: 20 + keycloak_quarkus_systemd_wait_for_delay: 2 + keycloak_quarkus_systemd_wait_for_log: true + keycloak_quarkus_restart_health_check: false # would fail because of self-signed cert + keycloak_quarkus_version: 26.2.4 + keycloak_quarkus_java_heap_opts: "-Xms1024m -Xmx1024m" + keycloak_quarkus_additional_env_vars: + - key: KC_FEATURES_DISABLED + value: impersonation,kerberos + keycloak_quarkus_providers: + - id: http-client + spi: connections + default: true + restart: true + properties: + - key: default-connection-pool-size + value: 10 + - id: spid-saml + url: https://github.com/italia/spid-keycloak-provider/releases/download/24.0.2/spid-provider.jar + - id: spid-saml-w-checksum + url: https://github.com/italia/spid-keycloak-provider/releases/download/24.0.2/spid-provider.jar + checksum: sha256:fbb50e73739d7a6d35b5bff611b1c01668b29adf6f6259624b95e466a305f377 + - id: keycloak-kerberos-federation + maven: + 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: 26.2.4 # optional + # username: myUser # optional + # password: myPAT # optional + # - id: my-static-theme + # local_path: /tmp/my-static-theme.jar + keycloak_quarkus_policies: + - name: "cain-and-abel.txt" + url: "https://github.com/danielmiessler/SecLists/raw/master/Passwords/Software/cain-and-abel.txt" + - name: "john-the-ripper.txt" + url: "https://github.com/danielmiessler/SecLists/raw/master/Passwords/Software/john-the-ripper.txt" + type: password-blacklists 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 785de19..20ca3bc 100644 --- a/molecule/quarkus/molecule.yml +++ b/molecule/quarkus/molecule.yml @@ -1,15 +1,9 @@ --- -dependency: - name: shell - command: ansible-galaxy collection install -r molecule/default/requirements.yml -p $HOME/.ansible/collections --force-with-deps driver: name: docker -lint: | - ansible-lint --version - ansible-lint -v 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" @@ -17,6 +11,7 @@ platforms: - "8080/tcp" - "8443/tcp" - "8009/tcp" + - "9000/tcp" published_ports: - 0.0.0.0:8443:8443/tcp provisioner: @@ -35,16 +30,16 @@ provisioner: localhost: ansible_python_interpreter: "{{ ansible_playbook_python }}" env: - ANSIBLE_FORCE_COLOR: "true" + ANSIBLE_FORCE_COLOR: "true" + PYTHONHTTPSVERIFY: 0 + PROXY: "${PROXY}" + NO_PROXY: "${NO_PROXY}" verifier: name: ansible scenario: test_sequence: - - dependency - - lint - cleanup - destroy - - syntax - create - prepare - converge diff --git a/molecule/quarkus/prepare.yml b/molecule/quarkus/prepare.yml index 7d95f2f..abe2518 100644 --- a/molecule/quarkus/prepare.yml +++ b/molecule/quarkus/prepare.yml @@ -2,25 +2,43 @@ - name: Prepare hosts: all tasks: - - name: Install sudo - ansible.builtin.yum: - name: sudo - state: present - - command: openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes -subj '/CN=instance' + - name: "Display hera_home if defined." + ansible.builtin.set_fact: + hera_home: "{{ lookup('env', 'HERA_HOME') }}" + + - name: "Ensure common prepare phase are set." + ansible.builtin.include_tasks: ../prepare.yml + + - name: Create certificate request + ansible.builtin.command: openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes -subj '/CN=instance' delegate_to: localhost - - lineinfile: - dest: /etc/hosts - line: "127.0.0.1 instance" - state: present - delegate_to: localhost - become: yes - - file: + changed_when: false + + - name: Create vault directory + become: true + ansible.builtin.file: state: directory - path: /opt/keycloak/keycloak-18.0.0/conf/ - - copy: - src: "{{ item }}" - dest: "/opt/keycloak/keycloak-18.0.0/conf/{{ item }}" - mode: 0444 - loop: - - cert.pem - - key.pem + path: "/opt/keycloak/vault" + mode: '0755' + + - name: Make sure a jre is available (for keytool to prepare keystore) + delegate_to: localhost + ansible.builtin.package: + name: java-21-openjdk-headless + state: present + become: true + failed_when: false + + - name: Create vault keystore + ansible.builtin.command: keytool -importpass -alias TestRealm_testalias -keystore keystore.p12 -storepass keystorepassword + delegate_to: localhost + register: keytool_cmd + changed_when: False + failed_when: not 'already exists' in keytool_cmd.stdout and keytool_cmd.rc != 0 + + - name: Copy certificates and vault + become: true + ansible.builtin.copy: + src: keystore.p12 + dest: /opt/keycloak/vault/keystore.p12 + mode: '0444' diff --git a/molecule/quarkus/requirements.yml b/molecule/quarkus/requirements.yml deleted file mode 100644 index 9aa3437..0000000 --- a/molecule/quarkus/requirements.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -collections: - - name: middleware_automation.redhat_csp_download - version: ">=1.2.1" - - name: middleware_automation.wildfly - version: ">=0.0.5" - - name: community.general - - name: community.docker - version: ">=1.9.1" - diff --git a/molecule/quarkus/verify.yml b/molecule/quarkus/verify.yml index 9b469a4..1d9d2c3 100644 --- a/molecule/quarkus/verify.yml +++ b/molecule/quarkus/verify.yml @@ -1,27 +1,128 @@ --- - 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: + - name: Check if keycloak service started ansible.builtin.assert: that: - ansible_facts.services["keycloak.service"]["state"] == "running" - ansible_facts.services["keycloak.service"]["status"] == "enabled" - - name: Fetch openID config - shell: | - curl https://instance:8443/realms/master/.well-known/openid-configuration -k | jq . - delegate_to: localhost - register: openid_config - - debug: - msg: " {{ openid_config.stdout | from_json }}" - delegate_to: localhost - - name: Verify endpoint URLs - assert: + fail_msg: "Service not running" + + - name: Set internal envvar + ansible.builtin.set_fact: + hera_home: "{{ lookup('env', 'HERA_HOME') }}" + + - name: Verify openid config + when: + - hera_home is defined + - hera_home | length == 0 + block: + - name: Fetch openID config # noqa blocked_modules command-instead-of-module + ansible.builtin.shell: | + set -o pipefail + curl -H 'Host: instance' https://localhost:8443/realms/master/.well-known/openid-configuration -k | jq . + args: + executable: /bin/bash + delegate_to: localhost + register: openid_config + changed_when: False + - name: Verify endpoint URLs + ansible.builtin.assert: + that: + - (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 + 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: - - (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 + - keycloak_log_folder.stat.exists + - not keycloak_log_folder.stat.isdir + - keycloak_log_folder.stat.islnk + fail_msg: "Service log symlink not correctly created" + + - name: Check log file + become: true + ansible.builtin.stat: + path: /tmp/keycloak/keycloak.log + register: keycloak_log_file + + - name: Check if keycloak file exists + ansible.builtin.assert: + that: + - keycloak_log_file.stat.exists + - not keycloak_log_file.stat.isdir + + - name: Check default log folder + become: yes + 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 + + - name: Verify vault SPI in logfile + become: true + ansible.builtin.shell: | + set -o pipefail + zgrep 'Configured KeystoreVaultProviderFactory with the keystore file' /opt/keycloak/keycloak-*/data/log/keycloak.log*zip + 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_devmode/converge.yml b/molecule/quarkus_devmode/converge.yml new file mode 100644 index 0000000..a596478 --- /dev/null +++ b/molecule/quarkus_devmode/converge.yml @@ -0,0 +1,50 @@ +--- +- name: Converge + hosts: all + vars: + keycloak_quarkus_show_deprecation_warnings: false + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" + keycloak_realm: TestRealm + keycloak_quarkus_log: file + keycloak_quarkus_hostname: 'http://localhost:8080' + keycloak_quarkus_start_dev: True + keycloak_quarkus_proxy_mode: none + keycloak_quarkus_java_home: /opt/openjdk/ + keycloak_quarkus_java_heap_opts: "-Xms640m -Xmx640m" + + 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 + 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 diff --git a/molecule/quarkus_devmode/molecule.yml b/molecule/quarkus_devmode/molecule.yml new file mode 100644 index 0000000..0ae28b3 --- /dev/null +++ b/molecule/quarkus_devmode/molecule.yml @@ -0,0 +1,49 @@ +--- +driver: + name: podman +platforms: + - name: instance + image: registry.access.redhat.com/ubi9/ubi-init:latest + pre_build_image: true + privileged: true + command: "/usr/sbin/init" + port_bindings: + - "8080/tcp" + - "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: + defaults: + interpreter_python: auto_silent + ssh_connection: + pipelining: false + playbooks: + prepare: prepare.yml + converge: converge.yml + verify: verify.yml + inventory: + host_vars: + localhost: + ansible_python_interpreter: "{{ ansible_playbook_python }}" + env: + ANSIBLE_FORCE_COLOR: "true" + PROXY: "${PROXY}" + NO_PROXY: "${NO_PROXY}" +verifier: + name: ansible +scenario: + test_sequence: + - cleanup + - destroy + - create + - prepare + - converge + - idempotence + - side_effect + - verify + - cleanup + - destroy diff --git a/molecule/quarkus_devmode/prepare.yml b/molecule/quarkus_devmode/prepare.yml new file mode 100644 index 0000000..9ce721e --- /dev/null +++ b/molecule/quarkus_devmode/prepare.yml @@ -0,0 +1,49 @@ +--- +- name: Prepare + hosts: all + tasks: + - name: Install sudo + ansible.builtin.apt: + name: + - sudo + - openjdk-17-jdk-headless + state: present + when: + - ansible_facts.os_family == 'Debian' + + - name: "Ensure common prepare phase are set." + ansible.builtin.include_tasks: ../prepare.yml + + - name: Install JDK17 + become: yes + ansible.builtin.yum: + name: + - java-17-openjdk-headless + state: present + when: + - ansible_facts.os_family == 'RedHat' + + - name: Link default logs directory + become: yes + ansible.builtin.file: + state: link + src: "{{ item }}" + dest: /opt/openjdk + force: true + with_fileglob: + - /usr/lib/jvm/java-17-openjdk* + when: + - ansible_facts.os_family == "Debian" + + - name: Link default logs directory + ansible.builtin.file: + state: link + src: /usr/lib/jvm/jre-17-openjdk + dest: /opt/openjdk + force: true + when: + - ansible_facts.os_family == "RedHat" + + - name: "Display hera_home if defined." + ansible.builtin.set_fact: + hera_home: "{{ lookup('env', 'HERA_HOME') }}" diff --git a/molecule/quarkus_devmode/roles b/molecule/quarkus_devmode/roles new file mode 120000 index 0000000..b741aa3 --- /dev/null +++ b/molecule/quarkus_devmode/roles @@ -0,0 +1 @@ +../../roles \ No newline at end of file diff --git a/molecule/quarkus_devmode/verify.yml b/molecule/quarkus_devmode/verify.yml new file mode 100644 index 0000000..b808ece --- /dev/null +++ b/molecule/quarkus_devmode/verify.yml @@ -0,0 +1,47 @@ +--- +- name: Verify + hosts: all + tasks: + - name: Populate service facts + ansible.builtin.service_facts: + + - name: Check if keycloak service started + ansible.builtin.assert: + that: + - ansible_facts.services["keycloak.service"]["state"] == "running" + - ansible_facts.services["keycloak.service"]["status"] == "enabled" + + - name: Verify we are running on requested JAVA_HOME # noqa blocked_modules command-instead-of-module + ansible.builtin.shell: | + set -o pipefail + ps -ef | grep '/opt/openjdk' | grep -v grep + args: + executable: /bin/bash + changed_when: False + + - name: Set internal envvar + ansible.builtin.set_fact: + hera_home: "{{ lookup('env', 'HERA_HOME') }}" + + - name: Verify openid config + block: + - name: Fetch openID config # noqa blocked_modules command-instead-of-module + ansible.builtin.shell: | + set -o pipefail + curl http://localhost:8080/realms/master/.well-known/openid-configuration -k | jq . + args: + executable: /bin/bash + delegate_to: localhost + register: openid_config + changed_when: False + - name: Verify endpoint URLs + ansible.builtin.assert: + that: + - (openid_config.stdout | from_json)["backchannel_authentication_endpoint"] == 'http://localhost:8080/realms/master/protocol/openid-connect/ext/ciba/auth' + - (openid_config.stdout | from_json)['issuer'] == 'http://localhost:8080/realms/master' + - (openid_config.stdout | from_json)['authorization_endpoint'] == 'http://localhost:8080/realms/master/protocol/openid-connect/auth' + - (openid_config.stdout | from_json)['token_endpoint'] == 'http://localhost:8080/realms/master/protocol/openid-connect/token' + delegate_to: localhost + when: + - hera_home is defined + - hera_home | length == 0 diff --git a/molecule/quarkus_ha/converge.yml b/molecule/quarkus_ha/converge.yml new file mode 100644 index 0000000..fa5314f --- /dev/null +++ b/molecule/quarkus_ha/converge.yml @@ -0,0 +1,29 @@ +--- +- name: Converge + hosts: keycloak + vars: + keycloak_quarkus_show_deprecation_warnings: false + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" + keycloak_quarkus_hostname: "http://{{ inventory_hostname }}:8080" + keycloak_quarkus_log: file + keycloak_quarkus_log_level: info + keycloak_quarkus_https_key_file_enabled: true + keycloak_quarkus_key_file_copy_enabled: true + keycloak_quarkus_key_content: "{{ lookup('file', inventory_hostname + '.key') }}" + keycloak_quarkus_cert_file_copy_enabled: true + keycloak_quarkus_cert_file_src: "{{ inventory_hostname }}.pem" + keycloak_quarkus_ks_vault_enabled: true + keycloak_quarkus_ks_vault_file: "/opt/keycloak/vault/keystore.p12" + keycloak_quarkus_ks_vault_pass: keystorepassword + keycloak_quarkus_systemd_wait_for_port: true + keycloak_quarkus_systemd_wait_for_timeout: 20 + keycloak_quarkus_systemd_wait_for_delay: 2 + keycloak_quarkus_systemd_wait_for_log: true + keycloak_quarkus_ha_enabled: true + keycloak_quarkus_restart_strategy: restart/serial.yml + keycloak_quarkus_db_user: keycloak + keycloak_quarkus_db_pass: mysecretpass + keycloak_quarkus_db_url: jdbc:postgresql://postgres:5432/keycloak + roles: + - role: keycloak_quarkus diff --git a/molecule/quarkus_ha/molecule.yml b/molecule/quarkus_ha/molecule.yml new file mode 100644 index 0000000..ed09971 --- /dev/null +++ b/molecule/quarkus_ha/molecule.yml @@ -0,0 +1,82 @@ +--- +driver: + name: docker +platforms: + - name: instance1 + image: registry.access.redhat.com/ubi9/ubi-init:latest + pre_build_image: true + privileged: true + command: "/usr/sbin/init" + groups: + - keycloak + networks: + - name: rhbk + port_bindings: + - "8080/tcp" + - "8443/tcp" + - "9000/tcp" + - name: instance2 + image: registry.access.redhat.com/ubi9/ubi-init:latest + pre_build_image: true + privileged: true + command: "/usr/sbin/init" + groups: + - keycloak + networks: + - name: rhbk + port_bindings: + - "8080/tcp" + - "8443/tcp" + - "9000/tcp" + - name: postgres + image: ubuntu/postgres:14-22.04_beta + pre_build_image: true + privileged: true + command: postgres + groups: + - database + networks: + - name: rhbk + port_bindings: + - "5432/tcp" + mounts: + - type: bind + target: /etc/postgresql/postgresql.conf + source: ${PWD}/molecule/quarkus_ha/postgresql/postgresql.conf + env: + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: mysecretpass + POSTGRES_DB: keycloak + POSTGRES_HOST_AUTH_METHOD: trust +provisioner: + name: ansible + config_options: + defaults: + interpreter_python: auto_silent + ssh_connection: + pipelining: false + playbooks: + prepare: prepare.yml + converge: converge.yml + verify: verify.yml + inventory: + host_vars: + localhost: + ansible_python_interpreter: "{{ ansible_playbook_python }}" + env: + ANSIBLE_FORCE_COLOR: "true" + PYTHONHTTPSVERIFY: 0 +verifier: + name: ansible +scenario: + test_sequence: + - cleanup + - destroy + - create + - prepare + - converge + - idempotence + - side_effect + - verify + - cleanup + - destroy diff --git a/molecule/quarkus_ha/postgresql/postgresql.conf b/molecule/quarkus_ha/postgresql/postgresql.conf new file mode 100644 index 0000000..d702576 --- /dev/null +++ b/molecule/quarkus_ha/postgresql/postgresql.conf @@ -0,0 +1,750 @@ +# ----------------------------- +# PostgreSQL configuration file +# ----------------------------- +# +# This file consists of lines of the form: +# +# name = value +# +# (The "=" is optional.) Whitespace may be used. Comments are introduced with +# "#" anywhere on a line. The complete list of parameter names and allowed +# values can be found in the PostgreSQL documentation. +# +# The commented-out settings shown in this file represent the default values. +# Re-commenting a setting is NOT sufficient to revert it to the default value; +# you need to reload the server. +# +# This file is read on server startup and when the server receives a SIGHUP +# signal. If you edit the file on a running system, you have to SIGHUP the +# server for the changes to take effect, run "pg_ctl reload", or execute +# "SELECT pg_reload_conf()". Some parameters, which are marked below, +# require a server shutdown and restart to take effect. +# +# Any parameter can also be given as a command-line option to the server, e.g., +# "postgres -c log_connections=on". Some parameters can be changed at run time +# with the "SET" SQL command. +# +# Memory units: kB = kilobytes Time units: ms = milliseconds +# MB = megabytes s = seconds +# GB = gigabytes min = minutes +# TB = terabytes h = hours +# d = days + + +#------------------------------------------------------------------------------ +# FILE LOCATIONS +#------------------------------------------------------------------------------ + +# The default values of these variables are driven from the -D command-line +# option or PGDATA environment variable, represented here as ConfigDir. + +#data_directory = 'ConfigDir' # use data in another directory + # (change requires restart) +#hba_file = 'ConfigDir/pg_hba.conf' # host-based authentication file + # (change requires restart) +#ident_file = 'ConfigDir/pg_ident.conf' # ident configuration file + # (change requires restart) + +# If external_pid_file is not explicitly set, no extra PID file is written. +#external_pid_file = '' # write an extra PID file + # (change requires restart) + + +#------------------------------------------------------------------------------ +# CONNECTIONS AND AUTHENTICATION +#------------------------------------------------------------------------------ + +# - Connection Settings - + +listen_addresses = '*' # what IP address(es) to listen on; + # comma-separated list of addresses; + # defaults to 'localhost'; use '*' for all + # (change requires restart) +#port = 5432 # (change requires restart) +#max_connections = 100 # (change requires restart) +#superuser_reserved_connections = 3 # (change requires restart) +#unix_socket_directories = '/tmp' # comma-separated list of directories + # (change requires restart) +#unix_socket_group = '' # (change requires restart) +#unix_socket_permissions = 0777 # begin with 0 to use octal notation + # (change requires restart) +#bonjour = off # advertise server via Bonjour + # (change requires restart) +#bonjour_name = '' # defaults to the computer name + # (change requires restart) + +# - TCP settings - +# see "man 7 tcp" for details + +#tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds; + # 0 selects the system default +#tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds; + # 0 selects the system default +#tcp_keepalives_count = 0 # TCP_KEEPCNT; + # 0 selects the system default +#tcp_user_timeout = 0 # TCP_USER_TIMEOUT, in milliseconds; + # 0 selects the system default + +# - Authentication - + +#authentication_timeout = 1min # 1s-600s +#password_encryption = md5 # md5 or scram-sha-256 +#db_user_namespace = off + +# GSSAPI using Kerberos +#krb_server_keyfile = '' +#krb_caseins_users = off + +# - SSL - + +#ssl = off +#ssl_ca_file = '' +#ssl_cert_file = 'server.crt' +#ssl_crl_file = '' +#ssl_key_file = 'server.key' +#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers +#ssl_prefer_server_ciphers = on +#ssl_ecdh_curve = 'prime256v1' +#ssl_min_protocol_version = 'TLSv1' +#ssl_max_protocol_version = '' +#ssl_dh_params_file = '' +#ssl_passphrase_command = '' +#ssl_passphrase_command_supports_reload = off + + +#------------------------------------------------------------------------------ +# RESOURCE USAGE (except WAL) +#------------------------------------------------------------------------------ + +# - Memory - + +#shared_buffers = 32MB # min 128kB + # (change requires restart) +#huge_pages = try # on, off, or try + # (change requires restart) +#temp_buffers = 8MB # min 800kB +#max_prepared_transactions = 0 # zero disables the feature + # (change requires restart) +# Caution: it is not advisable to set max_prepared_transactions nonzero unless +# you actively intend to use prepared transactions. +#work_mem = 4MB # min 64kB +#maintenance_work_mem = 64MB # min 1MB +#autovacuum_work_mem = -1 # min 1MB, or -1 to use maintenance_work_mem +#max_stack_depth = 2MB # min 100kB +#shared_memory_type = mmap # the default is the first option + # supported by the operating system: + # mmap + # sysv + # windows + # (change requires restart) +#dynamic_shared_memory_type = posix # the default is the first option + # supported by the operating system: + # posix + # sysv + # windows + # mmap + # (change requires restart) + +# - Disk - + +#temp_file_limit = -1 # limits per-process temp file space + # in kB, or -1 for no limit + +# - Kernel Resources - + +#max_files_per_process = 1000 # min 25 + # (change requires restart) + +# - Cost-Based Vacuum Delay - + +#vacuum_cost_delay = 0 # 0-100 milliseconds (0 disables) +#vacuum_cost_page_hit = 1 # 0-10000 credits +#vacuum_cost_page_miss = 10 # 0-10000 credits +#vacuum_cost_page_dirty = 20 # 0-10000 credits +#vacuum_cost_limit = 200 # 1-10000 credits + +# - Background Writer - + +#bgwriter_delay = 200ms # 10-10000ms between rounds +#bgwriter_lru_maxpages = 100 # max buffers written/round, 0 disables +#bgwriter_lru_multiplier = 2.0 # 0-10.0 multiplier on buffers scanned/round +#bgwriter_flush_after = 0 # measured in pages, 0 disables + +# - Asynchronous Behavior - + +#effective_io_concurrency = 1 # 1-1000; 0 disables prefetching +#max_worker_processes = 8 # (change requires restart) +#max_parallel_maintenance_workers = 2 # taken from max_parallel_workers +#max_parallel_workers_per_gather = 2 # taken from max_parallel_workers +#parallel_leader_participation = on +#max_parallel_workers = 8 # maximum number of max_worker_processes that + # can be used in parallel operations +#old_snapshot_threshold = -1 # 1min-60d; -1 disables; 0 is immediate + # (change requires restart) +#backend_flush_after = 0 # measured in pages, 0 disables + + +#------------------------------------------------------------------------------ +# WRITE-AHEAD LOG +#------------------------------------------------------------------------------ + +# - Settings - + +#wal_level = replica # minimal, replica, or logical + # (change requires restart) +#fsync = on # flush data to disk for crash safety + # (turning this off can cause + # unrecoverable data corruption) +#synchronous_commit = on # synchronization level; + # off, local, remote_write, remote_apply, or on +#wal_sync_method = fsync # the default is the first option + # supported by the operating system: + # open_datasync + # fdatasync (default on Linux) + # fsync + # fsync_writethrough + # open_sync +#full_page_writes = on # recover from partial page writes +#wal_compression = off # enable compression of full-page writes +#wal_log_hints = off # also do full page writes of non-critical updates + # (change requires restart) +#wal_init_zero = on # zero-fill new WAL files +#wal_recycle = on # recycle WAL files +#wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers + # (change requires restart) +#wal_writer_delay = 200ms # 1-10000 milliseconds +#wal_writer_flush_after = 1MB # measured in pages, 0 disables + +#commit_delay = 0 # range 0-100000, in microseconds +#commit_siblings = 5 # range 1-1000 + +# - Checkpoints - + +#checkpoint_timeout = 5min # range 30s-1d +#max_wal_size = 1GB +#min_wal_size = 80MB +#checkpoint_completion_target = 0.5 # checkpoint target duration, 0.0 - 1.0 +#checkpoint_flush_after = 0 # measured in pages, 0 disables +#checkpoint_warning = 30s # 0 disables + +# - Archiving - + +#archive_mode = off # enables archiving; off, on, or always + # (change requires restart) +#archive_command = '' # command to use to archive a logfile segment + # placeholders: %p = path of file to archive + # %f = file name only + # e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f' +#archive_timeout = 0 # force a logfile segment switch after this + # number of seconds; 0 disables + +# - Archive Recovery - + +# These are only used in recovery mode. + +#restore_command = '' # command to use to restore an archived logfile segment + # placeholders: %p = path of file to restore + # %f = file name only + # e.g. 'cp /mnt/server/archivedir/%f %p' + # (change requires restart) +#archive_cleanup_command = '' # command to execute at every restartpoint +#recovery_end_command = '' # command to execute at completion of recovery + +# - Recovery Target - + +# Set these only when performing a targeted recovery. + +#recovery_target = '' # 'immediate' to end recovery as soon as a + # consistent state is reached + # (change requires restart) +#recovery_target_name = '' # the named restore point to which recovery will proceed + # (change requires restart) +#recovery_target_time = '' # the time stamp up to which recovery will proceed + # (change requires restart) +#recovery_target_xid = '' # the transaction ID up to which recovery will proceed + # (change requires restart) +#recovery_target_lsn = '' # the WAL LSN up to which recovery will proceed + # (change requires restart) +#recovery_target_inclusive = on # Specifies whether to stop: + # just after the specified recovery target (on) + # just before the recovery target (off) + # (change requires restart) +#recovery_target_timeline = 'latest' # 'current', 'latest', or timeline ID + # (change requires restart) +#recovery_target_action = 'pause' # 'pause', 'promote', 'shutdown' + # (change requires restart) + + +#------------------------------------------------------------------------------ +# REPLICATION +#------------------------------------------------------------------------------ + +# - Sending Servers - + +# Set these on the master and on any standby that will send replication data. + +#max_wal_senders = 10 # max number of walsender processes + # (change requires restart) +#wal_keep_segments = 0 # in logfile segments; 0 disables +#wal_sender_timeout = 60s # in milliseconds; 0 disables + +#max_replication_slots = 10 # max number of replication slots + # (change requires restart) +#track_commit_timestamp = off # collect timestamp of transaction commit + # (change requires restart) + +# - Master Server - + +# These settings are ignored on a standby server. + +#synchronous_standby_names = '' # standby servers that provide sync rep + # method to choose sync standbys, number of sync standbys, + # and comma-separated list of application_name + # from standby(s); '*' = all +#vacuum_defer_cleanup_age = 0 # number of xacts by which cleanup is delayed + +# - Standby Servers - + +# These settings are ignored on a master server. + +#primary_conninfo = '' # connection string to sending server + # (change requires restart) +#primary_slot_name = '' # replication slot on sending server + # (change requires restart) +#promote_trigger_file = '' # file name whose presence ends recovery +#hot_standby = on # "off" disallows queries during recovery + # (change requires restart) +#max_standby_archive_delay = 30s # max delay before canceling queries + # when reading WAL from archive; + # -1 allows indefinite delay +#max_standby_streaming_delay = 30s # max delay before canceling queries + # when reading streaming WAL; + # -1 allows indefinite delay +#wal_receiver_status_interval = 10s # send replies at least this often + # 0 disables +#hot_standby_feedback = off # send info from standby to prevent + # query conflicts +#wal_receiver_timeout = 60s # time that receiver waits for + # communication from master + # in milliseconds; 0 disables +#wal_retrieve_retry_interval = 5s # time to wait before retrying to + # retrieve WAL after a failed attempt +#recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery + +# - Subscribers - + +# These settings are ignored on a publisher. + +#max_logical_replication_workers = 4 # taken from max_worker_processes + # (change requires restart) +#max_sync_workers_per_subscription = 2 # taken from max_logical_replication_workers + + +#------------------------------------------------------------------------------ +# QUERY TUNING +#------------------------------------------------------------------------------ + +# - Planner Method Configuration - + +#enable_bitmapscan = on +#enable_hashagg = on +#enable_hashjoin = on +#enable_indexscan = on +#enable_indexonlyscan = on +#enable_material = on +#enable_mergejoin = on +#enable_nestloop = on +#enable_parallel_append = on +#enable_seqscan = on +#enable_sort = on +#enable_tidscan = on +#enable_partitionwise_join = off +#enable_partitionwise_aggregate = off +#enable_parallel_hash = on +#enable_partition_pruning = on + +# - Planner Cost Constants - + +#seq_page_cost = 1.0 # measured on an arbitrary scale +#random_page_cost = 4.0 # same scale as above +#cpu_tuple_cost = 0.01 # same scale as above +#cpu_index_tuple_cost = 0.005 # same scale as above +#cpu_operator_cost = 0.0025 # same scale as above +#parallel_tuple_cost = 0.1 # same scale as above +#parallel_setup_cost = 1000.0 # same scale as above + +#jit_above_cost = 100000 # perform JIT compilation if available + # and query more expensive than this; + # -1 disables +#jit_inline_above_cost = 500000 # inline small functions if query is + # more expensive than this; -1 disables +#jit_optimize_above_cost = 500000 # use expensive JIT optimizations if + # query is more expensive than this; + # -1 disables + +#min_parallel_table_scan_size = 8MB +#min_parallel_index_scan_size = 512kB +#effective_cache_size = 4GB + +# - Genetic Query Optimizer - + +#geqo = on +#geqo_threshold = 12 +#geqo_effort = 5 # range 1-10 +#geqo_pool_size = 0 # selects default based on effort +#geqo_generations = 0 # selects default based on effort +#geqo_selection_bias = 2.0 # range 1.5-2.0 +#geqo_seed = 0.0 # range 0.0-1.0 + +# - Other Planner Options - + +#default_statistics_target = 100 # range 1-10000 +#constraint_exclusion = partition # on, off, or partition +#cursor_tuple_fraction = 0.1 # range 0.0-1.0 +#from_collapse_limit = 8 +#join_collapse_limit = 8 # 1 disables collapsing of explicit + # JOIN clauses +#force_parallel_mode = off +#jit = on # allow JIT compilation +#plan_cache_mode = auto # auto, force_generic_plan or + # force_custom_plan + + +#------------------------------------------------------------------------------ +# REPORTING AND LOGGING +#------------------------------------------------------------------------------ + +# - Where to Log - + +#log_destination = 'stderr' # Valid values are combinations of + # stderr, csvlog, syslog, and eventlog, + # depending on platform. csvlog + # requires logging_collector to be on. + +# This is used when logging to stderr: +#logging_collector = off # Enable capturing of stderr and csvlog + # into log files. Required to be on for + # csvlogs. + # (change requires restart) + +# These are only used if logging_collector is on: +#log_directory = 'log' # directory where log files are written, + # can be absolute or relative to PGDATA +#log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern, + # can include strftime() escapes +#log_file_mode = 0600 # creation mode for log files, + # begin with 0 to use octal notation +#log_truncate_on_rotation = off # If on, an existing log file with the + # same name as the new log file will be + # truncated rather than appended to. + # But such truncation only occurs on + # time-driven rotation, not on restarts + # or size-driven rotation. Default is + # off, meaning append to existing files + # in all cases. +#log_rotation_age = 1d # Automatic rotation of logfiles will + # happen after that time. 0 disables. +#log_rotation_size = 10MB # Automatic rotation of logfiles will + # happen after that much log output. + # 0 disables. + +# These are relevant when logging to syslog: +#syslog_facility = 'LOCAL0' +#syslog_ident = 'postgres' +#syslog_sequence_numbers = on +#syslog_split_messages = on + +# This is only relevant when logging to eventlog (win32): +# (change requires restart) +#event_source = 'PostgreSQL' + +# - When to Log - + +#log_min_messages = warning # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # info + # notice + # warning + # error + # log + # fatal + # panic + +#log_min_error_statement = error # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # info + # notice + # warning + # error + # log + # fatal + # panic (effectively off) + +#log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements + # and their durations, > 0 logs only + # statements running at least this number + # of milliseconds + +#log_transaction_sample_rate = 0.0 # Fraction of transactions whose statements + # are logged regardless of their duration. 1.0 logs all + # statements from all transactions, 0.0 never logs. + +# - What to Log - + +#debug_print_parse = off +#debug_print_rewritten = off +#debug_print_plan = off +#debug_pretty_print = on +#log_checkpoints = off +#log_connections = off +#log_disconnections = off +#log_duration = off +#log_error_verbosity = default # terse, default, or verbose messages +#log_hostname = off +#log_line_prefix = '%m [%p] ' # special values: + # %a = application name + # %u = user name + # %d = database name + # %r = remote host and port + # %h = remote host + # %p = process ID + # %t = timestamp without milliseconds + # %m = timestamp with milliseconds + # %n = timestamp with milliseconds (as a Unix epoch) + # %i = command tag + # %e = SQL state + # %c = session ID + # %l = session line number + # %s = session start timestamp + # %v = virtual transaction ID + # %x = transaction ID (0 if none) + # %q = stop here in non-session + # processes + # %% = '%' + # e.g. '<%u%%%d> ' +#log_lock_waits = off # log lock waits >= deadlock_timeout +#log_statement = 'none' # none, ddl, mod, all +#log_replication_commands = off +#log_temp_files = -1 # log temporary files equal or larger + # than the specified size in kilobytes; + # -1 disables, 0 logs all temp files +#log_timezone = 'GMT' + +#------------------------------------------------------------------------------ +# PROCESS TITLE +#------------------------------------------------------------------------------ + +#cluster_name = '' # added to process titles if nonempty + # (change requires restart) +#update_process_title = on + + +#------------------------------------------------------------------------------ +# STATISTICS +#------------------------------------------------------------------------------ + +# - Query and Index Statistics Collector - + +#track_activities = on +#track_counts = on +#track_io_timing = off +#track_functions = none # none, pl, all +#track_activity_query_size = 1024 # (change requires restart) +#stats_temp_directory = 'pg_stat_tmp' + + +# - Monitoring - + +#log_parser_stats = off +#log_planner_stats = off +#log_executor_stats = off +#log_statement_stats = off + + +#------------------------------------------------------------------------------ +# AUTOVACUUM +#------------------------------------------------------------------------------ + +#autovacuum = on # Enable autovacuum subprocess? 'on' + # requires track_counts to also be on. +#log_autovacuum_min_duration = -1 # -1 disables, 0 logs all actions and + # their durations, > 0 logs only + # actions running at least this number + # of milliseconds. +#autovacuum_max_workers = 3 # max number of autovacuum subprocesses + # (change requires restart) +#autovacuum_naptime = 1min # time between autovacuum runs +#autovacuum_vacuum_threshold = 50 # min number of row updates before + # vacuum +#autovacuum_analyze_threshold = 50 # min number of row updates before + # analyze +#autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum +#autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze +#autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum + # (change requires restart) +#autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age + # before forced vacuum + # (change requires restart) +#autovacuum_vacuum_cost_delay = 2ms # default vacuum cost delay for + # autovacuum, in milliseconds; + # -1 means use vacuum_cost_delay +#autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for + # autovacuum, -1 means use + # vacuum_cost_limit + + +#------------------------------------------------------------------------------ +# CLIENT CONNECTION DEFAULTS +#------------------------------------------------------------------------------ + +# - Statement Behavior - + +#client_min_messages = notice # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # log + # notice + # warning + # error +#search_path = '"$user", public' # schema names +#row_security = on +#default_tablespace = '' # a tablespace name, '' uses the default +#temp_tablespaces = '' # a list of tablespace names, '' uses + # only default tablespace +#default_table_access_method = 'heap' +#check_function_bodies = on +#default_transaction_isolation = 'read committed' +#default_transaction_read_only = off +#default_transaction_deferrable = off +#session_replication_role = 'origin' +#statement_timeout = 0 # in milliseconds, 0 is disabled +#lock_timeout = 0 # in milliseconds, 0 is disabled +#idle_in_transaction_session_timeout = 0 # in milliseconds, 0 is disabled +#vacuum_freeze_min_age = 50000000 +#vacuum_freeze_table_age = 150000000 +#vacuum_multixact_freeze_min_age = 5000000 +#vacuum_multixact_freeze_table_age = 150000000 +#vacuum_cleanup_index_scale_factor = 0.1 # fraction of total number of tuples + # before index cleanup, 0 always performs + # index cleanup +#bytea_output = 'hex' # hex, escape +#xmlbinary = 'base64' +#xmloption = 'content' +#gin_fuzzy_search_limit = 0 +#gin_pending_list_limit = 4MB + +# - Locale and Formatting - + +#datestyle = 'iso, mdy' +#intervalstyle = 'postgres' +#timezone = 'GMT' +#timezone_abbreviations = 'Default' # Select the set of available time zone + # abbreviations. Currently, there are + # Default + # Australia (historical usage) + # India + # You can create your own file in + # share/timezonesets/. +#extra_float_digits = 1 # min -15, max 3; any value >0 actually + # selects precise output mode +#client_encoding = sql_ascii # actually, defaults to database + # encoding + +# These settings are initialized by initdb, but they can be changed. +#lc_messages = 'C' # locale for system error message + # strings +#lc_monetary = 'C' # locale for monetary formatting +#lc_numeric = 'C' # locale for number formatting +#lc_time = 'C' # locale for time formatting + +# default configuration for text search +#default_text_search_config = 'pg_catalog.simple' + +# - Shared Library Preloading - + +#shared_preload_libraries = '' # (change requires restart) +#local_preload_libraries = '' +#session_preload_libraries = '' +#jit_provider = 'llvmjit' # JIT library to use + +# - Other Defaults - + +#dynamic_library_path = '$libdir' + + +#------------------------------------------------------------------------------ +# LOCK MANAGEMENT +#------------------------------------------------------------------------------ + +#deadlock_timeout = 1s +#max_locks_per_transaction = 64 # min 10 + # (change requires restart) +#max_pred_locks_per_transaction = 64 # min 10 + # (change requires restart) +#max_pred_locks_per_relation = -2 # negative values mean + # (max_pred_locks_per_transaction + # / -max_pred_locks_per_relation) - 1 +#max_pred_locks_per_page = 2 # min 0 + + +#------------------------------------------------------------------------------ +# VERSION AND PLATFORM COMPATIBILITY +#------------------------------------------------------------------------------ + +# - Previous PostgreSQL Versions - + +#array_nulls = on +#backslash_quote = safe_encoding # on, off, or safe_encoding +#escape_string_warning = on +#lo_compat_privileges = off +#operator_precedence_warning = off +#quote_all_identifiers = off +#standard_conforming_strings = on +#synchronize_seqscans = on + +# - Other Platforms and Clients - + +#transform_null_equals = off + + +#------------------------------------------------------------------------------ +# ERROR HANDLING +#------------------------------------------------------------------------------ + +#exit_on_error = off # terminate session on any error? +#restart_after_crash = on # reinitialize after backend crash? +#data_sync_retry = off # retry or panic on failure to fsync + # data? + # (change requires restart) + + +#------------------------------------------------------------------------------ +# CONFIG FILE INCLUDES +#------------------------------------------------------------------------------ + +# These options allow settings to be loaded from files other than the +# default postgresql.conf. Note that these are directives, not variable +# assignments, so they can usefully be given more than once. + +#include_dir = '...' # include files ending in '.conf' from + # a directory, e.g., 'conf.d' +#include_if_exists = '...' # include file only if it exists +#include = '...' # include file + + +#------------------------------------------------------------------------------ +# CUSTOMIZED OPTIONS +#------------------------------------------------------------------------------ + +# Add settings for extensions here diff --git a/molecule/quarkus_ha/prepare.yml b/molecule/quarkus_ha/prepare.yml new file mode 100644 index 0000000..dff1821 --- /dev/null +++ b/molecule/quarkus_ha/prepare.yml @@ -0,0 +1,44 @@ +--- +- name: Prepare + hosts: keycloak + tasks: + - name: "Display hera_home if defined." + ansible.builtin.set_fact: + hera_home: "{{ lookup('env', 'HERA_HOME') }}" + + - name: "Ensure common prepare phase are set." + ansible.builtin.include_tasks: ../prepare.yml + + - name: Create certificate request + ansible.builtin.command: "openssl req -x509 -newkey rsa:4096 -keyout {{ inventory_hostname }}.key -out {{ inventory_hostname }}.pem -sha256 -days 365 -nodes -subj '/CN={{ inventory_hostname }}'" + delegate_to: localhost + changed_when: False + + - name: Create vault directory + become: true + ansible.builtin.file: + state: directory + path: "/opt/keycloak/vault" + mode: 0755 + + - name: Make sure a jre is available (for keytool to prepare keystore) + delegate_to: localhost + ansible.builtin.package: + name: "{{ 'java-17-openjdk-headless' if hera_home | length > 0 else 'openjdk-17-jdk-headless' }}" + state: present + become: true + failed_when: false + + - name: Create vault keystore + ansible.builtin.command: keytool -importpass -alias TestRealm_testalias -keystore keystore.p12 -storepass keystorepassword + delegate_to: localhost + register: keytool_cmd + changed_when: False + failed_when: not 'already exists' in keytool_cmd.stdout and keytool_cmd.rc != 0 + + - name: Copy certificates and vault + become: true + ansible.builtin.copy: + src: keystore.p12 + dest: /opt/keycloak/vault/keystore.p12 + mode: 0444 diff --git a/molecule/quarkus_ha/roles b/molecule/quarkus_ha/roles new file mode 120000 index 0000000..b741aa3 --- /dev/null +++ b/molecule/quarkus_ha/roles @@ -0,0 +1 @@ +../../roles \ No newline at end of file diff --git a/molecule/quarkus_ha/verify.yml b/molecule/quarkus_ha/verify.yml new file mode 100644 index 0000000..c1a2fb9 --- /dev/null +++ b/molecule/quarkus_ha/verify.yml @@ -0,0 +1,29 @@ +--- +- name: Verify + hosts: keycloak + tasks: + - name: Populate service facts + ansible.builtin.service_facts: + + - name: Check if keycloak service started + ansible.builtin.assert: + that: + - ansible_facts.services["keycloak.service"]["state"] == "running" + - ansible_facts.services["keycloak.service"]["status"] == "enabled" + fail_msg: "Service not running" + + - name: Set internal envvar + ansible.builtin.set_fact: + hera_home: "{{ lookup('env', 'HERA_HOME') }}" + + - name: Check log file + become: true + ansible.builtin.stat: + path: /var/log/keycloak/keycloak.log + register: keycloak_log_file + + - name: Check if keycloak file exists + ansible.builtin.assert: + that: + - keycloak_log_file.stat.exists + - not keycloak_log_file.stat.isdir diff --git a/molecule/quarkus_ha_remote/converge.yml b/molecule/quarkus_ha_remote/converge.yml new file mode 100644 index 0000000..e62ae23 --- /dev/null +++ b/molecule/quarkus_ha_remote/converge.yml @@ -0,0 +1,57 @@ +--- +- name: Converge + hosts: infinispan + roles: + - role: middleware_automation.infinispan.infinispan + infinispan_service_name: infinispan + infinispan_supervisor_password: remembertochangeme + infinispan_keycloak_caches: true + infinispan_keycloak_persistence: False + infinispan_jdbc_engine: postgres + infinispan_jdbc_url: jdbc:postgresql://postgres:5432/keycloak + infinispan_jdbc_driver_version: 9.4.1212 + infinispan_jdbc_user: keycloak + infinispan_jdbc_pass: mysecretpass + infinispan_bind_address: "{{ ansible_default_ipv4.address }}" + infinispan_users: + - { name: 'testuser', password: 'test', roles: 'observer' } + +- name: Converge + hosts: keycloak + vars: + keycloak_quarkus_show_deprecation_warnings: false + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_bootstrap_admin_user: "remembertochangeme" + keycloak_quarkus_hostname: "http://{{ inventory_hostname }}:8080" + keycloak_quarkus_log: file + keycloak_quarkus_log_level: info + keycloak_quarkus_https_key_file_enabled: true + keycloak_quarkus_key_file_copy_enabled: true + keycloak_quarkus_key_content: "{{ lookup('file', inventory_hostname + '.key') }}" + keycloak_quarkus_cert_file_copy_enabled: true + keycloak_quarkus_cert_file_src: "{{ inventory_hostname }}.pem" + keycloak_quarkus_ks_vault_enabled: true + keycloak_quarkus_ks_vault_file: "/opt/keycloak/vault/keystore.p12" + keycloak_quarkus_ks_vault_pass: keystorepassword + keycloak_quarkus_systemd_wait_for_port: true + keycloak_quarkus_systemd_wait_for_timeout: 20 + keycloak_quarkus_systemd_wait_for_delay: 2 + keycloak_quarkus_systemd_wait_for_log: true + keycloak_quarkus_ha_enabled: true + keycloak_quarkus_restart_strategy: restart/serial.yml + keycloak_quarkus_db_user: keycloak + keycloak_quarkus_db_pass: mysecretpass + keycloak_quarkus_db_url: jdbc:postgresql://postgres:5432/keycloak + keycloak_quarkus_cache_remote: true + keycloak_quarkus_cache_remote_username: supervisor + keycloak_quarkus_cache_remote_password: remembertochangeme + keycloak_quarkus_cache_remote_host: "infinispan1" + keycloak_quarkus_cache_remote_port: 11222 + keycloak_quarkus_cache_remote_tls_enabled: false + keycloak_quarkus_additional_env_vars: + - key: KC_FEATURES + value: clusterless + - key: KC_FEATURES_DISABLED + value: persistent-user-sessions + roles: + - role: keycloak_quarkus diff --git a/molecule/quarkus_ha_remote/molecule.yml b/molecule/quarkus_ha_remote/molecule.yml new file mode 100644 index 0000000..23d8db6 --- /dev/null +++ b/molecule/quarkus_ha_remote/molecule.yml @@ -0,0 +1,80 @@ +--- +driver: + name: docker +platforms: + - name: keycloak1 + image: registry.access.redhat.com/ubi9/ubi-init:latest + pre_build_image: true + privileged: true + command: "/usr/sbin/init" + groups: + - keycloak + networks: + - name: rhbk + port_bindings: + - "8080/tcp" + - "8443/tcp" + - "9000/tcp" + - name: infinispan1 + image: registry.access.redhat.com/ubi9/ubi-init:latest + pre_build_image: true + privileged: true + command: "/usr/sbin/init" + groups: + - infinispan + networks: + - name: rhbk + port_bindings: + - "11222/tcp" + - name: postgres + image: ubuntu/postgres:14-22.04_beta + pre_build_image: true + privileged: true + command: postgres + groups: + - database + networks: + - name: rhbk + port_bindings: + - "5432/tcp" + mounts: + - type: bind + target: /etc/postgresql/postgresql.conf + source: ${PWD}/molecule/quarkus_ha/postgresql/postgresql.conf + env: + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: mysecretpass + POSTGRES_DB: keycloak + POSTGRES_HOST_AUTH_METHOD: trust +provisioner: + name: ansible + config_options: + defaults: + interpreter_python: auto_silent + ssh_connection: + pipelining: false + playbooks: + prepare: prepare.yml + converge: converge.yml + verify: verify.yml + inventory: + host_vars: + localhost: + ansible_python_interpreter: "{{ ansible_playbook_python }}" + env: + ANSIBLE_FORCE_COLOR: "true" + PYTHONHTTPSVERIFY: 0 +verifier: + name: ansible +scenario: + test_sequence: + - cleanup + - destroy + - create + - prepare + - converge + - idempotence + - side_effect + - verify + - cleanup + - destroy diff --git a/molecule/quarkus_ha_remote/postgresql/postgresql.conf b/molecule/quarkus_ha_remote/postgresql/postgresql.conf new file mode 100644 index 0000000..d702576 --- /dev/null +++ b/molecule/quarkus_ha_remote/postgresql/postgresql.conf @@ -0,0 +1,750 @@ +# ----------------------------- +# PostgreSQL configuration file +# ----------------------------- +# +# This file consists of lines of the form: +# +# name = value +# +# (The "=" is optional.) Whitespace may be used. Comments are introduced with +# "#" anywhere on a line. The complete list of parameter names and allowed +# values can be found in the PostgreSQL documentation. +# +# The commented-out settings shown in this file represent the default values. +# Re-commenting a setting is NOT sufficient to revert it to the default value; +# you need to reload the server. +# +# This file is read on server startup and when the server receives a SIGHUP +# signal. If you edit the file on a running system, you have to SIGHUP the +# server for the changes to take effect, run "pg_ctl reload", or execute +# "SELECT pg_reload_conf()". Some parameters, which are marked below, +# require a server shutdown and restart to take effect. +# +# Any parameter can also be given as a command-line option to the server, e.g., +# "postgres -c log_connections=on". Some parameters can be changed at run time +# with the "SET" SQL command. +# +# Memory units: kB = kilobytes Time units: ms = milliseconds +# MB = megabytes s = seconds +# GB = gigabytes min = minutes +# TB = terabytes h = hours +# d = days + + +#------------------------------------------------------------------------------ +# FILE LOCATIONS +#------------------------------------------------------------------------------ + +# The default values of these variables are driven from the -D command-line +# option or PGDATA environment variable, represented here as ConfigDir. + +#data_directory = 'ConfigDir' # use data in another directory + # (change requires restart) +#hba_file = 'ConfigDir/pg_hba.conf' # host-based authentication file + # (change requires restart) +#ident_file = 'ConfigDir/pg_ident.conf' # ident configuration file + # (change requires restart) + +# If external_pid_file is not explicitly set, no extra PID file is written. +#external_pid_file = '' # write an extra PID file + # (change requires restart) + + +#------------------------------------------------------------------------------ +# CONNECTIONS AND AUTHENTICATION +#------------------------------------------------------------------------------ + +# - Connection Settings - + +listen_addresses = '*' # what IP address(es) to listen on; + # comma-separated list of addresses; + # defaults to 'localhost'; use '*' for all + # (change requires restart) +#port = 5432 # (change requires restart) +#max_connections = 100 # (change requires restart) +#superuser_reserved_connections = 3 # (change requires restart) +#unix_socket_directories = '/tmp' # comma-separated list of directories + # (change requires restart) +#unix_socket_group = '' # (change requires restart) +#unix_socket_permissions = 0777 # begin with 0 to use octal notation + # (change requires restart) +#bonjour = off # advertise server via Bonjour + # (change requires restart) +#bonjour_name = '' # defaults to the computer name + # (change requires restart) + +# - TCP settings - +# see "man 7 tcp" for details + +#tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds; + # 0 selects the system default +#tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds; + # 0 selects the system default +#tcp_keepalives_count = 0 # TCP_KEEPCNT; + # 0 selects the system default +#tcp_user_timeout = 0 # TCP_USER_TIMEOUT, in milliseconds; + # 0 selects the system default + +# - Authentication - + +#authentication_timeout = 1min # 1s-600s +#password_encryption = md5 # md5 or scram-sha-256 +#db_user_namespace = off + +# GSSAPI using Kerberos +#krb_server_keyfile = '' +#krb_caseins_users = off + +# - SSL - + +#ssl = off +#ssl_ca_file = '' +#ssl_cert_file = 'server.crt' +#ssl_crl_file = '' +#ssl_key_file = 'server.key' +#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers +#ssl_prefer_server_ciphers = on +#ssl_ecdh_curve = 'prime256v1' +#ssl_min_protocol_version = 'TLSv1' +#ssl_max_protocol_version = '' +#ssl_dh_params_file = '' +#ssl_passphrase_command = '' +#ssl_passphrase_command_supports_reload = off + + +#------------------------------------------------------------------------------ +# RESOURCE USAGE (except WAL) +#------------------------------------------------------------------------------ + +# - Memory - + +#shared_buffers = 32MB # min 128kB + # (change requires restart) +#huge_pages = try # on, off, or try + # (change requires restart) +#temp_buffers = 8MB # min 800kB +#max_prepared_transactions = 0 # zero disables the feature + # (change requires restart) +# Caution: it is not advisable to set max_prepared_transactions nonzero unless +# you actively intend to use prepared transactions. +#work_mem = 4MB # min 64kB +#maintenance_work_mem = 64MB # min 1MB +#autovacuum_work_mem = -1 # min 1MB, or -1 to use maintenance_work_mem +#max_stack_depth = 2MB # min 100kB +#shared_memory_type = mmap # the default is the first option + # supported by the operating system: + # mmap + # sysv + # windows + # (change requires restart) +#dynamic_shared_memory_type = posix # the default is the first option + # supported by the operating system: + # posix + # sysv + # windows + # mmap + # (change requires restart) + +# - Disk - + +#temp_file_limit = -1 # limits per-process temp file space + # in kB, or -1 for no limit + +# - Kernel Resources - + +#max_files_per_process = 1000 # min 25 + # (change requires restart) + +# - Cost-Based Vacuum Delay - + +#vacuum_cost_delay = 0 # 0-100 milliseconds (0 disables) +#vacuum_cost_page_hit = 1 # 0-10000 credits +#vacuum_cost_page_miss = 10 # 0-10000 credits +#vacuum_cost_page_dirty = 20 # 0-10000 credits +#vacuum_cost_limit = 200 # 1-10000 credits + +# - Background Writer - + +#bgwriter_delay = 200ms # 10-10000ms between rounds +#bgwriter_lru_maxpages = 100 # max buffers written/round, 0 disables +#bgwriter_lru_multiplier = 2.0 # 0-10.0 multiplier on buffers scanned/round +#bgwriter_flush_after = 0 # measured in pages, 0 disables + +# - Asynchronous Behavior - + +#effective_io_concurrency = 1 # 1-1000; 0 disables prefetching +#max_worker_processes = 8 # (change requires restart) +#max_parallel_maintenance_workers = 2 # taken from max_parallel_workers +#max_parallel_workers_per_gather = 2 # taken from max_parallel_workers +#parallel_leader_participation = on +#max_parallel_workers = 8 # maximum number of max_worker_processes that + # can be used in parallel operations +#old_snapshot_threshold = -1 # 1min-60d; -1 disables; 0 is immediate + # (change requires restart) +#backend_flush_after = 0 # measured in pages, 0 disables + + +#------------------------------------------------------------------------------ +# WRITE-AHEAD LOG +#------------------------------------------------------------------------------ + +# - Settings - + +#wal_level = replica # minimal, replica, or logical + # (change requires restart) +#fsync = on # flush data to disk for crash safety + # (turning this off can cause + # unrecoverable data corruption) +#synchronous_commit = on # synchronization level; + # off, local, remote_write, remote_apply, or on +#wal_sync_method = fsync # the default is the first option + # supported by the operating system: + # open_datasync + # fdatasync (default on Linux) + # fsync + # fsync_writethrough + # open_sync +#full_page_writes = on # recover from partial page writes +#wal_compression = off # enable compression of full-page writes +#wal_log_hints = off # also do full page writes of non-critical updates + # (change requires restart) +#wal_init_zero = on # zero-fill new WAL files +#wal_recycle = on # recycle WAL files +#wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers + # (change requires restart) +#wal_writer_delay = 200ms # 1-10000 milliseconds +#wal_writer_flush_after = 1MB # measured in pages, 0 disables + +#commit_delay = 0 # range 0-100000, in microseconds +#commit_siblings = 5 # range 1-1000 + +# - Checkpoints - + +#checkpoint_timeout = 5min # range 30s-1d +#max_wal_size = 1GB +#min_wal_size = 80MB +#checkpoint_completion_target = 0.5 # checkpoint target duration, 0.0 - 1.0 +#checkpoint_flush_after = 0 # measured in pages, 0 disables +#checkpoint_warning = 30s # 0 disables + +# - Archiving - + +#archive_mode = off # enables archiving; off, on, or always + # (change requires restart) +#archive_command = '' # command to use to archive a logfile segment + # placeholders: %p = path of file to archive + # %f = file name only + # e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f' +#archive_timeout = 0 # force a logfile segment switch after this + # number of seconds; 0 disables + +# - Archive Recovery - + +# These are only used in recovery mode. + +#restore_command = '' # command to use to restore an archived logfile segment + # placeholders: %p = path of file to restore + # %f = file name only + # e.g. 'cp /mnt/server/archivedir/%f %p' + # (change requires restart) +#archive_cleanup_command = '' # command to execute at every restartpoint +#recovery_end_command = '' # command to execute at completion of recovery + +# - Recovery Target - + +# Set these only when performing a targeted recovery. + +#recovery_target = '' # 'immediate' to end recovery as soon as a + # consistent state is reached + # (change requires restart) +#recovery_target_name = '' # the named restore point to which recovery will proceed + # (change requires restart) +#recovery_target_time = '' # the time stamp up to which recovery will proceed + # (change requires restart) +#recovery_target_xid = '' # the transaction ID up to which recovery will proceed + # (change requires restart) +#recovery_target_lsn = '' # the WAL LSN up to which recovery will proceed + # (change requires restart) +#recovery_target_inclusive = on # Specifies whether to stop: + # just after the specified recovery target (on) + # just before the recovery target (off) + # (change requires restart) +#recovery_target_timeline = 'latest' # 'current', 'latest', or timeline ID + # (change requires restart) +#recovery_target_action = 'pause' # 'pause', 'promote', 'shutdown' + # (change requires restart) + + +#------------------------------------------------------------------------------ +# REPLICATION +#------------------------------------------------------------------------------ + +# - Sending Servers - + +# Set these on the master and on any standby that will send replication data. + +#max_wal_senders = 10 # max number of walsender processes + # (change requires restart) +#wal_keep_segments = 0 # in logfile segments; 0 disables +#wal_sender_timeout = 60s # in milliseconds; 0 disables + +#max_replication_slots = 10 # max number of replication slots + # (change requires restart) +#track_commit_timestamp = off # collect timestamp of transaction commit + # (change requires restart) + +# - Master Server - + +# These settings are ignored on a standby server. + +#synchronous_standby_names = '' # standby servers that provide sync rep + # method to choose sync standbys, number of sync standbys, + # and comma-separated list of application_name + # from standby(s); '*' = all +#vacuum_defer_cleanup_age = 0 # number of xacts by which cleanup is delayed + +# - Standby Servers - + +# These settings are ignored on a master server. + +#primary_conninfo = '' # connection string to sending server + # (change requires restart) +#primary_slot_name = '' # replication slot on sending server + # (change requires restart) +#promote_trigger_file = '' # file name whose presence ends recovery +#hot_standby = on # "off" disallows queries during recovery + # (change requires restart) +#max_standby_archive_delay = 30s # max delay before canceling queries + # when reading WAL from archive; + # -1 allows indefinite delay +#max_standby_streaming_delay = 30s # max delay before canceling queries + # when reading streaming WAL; + # -1 allows indefinite delay +#wal_receiver_status_interval = 10s # send replies at least this often + # 0 disables +#hot_standby_feedback = off # send info from standby to prevent + # query conflicts +#wal_receiver_timeout = 60s # time that receiver waits for + # communication from master + # in milliseconds; 0 disables +#wal_retrieve_retry_interval = 5s # time to wait before retrying to + # retrieve WAL after a failed attempt +#recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery + +# - Subscribers - + +# These settings are ignored on a publisher. + +#max_logical_replication_workers = 4 # taken from max_worker_processes + # (change requires restart) +#max_sync_workers_per_subscription = 2 # taken from max_logical_replication_workers + + +#------------------------------------------------------------------------------ +# QUERY TUNING +#------------------------------------------------------------------------------ + +# - Planner Method Configuration - + +#enable_bitmapscan = on +#enable_hashagg = on +#enable_hashjoin = on +#enable_indexscan = on +#enable_indexonlyscan = on +#enable_material = on +#enable_mergejoin = on +#enable_nestloop = on +#enable_parallel_append = on +#enable_seqscan = on +#enable_sort = on +#enable_tidscan = on +#enable_partitionwise_join = off +#enable_partitionwise_aggregate = off +#enable_parallel_hash = on +#enable_partition_pruning = on + +# - Planner Cost Constants - + +#seq_page_cost = 1.0 # measured on an arbitrary scale +#random_page_cost = 4.0 # same scale as above +#cpu_tuple_cost = 0.01 # same scale as above +#cpu_index_tuple_cost = 0.005 # same scale as above +#cpu_operator_cost = 0.0025 # same scale as above +#parallel_tuple_cost = 0.1 # same scale as above +#parallel_setup_cost = 1000.0 # same scale as above + +#jit_above_cost = 100000 # perform JIT compilation if available + # and query more expensive than this; + # -1 disables +#jit_inline_above_cost = 500000 # inline small functions if query is + # more expensive than this; -1 disables +#jit_optimize_above_cost = 500000 # use expensive JIT optimizations if + # query is more expensive than this; + # -1 disables + +#min_parallel_table_scan_size = 8MB +#min_parallel_index_scan_size = 512kB +#effective_cache_size = 4GB + +# - Genetic Query Optimizer - + +#geqo = on +#geqo_threshold = 12 +#geqo_effort = 5 # range 1-10 +#geqo_pool_size = 0 # selects default based on effort +#geqo_generations = 0 # selects default based on effort +#geqo_selection_bias = 2.0 # range 1.5-2.0 +#geqo_seed = 0.0 # range 0.0-1.0 + +# - Other Planner Options - + +#default_statistics_target = 100 # range 1-10000 +#constraint_exclusion = partition # on, off, or partition +#cursor_tuple_fraction = 0.1 # range 0.0-1.0 +#from_collapse_limit = 8 +#join_collapse_limit = 8 # 1 disables collapsing of explicit + # JOIN clauses +#force_parallel_mode = off +#jit = on # allow JIT compilation +#plan_cache_mode = auto # auto, force_generic_plan or + # force_custom_plan + + +#------------------------------------------------------------------------------ +# REPORTING AND LOGGING +#------------------------------------------------------------------------------ + +# - Where to Log - + +#log_destination = 'stderr' # Valid values are combinations of + # stderr, csvlog, syslog, and eventlog, + # depending on platform. csvlog + # requires logging_collector to be on. + +# This is used when logging to stderr: +#logging_collector = off # Enable capturing of stderr and csvlog + # into log files. Required to be on for + # csvlogs. + # (change requires restart) + +# These are only used if logging_collector is on: +#log_directory = 'log' # directory where log files are written, + # can be absolute or relative to PGDATA +#log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern, + # can include strftime() escapes +#log_file_mode = 0600 # creation mode for log files, + # begin with 0 to use octal notation +#log_truncate_on_rotation = off # If on, an existing log file with the + # same name as the new log file will be + # truncated rather than appended to. + # But such truncation only occurs on + # time-driven rotation, not on restarts + # or size-driven rotation. Default is + # off, meaning append to existing files + # in all cases. +#log_rotation_age = 1d # Automatic rotation of logfiles will + # happen after that time. 0 disables. +#log_rotation_size = 10MB # Automatic rotation of logfiles will + # happen after that much log output. + # 0 disables. + +# These are relevant when logging to syslog: +#syslog_facility = 'LOCAL0' +#syslog_ident = 'postgres' +#syslog_sequence_numbers = on +#syslog_split_messages = on + +# This is only relevant when logging to eventlog (win32): +# (change requires restart) +#event_source = 'PostgreSQL' + +# - When to Log - + +#log_min_messages = warning # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # info + # notice + # warning + # error + # log + # fatal + # panic + +#log_min_error_statement = error # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # info + # notice + # warning + # error + # log + # fatal + # panic (effectively off) + +#log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements + # and their durations, > 0 logs only + # statements running at least this number + # of milliseconds + +#log_transaction_sample_rate = 0.0 # Fraction of transactions whose statements + # are logged regardless of their duration. 1.0 logs all + # statements from all transactions, 0.0 never logs. + +# - What to Log - + +#debug_print_parse = off +#debug_print_rewritten = off +#debug_print_plan = off +#debug_pretty_print = on +#log_checkpoints = off +#log_connections = off +#log_disconnections = off +#log_duration = off +#log_error_verbosity = default # terse, default, or verbose messages +#log_hostname = off +#log_line_prefix = '%m [%p] ' # special values: + # %a = application name + # %u = user name + # %d = database name + # %r = remote host and port + # %h = remote host + # %p = process ID + # %t = timestamp without milliseconds + # %m = timestamp with milliseconds + # %n = timestamp with milliseconds (as a Unix epoch) + # %i = command tag + # %e = SQL state + # %c = session ID + # %l = session line number + # %s = session start timestamp + # %v = virtual transaction ID + # %x = transaction ID (0 if none) + # %q = stop here in non-session + # processes + # %% = '%' + # e.g. '<%u%%%d> ' +#log_lock_waits = off # log lock waits >= deadlock_timeout +#log_statement = 'none' # none, ddl, mod, all +#log_replication_commands = off +#log_temp_files = -1 # log temporary files equal or larger + # than the specified size in kilobytes; + # -1 disables, 0 logs all temp files +#log_timezone = 'GMT' + +#------------------------------------------------------------------------------ +# PROCESS TITLE +#------------------------------------------------------------------------------ + +#cluster_name = '' # added to process titles if nonempty + # (change requires restart) +#update_process_title = on + + +#------------------------------------------------------------------------------ +# STATISTICS +#------------------------------------------------------------------------------ + +# - Query and Index Statistics Collector - + +#track_activities = on +#track_counts = on +#track_io_timing = off +#track_functions = none # none, pl, all +#track_activity_query_size = 1024 # (change requires restart) +#stats_temp_directory = 'pg_stat_tmp' + + +# - Monitoring - + +#log_parser_stats = off +#log_planner_stats = off +#log_executor_stats = off +#log_statement_stats = off + + +#------------------------------------------------------------------------------ +# AUTOVACUUM +#------------------------------------------------------------------------------ + +#autovacuum = on # Enable autovacuum subprocess? 'on' + # requires track_counts to also be on. +#log_autovacuum_min_duration = -1 # -1 disables, 0 logs all actions and + # their durations, > 0 logs only + # actions running at least this number + # of milliseconds. +#autovacuum_max_workers = 3 # max number of autovacuum subprocesses + # (change requires restart) +#autovacuum_naptime = 1min # time between autovacuum runs +#autovacuum_vacuum_threshold = 50 # min number of row updates before + # vacuum +#autovacuum_analyze_threshold = 50 # min number of row updates before + # analyze +#autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum +#autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze +#autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum + # (change requires restart) +#autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age + # before forced vacuum + # (change requires restart) +#autovacuum_vacuum_cost_delay = 2ms # default vacuum cost delay for + # autovacuum, in milliseconds; + # -1 means use vacuum_cost_delay +#autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for + # autovacuum, -1 means use + # vacuum_cost_limit + + +#------------------------------------------------------------------------------ +# CLIENT CONNECTION DEFAULTS +#------------------------------------------------------------------------------ + +# - Statement Behavior - + +#client_min_messages = notice # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # log + # notice + # warning + # error +#search_path = '"$user", public' # schema names +#row_security = on +#default_tablespace = '' # a tablespace name, '' uses the default +#temp_tablespaces = '' # a list of tablespace names, '' uses + # only default tablespace +#default_table_access_method = 'heap' +#check_function_bodies = on +#default_transaction_isolation = 'read committed' +#default_transaction_read_only = off +#default_transaction_deferrable = off +#session_replication_role = 'origin' +#statement_timeout = 0 # in milliseconds, 0 is disabled +#lock_timeout = 0 # in milliseconds, 0 is disabled +#idle_in_transaction_session_timeout = 0 # in milliseconds, 0 is disabled +#vacuum_freeze_min_age = 50000000 +#vacuum_freeze_table_age = 150000000 +#vacuum_multixact_freeze_min_age = 5000000 +#vacuum_multixact_freeze_table_age = 150000000 +#vacuum_cleanup_index_scale_factor = 0.1 # fraction of total number of tuples + # before index cleanup, 0 always performs + # index cleanup +#bytea_output = 'hex' # hex, escape +#xmlbinary = 'base64' +#xmloption = 'content' +#gin_fuzzy_search_limit = 0 +#gin_pending_list_limit = 4MB + +# - Locale and Formatting - + +#datestyle = 'iso, mdy' +#intervalstyle = 'postgres' +#timezone = 'GMT' +#timezone_abbreviations = 'Default' # Select the set of available time zone + # abbreviations. Currently, there are + # Default + # Australia (historical usage) + # India + # You can create your own file in + # share/timezonesets/. +#extra_float_digits = 1 # min -15, max 3; any value >0 actually + # selects precise output mode +#client_encoding = sql_ascii # actually, defaults to database + # encoding + +# These settings are initialized by initdb, but they can be changed. +#lc_messages = 'C' # locale for system error message + # strings +#lc_monetary = 'C' # locale for monetary formatting +#lc_numeric = 'C' # locale for number formatting +#lc_time = 'C' # locale for time formatting + +# default configuration for text search +#default_text_search_config = 'pg_catalog.simple' + +# - Shared Library Preloading - + +#shared_preload_libraries = '' # (change requires restart) +#local_preload_libraries = '' +#session_preload_libraries = '' +#jit_provider = 'llvmjit' # JIT library to use + +# - Other Defaults - + +#dynamic_library_path = '$libdir' + + +#------------------------------------------------------------------------------ +# LOCK MANAGEMENT +#------------------------------------------------------------------------------ + +#deadlock_timeout = 1s +#max_locks_per_transaction = 64 # min 10 + # (change requires restart) +#max_pred_locks_per_transaction = 64 # min 10 + # (change requires restart) +#max_pred_locks_per_relation = -2 # negative values mean + # (max_pred_locks_per_transaction + # / -max_pred_locks_per_relation) - 1 +#max_pred_locks_per_page = 2 # min 0 + + +#------------------------------------------------------------------------------ +# VERSION AND PLATFORM COMPATIBILITY +#------------------------------------------------------------------------------ + +# - Previous PostgreSQL Versions - + +#array_nulls = on +#backslash_quote = safe_encoding # on, off, or safe_encoding +#escape_string_warning = on +#lo_compat_privileges = off +#operator_precedence_warning = off +#quote_all_identifiers = off +#standard_conforming_strings = on +#synchronize_seqscans = on + +# - Other Platforms and Clients - + +#transform_null_equals = off + + +#------------------------------------------------------------------------------ +# ERROR HANDLING +#------------------------------------------------------------------------------ + +#exit_on_error = off # terminate session on any error? +#restart_after_crash = on # reinitialize after backend crash? +#data_sync_retry = off # retry or panic on failure to fsync + # data? + # (change requires restart) + + +#------------------------------------------------------------------------------ +# CONFIG FILE INCLUDES +#------------------------------------------------------------------------------ + +# These options allow settings to be loaded from files other than the +# default postgresql.conf. Note that these are directives, not variable +# assignments, so they can usefully be given more than once. + +#include_dir = '...' # include files ending in '.conf' from + # a directory, e.g., 'conf.d' +#include_if_exists = '...' # include file only if it exists +#include = '...' # include file + + +#------------------------------------------------------------------------------ +# CUSTOMIZED OPTIONS +#------------------------------------------------------------------------------ + +# Add settings for extensions here diff --git a/molecule/quarkus_ha_remote/prepare.yml b/molecule/quarkus_ha_remote/prepare.yml new file mode 100644 index 0000000..16ae9b9 --- /dev/null +++ b/molecule/quarkus_ha_remote/prepare.yml @@ -0,0 +1,44 @@ +--- +- name: Prepare + hosts: 'keycloak:infinispan' + tasks: + - name: "Display hera_home if defined." + ansible.builtin.set_fact: + hera_home: "{{ lookup('env', 'HERA_HOME') }}" + + - name: "Ensure common prepare phase are set." + ansible.builtin.include_tasks: ../prepare.yml + + - name: Create certificate request + ansible.builtin.command: "openssl req -x509 -newkey rsa:4096 -keyout {{ inventory_hostname }}.key -out {{ inventory_hostname }}.pem -sha256 -days 365 -nodes -subj '/CN={{ inventory_hostname }}'" + delegate_to: localhost + changed_when: False + + - name: Create vault directory + become: true + ansible.builtin.file: + state: directory + path: "/opt/keycloak/vault" + mode: 0755 + + - name: Make sure a jre is available (for keytool to prepare keystore) + delegate_to: localhost + ansible.builtin.package: + name: "{{ 'java-17-openjdk-headless' if hera_home | length > 0 else 'openjdk-17-jdk-headless' }}" + state: present + become: true + failed_when: false + + - name: Create vault keystore + ansible.builtin.command: keytool -importpass -alias TestRealm_testalias -keystore keystore.p12 -storepass keystorepassword + delegate_to: localhost + register: keytool_cmd + changed_when: False + failed_when: not 'already exists' in keytool_cmd.stdout and keytool_cmd.rc != 0 + + - name: Copy certificates and vault + become: true + ansible.builtin.copy: + src: keystore.p12 + dest: /opt/keycloak/vault/keystore.p12 + mode: 0444 diff --git a/molecule/quarkus_ha_remote/roles b/molecule/quarkus_ha_remote/roles new file mode 120000 index 0000000..b741aa3 --- /dev/null +++ b/molecule/quarkus_ha_remote/roles @@ -0,0 +1 @@ +../../roles \ No newline at end of file diff --git a/molecule/quarkus_ha_remote/verify.yml b/molecule/quarkus_ha_remote/verify.yml new file mode 100644 index 0000000..c1a2fb9 --- /dev/null +++ b/molecule/quarkus_ha_remote/verify.yml @@ -0,0 +1,29 @@ +--- +- name: Verify + hosts: keycloak + tasks: + - name: Populate service facts + ansible.builtin.service_facts: + + - name: Check if keycloak service started + ansible.builtin.assert: + that: + - ansible_facts.services["keycloak.service"]["state"] == "running" + - ansible_facts.services["keycloak.service"]["status"] == "enabled" + fail_msg: "Service not running" + + - name: Set internal envvar + ansible.builtin.set_fact: + hera_home: "{{ lookup('env', 'HERA_HOME') }}" + + - name: Check log file + become: true + ansible.builtin.stat: + path: /var/log/keycloak/keycloak.log + register: keycloak_log_file + + - name: Check if keycloak file exists + ansible.builtin.assert: + that: + - keycloak_log_file.stat.exists + - not keycloak_log_file.stat.isdir diff --git a/molecule/quarkus_upgrade/converge.yml b/molecule/quarkus_upgrade/converge.yml new file mode 100644 index 0000000..0f67169 --- /dev/null +++ b/molecule/quarkus_upgrade/converge.yml @@ -0,0 +1,13 @@ +--- +- name: Converge + hosts: all + vars_files: + - vars.yml + vars: + keycloak_quarkus_show_deprecation_warnings: false + keycloak_quarkus_additional_env_vars: + - key: KC_FEATURES_DISABLED + value: ciba,device-flow,impersonation,kerberos,docker + keycloak_quarkus_version: 26.0.7 + roles: + - role: keycloak_quarkus diff --git a/molecule/quarkus_upgrade/molecule.yml b/molecule/quarkus_upgrade/molecule.yml new file mode 100644 index 0000000..b121079 --- /dev/null +++ b/molecule/quarkus_upgrade/molecule.yml @@ -0,0 +1,49 @@ +--- +dependency: + name: galaxy + options: + requirements-file: molecule/requirements.yml +driver: + name: podman +platforms: + - name: instance + image: registry.access.redhat.com/ubi9/ubi-init:latest + command: "/usr/sbin/init" + pre_build_image: true + 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: + prepare: prepare.yml + converge: converge.yml + verify: verify.yml + inventory: + host_vars: + localhost: + ansible_python_interpreter: "{{ ansible_playbook_python }}" + env: + ANSIBLE_FORCE_COLOR: "true" + PROXY: "${PROXY}" + NO_PROXY: "${NO_PROXY}" +verifier: + name: ansible +scenario: + test_sequence: + - dependency + - cleanup + - destroy + - syntax + - create + - prepare + - converge + - idempotence + - side_effect + - verify + - cleanup + - destroy diff --git a/molecule/quarkus_upgrade/prepare.yml b/molecule/quarkus_upgrade/prepare.yml new file mode 100644 index 0000000..1be16d6 --- /dev/null +++ b/molecule/quarkus_upgrade/prepare.yml @@ -0,0 +1,56 @@ +--- +- name: Prepare + hosts: all + vars_files: + - vars.yml + vars: + sudo_pkg_name: sudo + keycloak_quarkus_version: 26.0.4 + keycloak_quarkus_additional_env_vars: + - key: KC_FEATURES_DISABLED + value: impersonation,kerberos + pre_tasks: + - name: Install sudo + ansible.builtin.apt: + name: + - sudo + - openjdk-17-jdk-headless + state: present + when: + - ansible_facts.os_family == 'Debian' + + - name: "Ensure common prepare phase are set." + ansible.builtin.include_tasks: ../prepare.yml + + - name: Display Ansible version + ansible.builtin.debug: + msg: "Ansible version is {{ ansible_version.full }}" + + - name: "Ensure {{ sudo_pkg_name }} is installed (if user is root)." + ansible.builtin.dnf: + name: "{{ sudo_pkg_name }}" + when: + - ansible_user_id == 'root' + + - name: Gather the package facts + ansible.builtin.package_facts: + manager: auto + + - name: "Check if {{ sudo_pkg_name }} is installed." + ansible.builtin.assert: + that: + - sudo_pkg_name in ansible_facts.packages + + - 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 + roles: + - role: keycloak_quarkus + + post_tasks: + - name: "Delete custom fact" + ansible.builtin.file: + path: /etc/ansible/facts.d/keycloak.fact + state: absent + become: true 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 new file mode 100644 index 0000000..1567ae4 --- /dev/null +++ b/molecule/quarkus_upgrade/vars.yml @@ -0,0 +1,13 @@ +--- +keycloak_quarkus_offline_install: false +keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" +keycloak_quarkus_realm: TestRealm +keycloak_quarkus_hostname: http://instance:8080 +keycloak_quarkus_log: file +keycloak_quarkus_https_key_file_enabled: true +keycloak_quarkus_log_target: /tmp/keycloak +keycloak_quarkus_hostname_strict: false +keycloak_quarkus_cert_file_copy_enabled: true +keycloak_quarkus_key_file_copy_enabled: true +keycloak_quarkus_key_content: "{{ lookup('file', 'key.pem') }}" +keycloak_quarkus_cert_file_src: cert.pem diff --git a/molecule/quarkus_upgrade/verify.yml b/molecule/quarkus_upgrade/verify.yml new file mode 100644 index 0000000..1c4a0ba --- /dev/null +++ b/molecule/quarkus_upgrade/verify.yml @@ -0,0 +1,32 @@ +--- +- name: Verify + hosts: instance + vars: + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_port: http://localhost:8080 + tasks: + - name: Populate service facts + ansible.builtin.service_facts: + + - name: Check if keycloak service started + ansible.builtin.assert: + that: + - ansible_facts.services["keycloak.service"]["state"] == "running" + - ansible_facts.services["keycloak.service"]["status"] == "enabled" + + - name: Verify we are running on requested jvm + ansible.builtin.shell: | + set -eo pipefail + 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_bootstrap_admin_password }}&grant_type=password" + validate_certs: no + register: keycloak_auth_response + until: keycloak_auth_response.status == 200 + retries: 2 + delay: 2 diff --git a/molecule/requirements.yml b/molecule/requirements.yml new file mode 100644 index 0000000..125a922 --- /dev/null +++ b/molecule/requirements.yml @@ -0,0 +1,12 @@ +--- +collections: + - name: middleware_automation.common + - name: middleware_automation.jbcs + - name: middleware_automation.infinispan + - name: community.general + - name: ansible.posix + - name: community.docker + version: ">=3.8.0" + +roles: + - name: elan.simple_nginx_reverse_proxy diff --git a/playbooks/keycloak.yml b/playbooks/keycloak.yml index 2b222a5..6c7ca4a 100644 --- a/playbooks/keycloak.yml +++ b/playbooks/keycloak.yml @@ -3,7 +3,5 @@ hosts: all vars: keycloak_admin_password: "remembertochangeme" - collections: - - middleware_automation.keycloak roles: - - keycloak + - middleware_automation.keycloak.keycloak diff --git a/playbooks/keycloak_federation.yml b/playbooks/keycloak_federation.yml new file mode 100644 index 0000000..49cb6c0 --- /dev/null +++ b/playbooks/keycloak_federation.yml @@ -0,0 +1,68 @@ +--- +- name: Playbook for Keycloak Hosts + hosts: all + tasks: + - name: Keycloak Realm Role + ansible.builtin.include_role: + name: keycloak_realm + vars: + keycloak_admin_password: "remembertochangeme" + keycloak_realm: TestRealm + keycloak_user_federation: + - realm: TestRealm + name: my-ldap + provider_id: ldap + provider_type: org.keycloak.storage.UserStorageProvider + config: + priority: '0' + enabled: true + cachePolicy: DEFAULT + batchSizeForSync: '1000' + editMode: READ_ONLY + importEnabled: true + syncRegistrations: false + vendor: other + usernameLDAPAttribute: uid + rdnLDAPAttribute: uid + uuidLDAPAttribute: entryUUID + userObjectClasses: inetOrgPerson, organizationalPerson + connectionUrl: ldaps://ldap.example.com:636 + usersDn: ou=Users,dc=example,dc=com + authType: simple + bindDn: cn=directory reader + bindCredential: password + searchScope: '1' + validatePasswordPolicy: false + trustEmail: false + useTruststoreSpi: ldapsOnly + connectionPooling: true + pagination: true + allowKerberosAuthentication: false + debug: false + useKerberosForPasswordAuthentication: false + mappers: + - name: "full name" + providerId: "full-name-ldap-mapper" + providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" + config: + ldap.full.name.attribute: cn + read.only: true + write.only: false + keycloak_clients: + - name: TestClient1 + client_id: TestClient1 + roles: + - TestClient1Admin + - TestClient1User + realm: "{{ keycloak_realm }}" + public_client: true + web_origins: + - http://testclient1origin/application + - http://testclient1origin/other + users: + - username: TestUser + password: password + client_roles: + - client: TestClient1 + role: TestClient1User + realm: "{{ keycloak_realm }}" diff --git a/playbooks/keycloak_quarkus.yml b/playbooks/keycloak_quarkus.yml index b9bbbba..b8aedf2 100644 --- a/playbooks/keycloak_quarkus.yml +++ b/playbooks/keycloak_quarkus.yml @@ -1,15 +1,11 @@ --- -- name: Playbook for Keycloak X Hosts +- name: Playbook for Keycloak X Hosts with HTTPS enabled hosts: all vars: - keycloak_admin_password: "remembertochangeme" - keycloak_quarkus_host: localhost:8443 - keycloak_quarkus_http_relative_path: '' + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_hostname: http://localhost + keycloak_quarkus_port: 8443 keycloak_quarkus_log: file - keycloak_quarkus_https_enabled: True - keycloak_quarkus_key_file: conf/key.pem - keycloak_quarkus_cert_file: conf/cert.pem - collections: - - middleware_automation.keycloak + keycloak_quarkus_proxy_mode: none roles: - - keycloak_quarkus + - middleware_automation.keycloak.keycloak_quarkus diff --git a/playbooks/keycloak_quarkus_dev.yml b/playbooks/keycloak_quarkus_dev.yml new file mode 100644 index 0000000..c8bb54e --- /dev/null +++ b/playbooks/keycloak_quarkus_dev.yml @@ -0,0 +1,12 @@ +--- +- name: Playbook for Keycloak X Hosts in develop mode + hosts: all + vars: + keycloak_quarkus_bootstrap_admin_password: "remembertochangeme" + keycloak_quarkus_hostname: http://localhost + keycloak_quarkus_port: 8080 + keycloak_quarkus_log: file + keycloak_quarkus_start_dev: true + keycloak_quarkus_proxy_mode: none + roles: + - middleware_automation.keycloak.keycloak_quarkus diff --git a/playbooks/keycloak_realm.yml b/playbooks/keycloak_realm.yml index baeb2f2..99b2ef8 100644 --- a/playbooks/keycloak_realm.yml +++ b/playbooks/keycloak_realm.yml @@ -1,67 +1,26 @@ --- - name: Playbook for Keycloak Hosts hosts: all - tasks: - - name: Keycloak Realm Role - ansible.builtin.include_role: - name: middleware_automation.keycloak.keycloak_realm - vars: - keycloak_admin_password: "remembertochangeme" - keycloak_realm: TestRealm - keycloak_user_federation: - - realm: TestRealm - name: my-ldap - provider_id: ldap - provider_type: org.keycloak.storage.UserStorageProvider - config: - priority: '0' - enabled: true - cachePolicy: DEFAULT - batchSizeForSync: '1000' - editMode: READ_ONLY - importEnabled: true - syncRegistrations: false - vendor: other - usernameLDAPAttribute: uid - rdnLDAPAttribute: uid - uuidLDAPAttribute: entryUUID - userObjectClasses: inetOrgPerson, organizationalPerson - connectionUrl: ldaps://ldap.example.com:636 - usersDn: ou=Users,dc=example,dc=com - authType: simple - bindDn: cn=directory reader - bindCredential: password - searchScope: '1' - validatePasswordPolicy: false - trustEmail: false - useTruststoreSpi: ldapsOnly - connectionPooling: true - pagination: true - allowKerberosAuthentication: false - debug: false - useKerberosForPasswordAuthentication: false - mappers: - - name: "full name" - providerId: "full-name-ldap-mapper" - providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" - config: - ldap.full.name.attribute: cn - read.only: true - write.only: false - keycloak_clients: - - name: TestClient1 - roles: - - TestClient1Admin - - TestClient1User - realm: "{{ keycloak_realm }}" - public_client: True - web_origins: - - http://testclient1origin/application - - http://testclient1origin/other - users: - - username: TestUser - password: password - client_roles: - - client: TestClient1 - role: TestClient1User - realm: "{{ keycloak_realm }}" + vars: + keycloak_admin_password: "remembertochangeme" + keycloak_clients: + - name: TestClient1 + client_id: TestClient1 + roles: + - TestClient1Admin + - TestClient1User + realm: TestRealm + public_client: true + web_origins: + - http://testclient1origin/application + - http://testclient1origin/other + users: + - username: TestUser + password: password + client_roles: + - client: TestClient1 + role: TestClient1User + realm: TestRealm + roles: + - role: middleware_automation.keycloak.keycloak_realm + keycloak_realm: TestRealm diff --git a/playbooks/rhsso.yml b/playbooks/rhsso.yml index ba30a74..ea61f66 100644 --- a/playbooks/rhsso.yml +++ b/playbooks/rhsso.yml @@ -1,12 +1,8 @@ --- -- name: Playbook for Keycloak Hosts - hosts: keycloak +- name: Playbook for Red Hat SSO Hosts + hosts: sso vars: keycloak_admin_password: "remembertochangeme" - keycloak_rhsso_enable: True - collections: - - middleware_automation.redhat_csp_download - - middleware_automation.keycloak + sso_enable: true roles: - - middleware_automation.redhat_csp_download.redhat_csp_download - middleware_automation.keycloak.keycloak diff --git a/plugins/doc_fragments/attributes.py b/plugins/doc_fragments/attributes.py new file mode 100644 index 0000000..9b8488e --- /dev/null +++ b/plugins/doc_fragments/attributes.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard documentation fragment + DOCUMENTATION = r''' +options: {} +attributes: + check_mode: + description: Can run in C(check_mode) and return changed status prediction without modifying target. + diff_mode: + description: Will return details on what has changed (or possibly needs changing in C(check_mode)), when in diff mode. +''' + + PLATFORM = r''' +options: {} +attributes: + platform: + description: Target OS/families that can be operated against. + support: N/A +''' + + # Should be used together with the standard fragment + INFO_MODULE = r''' +options: {} +attributes: + check_mode: + support: full + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. +''' + + CONN = r''' +options: {} +attributes: + become: + description: Is usable alongside C(become) keywords. + connection: + description: Uses the target's configured connection information to execute code on it. + delegation: + description: Can be used in conjunction with C(delegate_to) and related keywords. +''' + + FACTS = r''' +options: {} +attributes: + facts: + description: Action returns an C(ansible_facts) dictionary that will update existing host facts. +''' + + # Should be used together with the standard fragment and the FACTS fragment + FACTS_MODULE = r''' +options: {} +attributes: + check_mode: + support: full + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. + facts: + support: full +''' + + FILES = r''' +options: {} +attributes: + safe_file_operations: + description: Uses Ansible's strict file operation functions to ensure proper permissions and avoid data corruption. +''' + + FLOW = r''' +options: {} +attributes: + action: + description: Indicates this has a corresponding action plugin so some parts of the options can be executed on the controller. + async: + description: Supports being used with the C(async) keyword. +''' diff --git a/plugins/doc_fragments/keycloak.py b/plugins/doc_fragments/keycloak.py new file mode 100644 index 0000000..5d79fad --- /dev/null +++ b/plugins/doc_fragments/keycloak.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Eike Frost +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard documentation fragment + DOCUMENTATION = r''' +options: + auth_keycloak_url: + description: + - URL to the Keycloak instance. + type: str + required: true + aliases: + - url + + auth_client_id: + description: + - OpenID Connect I(client_id) to authenticate to the API with. + type: str + default: admin-cli + + auth_realm: + description: + - Keycloak realm name to authenticate to for API access. + type: str + + auth_client_secret: + description: + - Client Secret to use in conjunction with I(auth_client_id) (if required). + type: str + + auth_username: + description: + - Username to authenticate for API access with. + type: str + aliases: + - username + + auth_password: + description: + - Password to authenticate for API access with. + type: str + aliases: + - password + + token: + description: + - Authentication token for Keycloak API. + type: str + version_added: 3.0.0 + + validate_certs: + description: + - Verify TLS certificates (do not disable this in production). + type: bool + default: true + + connection_timeout: + description: + - Controls the HTTP connections timeout period (in seconds) to Keycloak API. + type: int + default: 10 + version_added: 4.5.0 + http_agent: + description: + - Configures the HTTP User-Agent header. + type: str + default: Ansible + version_added: 5.4.0 +''' diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py new file mode 100644 index 0000000..128b0fe --- /dev/null +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -0,0 +1,3191 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017, Eike Frost +# BSD 2-Clause license (see LICENSES/BSD-2-Clause.txt) +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +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 +from ansible.module_utils.six.moves.urllib.error import HTTPError +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}" +URL_CLIENTS = "{url}/admin/realms/{realm}/clients" + +URL_CLIENT_ROLES = "{url}/admin/realms/{realm}/clients/{id}/roles" +URL_CLIENT_ROLE = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}" +URL_CLIENT_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}/composites" + +URL_CLIENT_ROLE_SCOPE_CLIENTS = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/clients/{scopeid}" +URL_CLIENT_ROLE_SCOPE_REALM = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/realm" + +URL_REALM_ROLES = "{url}/admin/realms/{realm}/roles" +URL_REALM_ROLE = "{url}/admin/realms/{realm}/roles/{name}" +URL_REALM_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm" +URL_REALM_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm/available" +URL_REALM_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm/composite" +URL_REALM_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/roles/{name}/composites" + +URL_ROLES_BY_ID = "{url}/admin/realms/{realm}/roles-by-id/{id}" +URL_ROLES_BY_ID_COMPOSITES_CLIENTS = "{url}/admin/realms/{realm}/roles-by-id/{id}/composites/clients/{cid}" +URL_ROLES_BY_ID_COMPOSITES = "{url}/admin/realms/{realm}/roles-by-id/{id}/composites" + +URL_CLIENTTEMPLATE = "{url}/admin/realms/{realm}/client-templates/{id}" +URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates" +URL_GROUPS = "{url}/admin/realms/{realm}/groups" +URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}" +URL_GROUP_CHILDREN = "{url}/admin/realms/{realm}/groups/{groupid}/children" + +URL_CLIENTSCOPES = "{url}/admin/realms/{realm}/client-scopes" +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" +URL_AUTHENTICATION_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{id}" +URL_AUTHENTICATION_FLOW_COPY = "{url}/admin/realms/{realm}/authentication/flows/{copyfrom}/copy" +URL_AUTHENTICATION_FLOW_EXECUTIONS = "{url}/admin/realms/{realm}/authentication/flows/{flowalias}/executions" +URL_AUTHENTICATION_FLOW_EXECUTIONS_EXECUTION = "{url}/admin/realms/{realm}/authentication/flows/{flowalias}/executions/execution" +URL_AUTHENTICATION_FLOW_EXECUTIONS_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{flowalias}/executions/flow" +URL_AUTHENTICATION_EXECUTION_CONFIG = "{url}/admin/realms/{realm}/authentication/executions/{id}/config" +URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY = "{url}/admin/realms/{realm}/authentication/executions/{id}/raise-priority" +URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY = "{url}/admin/realms/{realm}/authentication/executions/{id}/lower-priority" +URL_AUTHENTICATION_CONFIG = "{url}/admin/realms/{realm}/authentication/config/{id}" +URL_AUTHENTICATION_REGISTER_REQUIRED_ACTION = "{url}/admin/realms/{realm}/authentication/register-required-action" +URL_AUTHENTICATION_REQUIRED_ACTIONS = "{url}/admin/realms/{realm}/authentication/required-actions" +URL_AUTHENTICATION_REQUIRED_ACTIONS_ALIAS = "{url}/admin/realms/{realm}/authentication/required-actions/{alias}" + +URL_IDENTITY_PROVIDERS = "{url}/admin/realms/{realm}/identity-provider/instances" +URL_IDENTITY_PROVIDER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}" +URL_IDENTITY_PROVIDER_MAPPERS = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers" +URL_IDENTITY_PROVIDER_MAPPER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers/{id}" + +URL_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(): + """ + Returns argument_spec of options common to keycloak_*-modules + + :return: argument_spec dict + """ + return dict( + auth_keycloak_url=dict(type='str', aliases=['url'], required=True, no_log=False), + auth_client_id=dict(type='str', default='admin-cli'), + auth_realm=dict(type='str'), + auth_client_secret=dict(type='str', default=None, no_log=True), + auth_username=dict(type='str', aliases=['username']), + auth_password=dict(type='str', aliases=['password'], no_log=True), + validate_certs=dict(type='bool', default=True), + connection_timeout=dict(type='int', default=10), + token=dict(type='str', no_log=True), + http_agent=dict(type='str', default='Ansible'), + ) + + +def camel(words): + return words.split('_')[0] + ''.join(x.capitalize() or '_' for x in words.split('_')[1:]) + + +class KeycloakError(Exception): + pass + + +def get_token(module_params): + """ Obtains connection header with token for the authentication, + token already given or obtained from credentials + :param module_params: parameters of the module + :return: connection header + """ + token = module_params.get('token') + base_url = module_params.get('auth_keycloak_url') + http_agent = module_params.get('http_agent') + + if not base_url.lower().startswith(('http', 'https')): + raise KeycloakError("auth_url '%s' should either start with 'http' or 'https'." % base_url) + + if token is None: + base_url = module_params.get('auth_keycloak_url') + validate_certs = module_params.get('validate_certs') + auth_realm = module_params.get('auth_realm') + client_id = module_params.get('auth_client_id') + auth_username = module_params.get('auth_username') + auth_password = module_params.get('auth_password') + client_secret = module_params.get('auth_client_secret') + connection_timeout = module_params.get('connection_timeout') + auth_url = URL_TOKEN.format(url=base_url, realm=auth_realm) + temp_payload = { + 'grant_type': 'password', + 'client_id': client_id, + 'client_secret': client_secret, + 'username': auth_username, + 'password': auth_password, + } + # Remove empty items, for instance missing client_secret + payload = {k: v for k, v in temp_payload.items() if v is not None} + try: + r = json.loads(to_native(open_url(auth_url, method='POST', + validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout, + data=urlencode(payload)).read())) + except ValueError as e: + raise KeycloakError( + 'API returned invalid JSON when trying to obtain access token from %s: %s' + % (auth_url, str(e))) + except Exception as e: + raise KeycloakError('Could not obtain access token from %s: %s' + % (auth_url, str(e))) + + try: + token = r['access_token'] + except KeyError: + raise KeycloakError( + 'Could not obtain access token from %s' % auth_url) + return { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + } + + +def is_struct_included(struct1, struct2, exclude=None): + """ + This function compare if the first parameter structure is included in the second. + The function use every elements of struct1 and validates they are present in the struct2 structure. + The two structure does not need to be equals for that function to return true. + Each elements are compared recursively. + :param struct1: + type: + dict for the initial call, can be dict, list, bool, int or str for recursive calls + description: + reference structure + :param struct2: + type: + dict for the initial call, can be dict, list, bool, int or str for recursive calls + description: + structure to compare with first parameter. + :param exclude: + type: + list + description: + Key to exclude from the comparison. + default: None + :return: + type: + bool + description: + Return True if all element of dict 1 are present in dict 2, return false otherwise. + """ + if isinstance(struct1, list) and isinstance(struct2, list): + if not struct1 and not struct2: + return True + for item1 in struct1: + if isinstance(item1, (list, dict)): + for item2 in struct2: + 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 + except KeyError: + return False + return True + elif isinstance(struct1, bool) and isinstance(struct2, bool): + return struct1 == struct2 + else: + return to_text(struct1, 'utf-8') == to_text(struct2, 'utf-8') + + +class KeycloakAPI(object): + """ Keycloak API access; Keycloak uses OAuth 2.0 to protect its API, an access token for which + is obtained through OpenID connect + """ + def __init__(self, module, connection_header): + self.module = module + self.baseurl = self.module.params.get('auth_keycloak_url') + self.validate_certs = self.module.params.get('validate_certs') + self.connection_timeout = self.module.params.get('connection_timeout') + self.restheaders = connection_header + self.http_agent = self.module.params.get('http_agent') + + def get_realm_info_by_id(self, realm='master'): + """ Obtain realm public info by id + + :param realm: realm id + :return: dict of real, representation or None if none matching exist + """ + realm_info_url = URL_REALM_INFO.format(url=self.baseurl, realm=realm) + + try: + return json.loads(to_native(open_url(realm_info_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + 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()) + 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()) + 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_by_id(self, realm='master'): + """ Obtain realm representation by id + + :param realm: realm id + :return: dict of real, representation or None if none matching exist + """ + realm_url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return json.loads(to_native(open_url(realm_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + 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()) + except Exception as e: + self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + + def update_realm(self, realmrep, realm="master"): + """ Update an existing realm + :param realmrep: corresponding (partial/full) realm representation with updates + :param realm: realm to be updated in Keycloak + :return: HTTPResponse object on success + """ + realm_url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return 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.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 + :param realmrep: Realm representation of realm to be created. + :return: HTTPResponse object on success + """ + realm_url = URL_REALMS.format(url=self.baseurl) + + try: + return open_url(realm_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(realmrep), validate_certs=self.validate_certs) + except Exception as e: + 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 + + :param realm: realm to be deleted + :return: HTTPResponse object on success + """ + realm_url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return open_url(realm_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + 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 + + :param realm: realm to be queried + :param filter: if defined, only the client with clientId specified in the filter is returned + :return: list of dicts of client representations + """ + clientlist_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) + if filter is not None: + clientlist_url += '?clientId=%s' % filter + + try: + return json.loads(to_native(open_url(clientlist_url, http_agent=self.http_agent, method='GET', headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' + % (realm, str(e))) + except Exception as e: + 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'): + """ Get client representation by clientId + :param client_id: The clientId to be queried + :param realm: realm from which to obtain the client representation + :return: dict with a client representation or None if none matching exist + """ + r = self.get_clients(realm=realm, filter=client_id) + if len(r) > 0: + return r[0] + else: + return None + + def get_client_by_id(self, id, realm='master'): + """ Obtain client representation by id + + :param id: id (not clientId) of client to be queried + :param realm: client from this realm + :return: dict of client representation or None if none matching exist + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + return json.loads(to_native(open_url(client_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + 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' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain client %s for realm %s: %s' + % (id, realm, str(e))) + + def get_client_id(self, client_id, realm='master'): + """ Obtain id of client by client_id + + :param client_id: client_id of client to be queried + :param realm: client template from this realm + :return: id of client (usually a UUID) + """ + result = self.get_client_by_clientid(client_id, realm) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def update_client(self, id, clientrep, realm="master"): + """ Update an existing client + :param id: id (not clientId) of client to be updated in Keycloak + :param clientrep: corresponding (partial/full) client representation with updates + :param realm: realm the client is in + :return: HTTPResponse object on success + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + return 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.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"): + """ Create a client in keycloak + :param clientrep: Client representation of client to be created. Must at least contain field clientId. + :param realm: realm for client to be created. + :return: HTTPResponse object on success + """ + client_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) + + try: + return 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.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"): + """ Delete a client from Keycloak + + :param id: id (not clientId) of client to be deleted + :param realm: realm of client to be deleted + :return: HTTPResponse object on success + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + return 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.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"): + """ Fetch the roles of the a client on the Keycloak server. + + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + client_roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(client_roles_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch rolemappings for client %s in realm %s: %s" + % (cid, realm, str(e))) + + def get_client_role_id_by_name(self, cid, name, realm="master"): + """ Get the role ID of a client. + + :param cid: ID of the client from which to obtain the rolemappings. + :param name: Name of the role. + :param realm: Realm from which to obtain the rolemappings. + :return: The ID of the role, None if not found. + """ + rolemappings = self.get_client_roles_by_id(cid, realm=realm) + for role in rolemappings: + if name == role['name']: + return role['id'] + return None + + def get_client_group_rolemapping_by_id(self, gid, cid, rid, realm='master'): + """ Obtain client representation by id + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param rid: ID of the role. + :param realm: client from this realm + :return: dict of rolemapping representation or None if none matching exist + """ + rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + rolemappings = json.loads(to_native(open_url(rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + for role in rolemappings: + if rid == role['id']: + return role + except Exception as e: + self.fail_open_url(e, msg="Could not fetch rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + return None + + def get_client_group_available_rolemappings(self, gid, cid, realm="master"): + """ Fetch the available role of a client in a specified group on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + return json.loads(to_native(open_url(available_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + + def get_client_group_composite_rolemappings(self, gid, cid, realm="master"): + """ Fetch the composite role of a client in a specified group on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + composite_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + return json.loads(to_native(open_url(composite_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + + def get_role_by_id(self, rid, realm="master"): + """ Fetch a role by its id on the Keycloak server. + + :param rid: ID of the role. + :param realm: Realm from which to obtain the rolemappings. + :return: The role. + """ + client_roles_url = URL_ROLES_BY_ID.format(url=self.baseurl, realm=realm, id=rid) + try: + return json.loads(to_native(open_url(client_roles_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch role for id %s in realm %s: %s" + % (rid, realm, str(e))) + + def get_client_roles_by_id_composite_rolemappings(self, rid, cid, realm="master"): + """ Fetch a role by its id on the Keycloak server. + + :param rid: ID of the composite role. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The role. + """ + client_roles_url = URL_ROLES_BY_ID_COMPOSITES_CLIENTS.format(url=self.baseurl, realm=realm, id=rid, cid=cid) + try: + return json.loads(to_native(open_url(client_roles_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch role for id %s and cid %s in realm %s: %s" + % (rid, cid, realm, str(e))) + + def add_client_roles_by_id_composite_rolemapping(self, rid, roles_rep, realm="master"): + """ Assign roles to composite role + + :param rid: ID of the composite role. + :param roles_rep: Representation of the roles to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + available_rolemappings_url = URL_ROLES_BY_ID_COMPOSITES.format(url=self.baseurl, realm=realm, id=rid) + try: + 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.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 group on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + 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.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"): + """ Delete the rolemapping of a client in a specified group on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + 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.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'): + """ Obtain client representation by id + + :param uid: ID of the user from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param rid: ID of the role. + :param realm: client from this realm + :return: dict of rolemapping representation or None if none matching exist + """ + rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid, client=cid) + try: + rolemappings = json.loads(to_native(open_url(rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + for role in rolemappings: + if rid == role['id']: + return role + except Exception as e: + self.fail_open_url(e, msg="Could not fetch rolemappings for client %s and user %s, realm %s: %s" + % (cid, uid, realm, str(e))) + return None + + def get_client_user_available_rolemappings(self, uid, cid, realm="master"): + """ Fetch the available role of a client for a specified user on the Keycloak server. + + :param uid: ID of the user from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The effective rollemappings of specified client and user of the realm (default "master"). + """ + available_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=uid, client=cid) + try: + return json.loads(to_native(open_url(available_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch effective rolemappings for client %s and user %s, realm %s: %s" + % (cid, uid, realm, str(e))) + + def get_client_user_composite_rolemappings(self, uid, cid, realm="master"): + """ Fetch the composite role of a client for a specified user on the Keycloak server. + + :param uid: ID of the user from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + composite_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=uid, client=cid) + try: + return json.loads(to_native(open_url(composite_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch available rolemappings for user %s of realm %s: %s" + % (uid, realm, str(e))) + + def get_realm_user_rolemapping_by_id(self, uid, rid, realm='master'): + """ Obtain role representation by id + + :param uid: ID of the user from which to obtain the rolemappings. + :param rid: ID of the role. + :param realm: client from this realm + :return: dict of rolemapping representation or None if none matching exist + """ + rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) + try: + rolemappings = json.loads(to_native(open_url(rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + for role in rolemappings: + if rid == role['id']: + return role + except Exception as e: + self.fail_open_url(e, msg="Could not fetch rolemappings for user %s, realm %s: %s" + % (uid, realm, str(e))) + return None + + def get_realm_user_available_rolemappings(self, uid, realm="master"): + """ Fetch the available role of a realm for a specified user on the Keycloak server. + + :param uid: ID of the user from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + available_rolemappings_url = URL_REALM_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=uid) + try: + return json.loads(to_native(open_url(available_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch available rolemappings for user %s of realm %s: %s" + % (uid, realm, str(e))) + + def get_realm_user_composite_rolemappings(self, uid, realm="master"): + """ Fetch the composite role of a realm for a specified user on the Keycloak server. + + :param uid: ID of the user from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The effective rollemappings of specified client and user of the realm (default "master"). + """ + composite_rolemappings_url = URL_REALM_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=uid) + try: + return json.loads(to_native(open_url(composite_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg="Could not fetch effective rolemappings for user %s, realm %s: %s" + % (uid, realm, str(e))) + + def get_user_by_username(self, username, realm="master"): + """ Fetch a keycloak user within a realm based on its username. + + If the user does not exist, None is returned. + :param username: Username of the user to fetch. + :param realm: Realm in which the user resides; default 'master' + """ + users_url = URL_USERS.format(url=self.baseurl, realm=realm) + users_url += '?username=%s&exact=true' % username + try: + userrep = None + users = json.loads(to_native(open_url(users_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + 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.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"): + """ Fetch a keycloak service account user within a realm based on its client_id. + + If the user does not exist, None is returned. + :param client_id: clientId of the service account user to fetch. + :param realm: Realm in which the user resides; default 'master' + """ + cid = self.get_client_id(client_id, realm=realm) + + service_account_user_url = URL_CLIENT_SERVICE_ACCOUNT_USER.format(url=self.baseurl, realm=realm, id=cid) + try: + return 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.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"): + """ Assign a realm or client role to a specified user on the Keycloak server. + + :param uid: ID of the user roles are assigned to. + :param cid: ID of the client from which to obtain the rolemappings. If empty, roles are from the realm + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + if cid is None: + user_realm_rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) + try: + 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.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) + try: + open_url(user_client_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.fail_open_url(e, msg="Could not map roles to userId %s for client %s, realm %s and roles %s: %s" + % (cid, uid, realm, json.dumps(role_rep), str(e))) + + def delete_user_rolemapping(self, uid, cid, role_rep, realm="master"): + """ Delete the rolemapping of a client in a specified user on the Keycloak server. + + :param uid: ID of the user from which to remove the rolemappings. + :param cid: ID of the client from which to remove the rolemappings. + :param role_rep: Representation of the role to remove from rolemappings. + :param realm: Realm from which to remove the rolemappings. + :return: None. + """ + if cid is None: + user_realm_rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) + try: + 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.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) + try: + open_url(user_client_rolemappings_url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.fail_open_url(e, msg="Could not remove roles %s for client %s from userId %s, realm %s: %s" + % (json.dumps(role_rep), cid, uid, realm, str(e))) + + def get_client_templates(self, realm='master'): + """ Obtains client template representations for client templates in a realm + + :param realm: realm to be queried + :return: list of dicts of client representations + """ + url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) + + try: + return json.loads(to_native(open_url(url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of client templates for realm %s: %s' + % (realm, str(e))) + except Exception as e: + 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'): + """ Obtain client template representation by id + + :param id: id (not name) of client template to be queried + :param realm: client template from this realm + :return: dict of client template representation or None if none matching exist + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, id=id, realm=realm) + + try: + return json.loads(to_native(open_url(url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client templates %s for realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + 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'): + """ Obtain client template representation by name + + :param name: name of client template to be queried + :param realm: client template from this realm + :return: dict of client template representation or None if none matching exist + """ + result = self.get_client_templates(realm) + if isinstance(result, list): + result = [x for x in result if x['name'] == name] + if len(result) > 0: + return result[0] + return None + + def get_client_template_id(self, name, realm='master'): + """ Obtain client template id by name + + :param name: name of client template to be queried + :param realm: client template from this realm + :return: client template id (usually a UUID) + """ + result = self.get_client_template_by_name(name, realm) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def update_client_template(self, id, clienttrep, realm="master"): + """ Update an existing client template + :param id: id (not name) of client template to be updated in Keycloak + :param clienttrep: corresponding (partial/full) client template representation with updates + :param realm: realm the client template is in + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) + + try: + return 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.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"): + """ Create a client in keycloak + :param clienttrep: Client template representation of client template to be created. Must at least contain field name + :param realm: realm for client template to be created in + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) + + try: + return 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.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"): + """ Delete a client template from Keycloak + + :param id: id (not name) of client to be deleted + :param realm: realm of client template to be deleted + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) + + try: + return 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 client template %s in realm %s: %s' + % (id, realm, str(e))) + + def get_clientscopes(self, realm="master"): + """ Fetch the name and ID of all clientscopes on the Keycloak server. + + To fetch the full data of the group, make a subsequent call to + get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return. + + :param realm: Realm in which the clientscope resides; default 'master'. + :return The clientscopes of this realm (default "master") + """ + clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(clientscopes_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + 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"): + """ Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID. + + If the clientscope does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + :param cid: UUID of the clientscope to be returned + :param realm: Realm in which the clientscope resides; default 'master'. + """ + clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(clientscope_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + 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" + % (cid, realm, str(e))) + + def get_clientscope_by_name(self, name, realm="master"): + """ Fetch a keycloak clientscope within a realm based on its name. + + The Keycloak API does not allow filtering of the clientscopes resource by name. + As a result, this method first retrieves the entire list of clientscopes - name and ID - + then performs a second query to fetch the group. + + If the clientscope does not exist, None is returned. + :param name: Name of the clientscope to fetch. + :param realm: Realm in which the clientscope resides; default 'master' + """ + try: + all_clientscopes = self.get_clientscopes(realm=realm) + + for clientscope in all_clientscopes: + if clientscope['name'] == name: + return self.get_clientscope_by_clientscopeid(clientscope['id'], realm=realm) + + return None + + except Exception as e: + self.module.fail_json(msg="Could not fetch clientscope %s in realm %s: %s" + % (name, realm, str(e))) + + def create_clientscope(self, clientscoperep, realm="master"): + """ Create a Keycloak clientscope. + + :param clientscoperep: a ClientScopeRepresentation of the clientscope to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm) + try: + return open_url(clientscopes_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(clientscoperep), validate_certs=self.validate_certs) + except Exception as e: + 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"): + """ Update an existing clientscope. + + :param grouprep: A GroupRepresentation of the updated group. + :return HTTPResponse object on success + """ + clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=clientscoperep['id']) + + try: + return open_url(clientscope_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(clientscoperep), validate_certs=self.validate_certs) + + except Exception as e: + 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"): + """ Delete a clientscope. One of name or cid must be provided. + + Providing the clientscope ID is preferred as it avoids a second lookup to + convert a clientscope name to an ID. + + :param name: The name of the clientscope. A lookup will be performed to retrieve the clientscope ID. + :param cid: The ID of the clientscope (preferred to name). + :param realm: The realm in which this group resides, default "master". + """ + + if cid is None and name is None: + # prefer an exception since this is almost certainly a programming error in the module itself. + raise Exception("Unable to delete group - one of group ID or name must be provided.") + + # only lookup the name if cid isn't provided. + # in the case that both are provided, prefer the ID, since it's one + # less lookup. + if cid is None and name is not None: + for clientscope in self.get_clientscopes(realm=realm): + if clientscope['name'] == name: + cid = clientscope['id'] + break + + # if the group doesn't exist - no problem, nothing to delete. + if cid is None: + return None + + # should have a good cid by here. + clientscope_url = URL_CLIENTSCOPE.format(realm=realm, id=cid, url=self.baseurl) + try: + return open_url(clientscope_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + + except Exception as 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. + + To fetch the full data of the group, make a subsequent call to + get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return. + + :param cid: id of clientscope (not name). + :param realm: Realm in which the clientscope resides; default 'master'. + :return The protocolmappers of this realm (default "master") + """ + protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(id=cid, url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(protocolmappers_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + 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"): + """ Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID. + + If the clientscope does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + + :param cid: UUID of the protocolmapper to be returned + :param cid: UUID of the clientscope to be returned + :param realm: Realm in which the clientscope resides; default 'master'. + """ + protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=pid) + try: + return json.loads(to_native(open_url(protocolmapper_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + 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" + % (cid, realm, str(e))) + + def get_clientscope_protocolmapper_by_name(self, cid, name, realm="master"): + """ Fetch a keycloak clientscope within a realm based on its name. + + The Keycloak API does not allow filtering of the clientscopes resource by name. + As a result, this method first retrieves the entire list of clientscopes - name and ID - + then performs a second query to fetch the group. + + If the clientscope does not exist, None is returned. + :param cid: Id of the clientscope (not name). + :param name: Name of the protocolmapper to fetch. + :param realm: Realm in which the clientscope resides; default 'master' + """ + try: + all_protocolmappers = self.get_clientscope_protocolmappers(cid, realm=realm) + + for protocolmapper in all_protocolmappers: + if protocolmapper['name'] == name: + return self.get_clientscope_protocolmapper_by_protocolmapperid(protocolmapper['id'], cid, realm=realm) + + return None + + except Exception as e: + self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" + % (name, realm, str(e))) + + def create_clientscope_protocolmapper(self, cid, mapper_rep, realm="master"): + """ Create a Keycloak clientscope protocolmapper. + + :param cid: Id of the clientscope. + :param mapper_rep: a ProtocolMapperRepresentation of the protocolmapper to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(url=self.baseurl, id=cid, realm=realm) + try: + return open_url(protocolmappers_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(mapper_rep), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg="Could not create protocolmapper %s in realm %s: %s" + % (mapper_rep['name'], realm, str(e))) + + def update_clientscope_protocolmappers(self, cid, mapper_rep, realm="master"): + """ Update an existing clientscope. + + :param cid: Id of the clientscope. + :param mapper_rep: A ProtocolMapperRepresentation of the updated protocolmapper. + :return HTTPResponse object on success + """ + protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=mapper_rep['id']) + + try: + return open_url(protocolmapper_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(mapper_rep), validate_certs=self.validate_certs) + + except Exception as e: + self.fail_open_url(e, msg='Could not update protocolmappers for clientscope %s in realm %s: %s' + % (mapper_rep, realm, str(e))) + + 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 + + :param id: id (not clientId) of client to be queried + :param realm: client from this realm + :return: dict of credential representation + """ + clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id) + + try: + return 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.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' + % (id, realm, str(e))) + + def get_clientsecret(self, id, realm="master"): + """ Obtain client secret by id + + :param id: id (not clientId) of client to be queried + :param realm: client from this realm + :return: dict of credential representation + """ + clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id) + + try: + return 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.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' + % (id, realm, str(e))) + + def get_groups(self, realm="master"): + """ Fetch the name and ID of all groups on the Keycloak server. + + To fetch the full data of the group, make a subsequent call to + get_group_by_groupid, passing in the ID of the group you wish to return. + + :param realm: Return the groups of this realm (default "master"). + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(groups_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + 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"): + """ Fetch a keycloak group from the provided realm using the group's unique ID. + + If the group does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + :param gid: UUID of the group to be returned + :param realm: Realm in which the group resides; default 'master'. + """ + groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) + try: + return json.loads(to_native(open_url(groups_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except HTTPError as e: + if e.code == 404: + return None + else: + 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" + % (gid, realm, str(e))) + + def get_group_by_name(self, name, realm="master", parents=None): + """ Fetch a keycloak group within a realm based on its name. + + The Keycloak API does not allow filtering of the Groups resource by name. + As a result, this method first retrieves the entire list of groups - name and ID - + then performs a second query to fetch the group. + + If the group does not exist, None is returned. + :param name: Name of the group to fetch. + :param realm: Realm in which the group resides; default 'master' + :param parents: Optional list of parents when group to look for is a subgroup + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + if parents: + parent = self.get_subgroup_direct_parent(parents, realm) + + if not parent: + return None + + all_groups = parent['subGroups'] + else: + all_groups = self.get_groups(realm=realm) + + for group in all_groups: + if group['name'] == name: + return self.get_group_by_groupid(group['id'], realm=realm) + + return None + + except Exception as e: + self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" + % (name, realm, str(e))) + + def _get_normed_group_parent(self, parent): + """ Converts parent dict information into a more easy to use form. + + :param parent: parent describing dict + """ + if parent['id']: + return (parent['id'], True) + + return (parent['name'], False) + + def get_subgroup_by_chain(self, name_chain, realm="master"): + """ Access a subgroup API object by walking down a given name/id chain. + + Groups can be given either as by name or by ID, the first element + must either be a toplvl group or given as ID, all parents must exist. + + If the group cannot be found, None is returned. + :param name_chain: Topdown ordered list of subgroup parent (ids or names) + its own name at the end + :param realm: Realm in which the group resides; default 'master' + """ + cp = name_chain[0] + + # for 1st parent in chain we must query the server + cp, is_id = self._get_normed_group_parent(cp) + + if is_id: + tmp = self.get_group_by_groupid(cp, realm=realm) + else: + # given as name, assume toplvl group + tmp = self.get_group_by_name(cp, realm=realm) + + if not tmp: + return None + + for p in name_chain[1:]: + for sg in tmp['subGroups']: + pv, is_id = self._get_normed_group_parent(p) + + if is_id: + cmpkey = "id" + else: + cmpkey = "name" + + if pv == sg[cmpkey]: + tmp = sg + break + + if not tmp: + return None + + return tmp + + def get_subgroup_direct_parent(self, parents, realm="master", children_to_resolve=None): + """ Get keycloak direct parent group API object for a given chain of parents. + + To successfully work the API for subgroups we actually don't need + to "walk the whole tree" for nested groups but only need to know + the ID for the direct predecessor of current subgroup. This + method will guarantee us this information getting there with + as minimal work as possible. + + Note that given parent list can and might be incomplete at the + upper levels as long as it starts with an ID instead of a name + + If the group does not exist, None is returned. + :param parents: Topdown ordered list of subgroup parents + :param realm: Realm in which the group resides; default 'master' + """ + if children_to_resolve is None: + # start recursion by reversing parents (in optimal cases + # we dont need to walk the whole tree upwarts) + parents = list(reversed(parents)) + children_to_resolve = [] + + if not parents: + # walk complete parents list to the top, all names, no id's, + # try to resolve it assuming list is complete and 1st + # element is a toplvl group + return self.get_subgroup_by_chain(list(reversed(children_to_resolve)), realm=realm) + + cp = parents[0] + unused, is_id = self._get_normed_group_parent(cp) + + if is_id: + # current parent is given as ID, we can stop walking + # upwards searching for an entry point + return self.get_subgroup_by_chain([cp] + list(reversed(children_to_resolve)), realm=realm) + else: + # current parent is given as name, it must be resolved + # later, try next parent (recurse) + children_to_resolve.append(cp) + return self.get_subgroup_direct_parent( + parents[1:], + realm=realm, children_to_resolve=children_to_resolve + ) + + def create_group(self, grouprep, realm="master"): + """ Create a Keycloak group. + + :param grouprep: a GroupRepresentation of the group to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + return open_url(groups_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(grouprep), validate_certs=self.validate_certs) + except Exception as e: + 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"): + """ Create a Keycloak subgroup. + + :param parents: list of one or more parent groups + :param grouprep: a GroupRepresentation of the group to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + parent_id = "---UNDETERMINED---" + try: + parent_id = self.get_subgroup_direct_parent(parents, realm) + + if not parent_id: + raise Exception( + "Could not determine subgroup parent ID for given" + " parent chain {0}. Assure that all parents exist" + " already and the list is complete and properly" + " ordered, starts with an ID or starts at the" + " top level".format(parents) + ) + + parent_id = parent_id["id"] + url = URL_GROUP_CHILDREN.format(url=self.baseurl, realm=realm, groupid=parent_id) + return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(grouprep), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg="Could not create subgroup %s for parent group %s in realm %s: %s" + % (grouprep['name'], parent_id, realm, str(e))) + + def update_group(self, grouprep, realm="master"): + """ Update an existing group. + + :param grouprep: A GroupRepresentation of the updated group. + :return HTTPResponse object on success + """ + group_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=grouprep['id']) + + try: + return 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.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"): + """ Delete a group. One of name or groupid must be provided. + + Providing the group ID is preferred as it avoids a second lookup to + convert a group name to an ID. + + :param name: The name of the group. A lookup will be performed to retrieve the group ID. + :param groupid: The ID of the group (preferred to name). + :param realm: The realm in which this group resides, default "master". + """ + + if groupid is None and name is None: + # prefer an exception since this is almost certainly a programming error in the module itself. + raise Exception("Unable to delete group - one of group ID or name must be provided.") + + # only lookup the name if groupid isn't provided. + # in the case that both are provided, prefer the ID, since it's one + # less lookup. + if groupid is None and name is not None: + for group in self.get_groups(realm=realm): + if group['name'] == name: + groupid = group['id'] + break + + # if the group doesn't exist - no problem, nothing to delete. + if groupid is None: + return None + + # should have a good groupid by here. + group_url = URL_GROUP.format(realm=realm, groupid=groupid, url=self.baseurl) + try: + return 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.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 + + :param realm: realm to be queried + :return: list of dicts of role representations + """ + rolelist_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(rolelist_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of roles for realm %s: %s' + % (realm, str(e))) + except Exception as e: + 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'): + """ Fetch a keycloak role from the provided realm using the role's name. + + If the role does not exist, None is returned. + :param name: Name of the role to fetch. + :param realm: Realm in which the role resides; default 'master'. + """ + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name, safe='')) + try: + return 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())) + except HTTPError as e: + if e.code == 404: + return None + else: + 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' + % (name, realm, str(e))) + + def create_realm_role(self, rolerep, realm='master'): + """ Create a Keycloak realm role. + + :param rolerep: a RoleRepresentation of the role to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + roles_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) + try: + if "composites" in rolerep: + keycloak_compatible_composites = self.convert_role_composites(rolerep["composites"]) + rolerep["composites"] = keycloak_compatible_composites + return 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.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'): + """ Update an existing realm role. + + :param rolerep: A RoleRepresentation of the updated role. + :return HTTPResponse object on success + """ + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(rolerep['name']), safe='') + try: + composites = None + if "composites" in rolerep: + composites = copy.deepcopy(rolerep["composites"]) + del rolerep["composites"] + role_response = 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.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, 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.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'): + """ Obtains role representations for client roles in a specific client + + :param clientid: Client id to be queried + :param realm: Realm to be queried + :return: List of dicts of role representations + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + rolelist_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(rolelist_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of roles for client %s in realm %s: %s' + % (clientid, realm, str(e))) + except Exception as e: + self.fail_open_url(e, msg='Could not obtain list of roles for client %s in realm %s: %s' + % (clientid, realm, str(e))) + + def get_client_role(self, name, clientid, realm='master'): + """ Fetch a keycloak client role from the provided realm using the role's name. + + :param name: Name of the role to fetch. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + :return: Dict of role representation + If the role does not exist, None is returned. + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name, safe='')) + 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())) + except HTTPError as e: + if e.code == 404: + return None + else: + self.fail_open_url(e, msg='Could not fetch role %s in client %s of realm %s: %s' + % (name, clientid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch role %s for client %s in realm %s: %s' + % (name, clientid, realm, str(e))) + + def create_client_role(self, rolerep, clientid, realm='master'): + """ Create a Keycloak client role. + + :param rolerep: a RoleRepresentation of the role to be created. Must contain at minimum the field name. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + :return: HTTPResponse object on success + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (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.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. + + :param rolerep: A RoleRepresentation of the updated role. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + :return HTTPResponse object on success + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep['name'], safe='')) + try: + composites = None + if "composites" in rolerep: + composites = copy.deepcopy(rolerep["composites"]) + del rolerep['composites'] + update_role_response = open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + if composites is not None: + self.update_role_composites(rolerep=rolerep, clientid=clientid, composites=composites, realm=realm) + return update_role_response + except Exception as e: + self.fail_open_url(e, msg='Could not update role %s for client %s in realm %s: %s' + % (rolerep['name'], clientid, realm, str(e))) + + def delete_client_role(self, name, clientid, realm="master"): + """ Delete a role. One of name or roleid must be provided. + + :param name: The name of the role. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name, safe='')) + 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.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'): + """ + Get an authentication flow by it's alias + :param alias: Alias of the authentication flow to get. + :param realm: Realm. + :return: Authentication flow representation. + """ + try: + authentication_flow = {} + # Check if the authentication flow exists on the Keycloak serveraders + authentications = json.load(open_url(URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, realm=realm), method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, validate_certs=self.validate_certs)) + for authentication in authentications: + if authentication["alias"] == alias: + authentication_flow = authentication + break + return authentication_flow + except Exception as e: + self.fail_open_url(e, msg="Unable get authentication flow %s: %s" % (alias, str(e))) + + def delete_authentication_flow_by_id(self, id, realm='master'): + """ + Delete an authentication flow from Keycloak + :param id: id of authentication flow to be deleted + :param realm: realm of client to be deleted + :return: HTTPResponse object on success + """ + flow_url = URL_AUTHENTICATION_FLOW.format(url=self.baseurl, realm=realm, id=id) + + try: + return 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.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'): + """ + Create a new authentication flow from a copy of another. + :param config: Representation of the authentication flow to create. + :param realm: Realm. + :return: Representation of the new authentication flow. + """ + try: + new_name = dict( + newName=config["alias"] + ) + open_url( + URL_AUTHENTICATION_FLOW_COPY.format( + url=self.baseurl, + realm=realm, + copyfrom=quote(config["copyFrom"], safe='')), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(new_name), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + flow_list = json.load( + open_url( + URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, + realm=realm), + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs)) + for flow in flow_list: + if flow["alias"] == config["alias"]: + return flow + return None + except Exception as e: + self.fail_open_url(e, msg='Could not copy authentication flow %s in realm %s: %s' + % (config["alias"], realm, str(e))) + + def create_empty_auth_flow(self, config, realm='master'): + """ + Create a new empty authentication flow. + :param config: Representation of the authentication flow to create. + :param realm: Realm. + :return: Representation of the new authentication flow. + """ + try: + new_flow = dict( + alias=config["alias"], + providerId=config["providerId"], + description=config["description"], + topLevel=True + ) + open_url( + URL_AUTHENTICATION_FLOWS.format( + url=self.baseurl, + realm=realm), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(new_flow), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + flow_list = json.load( + open_url( + URL_AUTHENTICATION_FLOWS.format( + url=self.baseurl, + realm=realm), + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs)) + for flow in flow_list: + if flow["alias"] == config["alias"]: + return flow + return None + except Exception as e: + self.fail_open_url(e, msg='Could not create empty authentication flow %s in realm %s: %s' + % (config["alias"], realm, str(e))) + + def update_authentication_executions(self, flowAlias, updatedExec, realm='master'): + """ Update authentication executions + + :param flowAlias: name of the parent flow + :param updatedExec: JSON containing updated execution + :return: HTTPResponse object on success + """ + try: + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS.format( + url=self.baseurl, + realm=realm, + 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))) + + def add_authenticationConfig_to_execution(self, executionId, authenticationConfig, realm='master'): + """ Add autenticatorConfig to the execution + + :param executionId: id of execution + :param authenticationConfig: config to add to the execution + :return: HTTPResponse object on success + """ + try: + open_url( + URL_AUTHENTICATION_EXECUTION_CONFIG.format( + url=self.baseurl, + realm=realm, + id=executionId), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(authenticationConfig), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg="Unable to add authenticationConfig %s: %s" % (executionId, str(e))) + + 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 + :param flowAlias: name of the parent flow + :return: HTTPResponse object on success + """ + try: + newSubFlow = {} + newSubFlow["alias"] = subflowName + newSubFlow["provider"] = "registration-page-form" + newSubFlow["type"] = flowType + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS_FLOW.format( + url=self.baseurl, + realm=realm, + flowalias=quote(flowAlias, safe='')), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(newSubFlow), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg="Unable to create new subflow %s: %s" % (subflowName, str(e))) + + def create_execution(self, execution, flowAlias, realm='master'): + """ Create new execution on the flow + + :param execution: name of execution to create + :param flowAlias: name of the parent flow + :return: HTTPResponse object on success + """ + try: + newExec = {} + newExec["provider"] = execution["providerId"] + newExec["requirement"] = execution["requirement"] + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS_EXECUTION.format( + url=self.baseurl, + realm=realm, + flowalias=quote(flowAlias, safe='')), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + 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: %s" % (flowAlias, execution["providerId"], repr(e))) + + def change_execution_priority(self, executionId, diff, realm='master'): + """ Raise or lower execution priority of diff time + + :param executionId: id of execution to lower priority + :param realm: realm the client is in + :param diff: Integer number, raise of diff time if positive lower of diff time if negative + :return: HTTPResponse object on success + """ + try: + if diff > 0: + for i in range(diff): + open_url( + URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY.format( + url=self.baseurl, + realm=realm, + id=executionId), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + elif diff < 0: + for i in range(-diff): + open_url( + URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY.format( + url=self.baseurl, + realm=realm, + id=executionId), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as 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'): + """ + Get a representation of the executions for an authentication flow. + :param config: Representation of the authentication flow + :param realm: Realm + :return: Representation of the executions + """ + try: + # Get executions created + executions = json.load( + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS.format( + url=self.baseurl, + realm=realm, + flowalias=quote(config["alias"], safe='')), + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs)) + for execution in executions: + if "authenticationConfig" in execution: + execConfigId = execution["authenticationConfig"] + execConfig = json.load( + open_url( + URL_AUTHENTICATION_CONFIG.format( + url=self.baseurl, + realm=realm, + id=execConfigId), + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs)) + execution["authenticationConfig"] = execConfig + return executions + except Exception as e: + self.fail_open_url(e, msg='Could not get executions for authentication flow %s in realm %s: %s' + % (config["alias"], realm, str(e))) + + 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 + :param realm: realm to be queried + :return: list of representations for identity providers + """ + idps_url = URL_IDENTITY_PROVIDERS.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(idps_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of identity providers for realm %s: %s' + % (realm, str(e))) + except Exception as e: + 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'): + """ Fetch identity provider representation from a realm using the idp's alias. + If the identity provider does not exist, None is returned. + :param alias: Alias of the identity provider to fetch. + :param realm: Realm in which the identity provider resides; default 'master'. + """ + idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=alias) + try: + return json.loads(to_native(open_url(idp_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except HTTPError as e: + if e.code == 404: + return None + else: + 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' + % (alias, realm, str(e))) + + def create_identity_provider(self, idprep, realm='master'): + """ Create an identity provider. + :param idprep: Identity provider representation of the idp to be created. + :param realm: Realm in which this identity provider resides, default "master". + :return: HTTPResponse object on success + """ + idps_url = URL_IDENTITY_PROVIDERS.format(url=self.baseurl, realm=realm) + try: + return open_url(idps_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(idprep), validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Could not create identity provider %s in realm %s: %s' + % (idprep['alias'], realm, str(e))) + + def update_identity_provider(self, idprep, realm='master'): + """ Update an existing identity provider. + :param idprep: Identity provider representation of the idp to be updated. + :param realm: Realm in which this identity provider resides, default "master". + :return HTTPResponse object on success + """ + idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=idprep['alias']) + try: + return 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.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'): + """ Delete an identity provider. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + """ + idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=alias) + try: + return open_url(idp_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + 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'): + """ Fetch representations for identity provider mappers + :param alias: Alias of the identity provider. + :param realm: realm to be queried + :return: list of representations for identity provider mappers + """ + mappers_url = URL_IDENTITY_PROVIDER_MAPPERS.format(url=self.baseurl, realm=realm, alias=alias) + try: + return json.loads(to_native(open_url(mappers_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of identity provider mappers for idp %s in realm %s: %s' + % (alias, realm, str(e))) + except Exception as e: + self.fail_open_url(e, msg='Could not obtain list of identity provider mappers for idp %s in realm %s: %s' + % (alias, realm, str(e))) + + def get_identity_provider_mapper(self, mid, alias, realm='master'): + """ Fetch identity provider representation from a realm using the idp's alias. + If the identity provider does not exist, None is returned. + :param mid: Unique ID of the mapper to fetch. + :param alias: Alias of the identity provider. + :param realm: Realm in which the identity provider resides; default 'master'. + """ + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mid) + try: + return json.loads(to_native(open_url(mapper_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except HTTPError as e: + if e.code == 404: + return None + else: + self.fail_open_url(e, msg='Could not fetch mapper %s for identity provider %s in realm %s: %s' + % (mid, alias, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch mapper %s for identity provider %s in realm %s: %s' + % (mid, alias, realm, str(e))) + + def create_identity_provider_mapper(self, mapper, alias, realm='master'): + """ Create an identity provider mapper. + :param mapper: IdentityProviderMapperRepresentation of the mapper to be created. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + :return: HTTPResponse object on success + """ + mappers_url = URL_IDENTITY_PROVIDER_MAPPERS.format(url=self.baseurl, realm=realm, alias=alias) + try: + return 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.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'): + """ Update an existing identity provider. + :param mapper: IdentityProviderMapperRepresentation of the mapper to be updated. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + :return HTTPResponse object on success + """ + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mapper['id']) + try: + return 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.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'): + """ Delete an identity provider. + :param mid: Unique ID of the mapper to delete. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + """ + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mid) + try: + return open_url(mapper_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.fail_open_url(e, msg='Unable to delete mapper %s for identity provider %s in realm %s: %s' + % (mid, alias, realm, str(e))) + + def get_components(self, filter=None, realm='master'): + """ Fetch representations for components in a realm + :param realm: realm to be queried + :param filter: search filter + :return: list of representations for components + """ + comps_url = URL_COMPONENTS.format(url=self.baseurl, realm=realm) + if filter is not None: + comps_url += '?%s' % filter + + try: + return json.loads(to_native(open_url(comps_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of components for realm %s: %s' + % (realm, str(e))) + except Exception as e: + 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'): + """ Fetch component representation from a realm using its cid. + If the component does not exist, None is returned. + :param cid: Unique ID of the component to fetch. + :param realm: Realm in which the component resides; default 'master'. + """ + comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(comp_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except HTTPError as e: + if e.code == 404: + return None + else: + 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' + % (cid, realm, str(e))) + + def create_component(self, comprep, realm='master'): + """ Create an component. + :param comprep: Component representation of the component to be created. + :param realm: Realm in which this component resides, default "master". + :return: Component representation of the created component + """ + comps_url = URL_COMPONENTS.format(url=self.baseurl, realm=realm) + try: + resp = open_url(comps_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(comprep), validate_certs=self.validate_certs) + comp_url = resp.getheader('Location') + if comp_url is None: + self.module.fail_json(msg='Could not create component in realm %s: %s' + % (realm, 'unexpected response')) + return json.loads(to_native(open_url(comp_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg='Could not create component in realm %s: %s' + % (realm, str(e))) + + def update_component(self, comprep, realm='master'): + """ Update an existing component. + :param comprep: Component representation of the component to be updated. + :param realm: Realm in which this component resides, default "master". + :return HTTPResponse object on success + """ + cid = comprep.get('id') + if cid is None: + self.module.fail_json(msg='Cannot update component without id') + comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid) + try: + return 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.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'): + """ Delete an component. + :param cid: Unique ID of the component. + :param realm: Realm in which this component resides, default "master". + """ + comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid) + try: + return open_url(comp_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + 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 new file mode 100644 index 0000000..0afa52b --- /dev/null +++ b/plugins/modules/keycloak_client.py @@ -0,0 +1,1137 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Eike Frost +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_client + +short_description: Allows administration of Keycloak clients via Keycloak API + + +description: + - This module allows the administration of Keycloak clients via the Keycloak REST API. It + requires access to the REST API via OpenID Connect; the user connecting and the client being + used must have the requisite access rights. In a default Keycloak installation, admin-cli + and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + + - The names of module options are snake_cased versions of the camelCase ones found in the + Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html). + Aliases are provided so camelCased versions can be used as well. + + - The Keycloak API does not always sanity check inputs e.g. you can set + SAML-specific settings on an OpenID Connect client for instance and vice versa. Be careful. + If you do not specify a setting, usually a sensible default is chosen. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the client + - On V(present), the client will be created (or updated if it exists already). + - On V(absent), the client will be removed if it exists + choices: ['present', 'absent'] + default: 'present' + type: str + + realm: + description: + - The realm to create the client in. + type: str + default: master + + client_id: + description: + - Client id of client to be worked on. This is usually an alphanumeric name chosen by + you. Either this or O(id) is required. If you specify both, O(id) takes precedence. + This is 'clientId' in the Keycloak REST API. + aliases: + - clientId + type: str + + id: + description: + - Id of client to be worked on. This is usually an UUID. Either this or O(client_id) + is required. If you specify both, this takes precedence. + type: str + + name: + description: + - Name of the client (this is not the same as O(client_id)). + type: str + + description: + description: + - Description of the client in Keycloak. + type: str + + root_url: + description: + - Root URL appended to relative URLs for this client. + This is 'rootUrl' in the Keycloak REST API. + aliases: + - rootUrl + type: str + + admin_url: + description: + - URL to the admin interface of the client. + This is 'adminUrl' in the Keycloak REST API. + aliases: + - adminUrl + type: str + + base_url: + description: + - Default URL to use when the auth server needs to redirect or link back to the client + This is 'baseUrl' in the Keycloak REST API. + aliases: + - baseUrl + type: str + + enabled: + description: + - Is this client enabled or not? + type: bool + + client_authenticator_type: + description: + - How do clients authenticate with the auth server? Either V(client-secret), + V(client-jwt), or V(client-x509) can be chosen. When using V(client-secret), the module parameter + O(secret) can set it, for V(client-jwt), you can use the keys C(use.jwks.url), + C(jwks.url), and C(jwt.credential.certificate) in the O(attributes) module parameter + to configure its behavior. For V(client-x509) you can use the keys C(x509.allow.regex.pattern.comparison) + and C(x509.subjectdn) in the O(attributes) module parameter to configure which certificate(s) to accept. + - This is 'clientAuthenticatorType' in the Keycloak REST API. + choices: ['client-secret', 'client-jwt', 'client-x509'] + aliases: + - clientAuthenticatorType + type: str + + secret: + description: + - When using O(client_authenticator_type=client-secret) (the default), you can + specify a secret here (otherwise one 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). + type: str + + registration_access_token: + description: + - The registration access token provides access for clients to the client registration + service. + This is 'registrationAccessToken' in the Keycloak REST API. + aliases: + - registrationAccessToken + type: str + + default_roles: + description: + - list of default roles for this client. If the client roles referenced do not exist + yet, they will be created. + This is 'defaultRoles' in the Keycloak REST API. + aliases: + - defaultRoles + type: list + elements: str + + redirect_uris: + description: + - Acceptable redirect URIs for this client. + This is 'redirectUris' in the Keycloak REST API. + aliases: + - redirectUris + type: list + elements: str + + web_origins: + description: + - List of allowed CORS origins. + This is 'webOrigins' in the Keycloak REST API. + aliases: + - webOrigins + type: list + elements: str + + not_before: + description: + - Revoke any tokens issued before this date for this client (this is a UNIX timestamp). + This is 'notBefore' in the Keycloak REST API. + type: int + aliases: + - notBefore + + bearer_only: + description: + - The access type of this client is bearer-only. + This is 'bearerOnly' in the Keycloak REST API. + aliases: + - bearerOnly + type: bool + + consent_required: + description: + - If enabled, users have to consent to client access. + This is 'consentRequired' in the Keycloak REST API. + aliases: + - consentRequired + type: bool + + standard_flow_enabled: + description: + - Enable standard flow for this client or not (OpenID connect). + This is 'standardFlowEnabled' in the Keycloak REST API. + aliases: + - standardFlowEnabled + type: bool + + implicit_flow_enabled: + description: + - Enable implicit flow for this client or not (OpenID connect). + This is 'implicitFlowEnabled' in the Keycloak REST API. + aliases: + - implicitFlowEnabled + type: bool + + direct_access_grants_enabled: + description: + - Are direct access grants enabled for this client or not (OpenID connect). + This is 'directAccessGrantsEnabled' in the Keycloak REST API. + aliases: + - directAccessGrantsEnabled + type: bool + + service_accounts_enabled: + description: + - Are service accounts enabled for this client or not (OpenID connect). + This is 'serviceAccountsEnabled' in the Keycloak REST API. + aliases: + - serviceAccountsEnabled + type: bool + + authorization_services_enabled: + description: + - Are authorization services enabled for this client or not (OpenID connect). + This is 'authorizationServicesEnabled' in the Keycloak REST API. + aliases: + - authorizationServicesEnabled + type: bool + + public_client: + description: + - Is the access type for this client public or not. + This is 'publicClient' in the Keycloak REST API. + aliases: + - publicClient + type: bool + + frontchannel_logout: + description: + - Is frontchannel logout enabled for this client or not. + This is 'frontchannelLogout' in the Keycloak REST API. + aliases: + - frontchannelLogout + type: bool + + protocol: + description: + - Type of client. + - At creation only, default value will be V(openid-connect) if O(protocol) is omitted. + - The V(docker-v2) value was added in community.general 8.6.0. + type: str + choices: ['openid-connect', 'saml', 'docker-v2'] + + full_scope_allowed: + description: + - Is the "Full Scope Allowed" feature set for this client or not. + This is 'fullScopeAllowed' in the Keycloak REST API. + aliases: + - fullScopeAllowed + type: bool + + node_re_registration_timeout: + description: + - Cluster node re-registration timeout for this client. + This is 'nodeReRegistrationTimeout' in the Keycloak REST API. + type: int + aliases: + - nodeReRegistrationTimeout + + registered_nodes: + description: + - dict of registered cluster nodes (with C(nodename) as the key and last registration + time as the value). + This is 'registeredNodes' in the Keycloak REST API. + type: dict + aliases: + - registeredNodes + + client_template: + description: + - Client template to use for this client. If it does not exist this field will silently + be dropped. + This is 'clientTemplate' in the Keycloak REST API. + type: str + aliases: + - clientTemplate + + use_template_config: + description: + - Whether or not to use configuration from the O(client_template). + This is 'useTemplateConfig' in the Keycloak REST API. + aliases: + - useTemplateConfig + type: bool + + use_template_scope: + description: + - Whether or not to use scope configuration from the O(client_template). + This is 'useTemplateScope' in the Keycloak REST API. + aliases: + - useTemplateScope + type: bool + + use_template_mappers: + description: + - Whether or not to use mapper configuration from the O(client_template). + This is 'useTemplateMappers' in the Keycloak REST API. + aliases: + - useTemplateMappers + type: bool + + always_display_in_console: + description: + - Whether or not to display this client in account console, even if the + user does not have an active session. + aliases: + - alwaysDisplayInConsole + type: bool + version_added: 4.7.0 + + surrogate_auth_required: + description: + - Whether or not surrogate auth is required. + This is 'surrogateAuthRequired' in the Keycloak REST API. + aliases: + - surrogateAuthRequired + type: bool + + authorization_settings: + description: + - a data structure defining the authorization settings for this client. For reference, + please see the Keycloak API docs at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html#_resourceserverrepresentation). + This is 'authorizationSettings' in the Keycloak REST API. + type: dict + aliases: + - authorizationSettings + + authentication_flow_binding_overrides: + description: + - Override realm authentication flow bindings. + type: dict + suboptions: + browser: + description: + - Flow ID of the browser authentication flow. + - O(authentication_flow_binding_overrides.browser) + and O(authentication_flow_binding_overrides.browser_name) are mutually exclusive. + type: str + + browser_name: + description: + - Flow name of the browser authentication flow. + - O(authentication_flow_binding_overrides.browser) + and O(authentication_flow_binding_overrides.browser_name) are mutually exclusive. + aliases: + - browserName + type: str + version_added: 9.1.0 + + direct_grant: + description: + - Flow ID of the direct grant authentication flow. + - O(authentication_flow_binding_overrides.direct_grant) + and O(authentication_flow_binding_overrides.direct_grant_name) are mutually exclusive. + aliases: + - directGrant + type: str + + direct_grant_name: + description: + - Flow name of the direct grant authentication flow. + - O(authentication_flow_binding_overrides.direct_grant) + and O(authentication_flow_binding_overrides.direct_grant_name) are mutually exclusive. + aliases: + - directGrantName + type: str + version_added: 9.1.0 + aliases: + - authenticationFlowBindingOverrides + version_added: 3.4.0 + + default_client_scopes: + description: + - List of default client scopes. + aliases: + - defaultClientScopes + type: list + elements: str + version_added: 4.7.0 + + optional_client_scopes: + description: + - List of optional client scopes. + aliases: + - optionalClientScopes + type: list + elements: str + version_added: 4.7.0 + + protocol_mappers: + description: + - a list of dicts defining protocol mappers for this client. + This is 'protocolMappers' in the Keycloak REST API. + aliases: + - protocolMappers + type: list + elements: dict + suboptions: + consentRequired: + description: + - Specifies whether a user needs to provide consent to a client for this mapper to be active. + type: bool + + consentText: + description: + - The human-readable name of the consent the user is presented to accept. + type: str + + id: + description: + - Usually a UUID specifying the internal ID of this protocol mapper instance. + type: str + + name: + description: + - The name of this protocol mapper. + type: str + + protocol: + description: + - This specifies for which protocol this protocol mapper is active. + choices: ['openid-connect', 'saml', 'docker-v2'] + type: str + + protocolMapper: + description: + - "The Keycloak-internal name of the type of this protocol-mapper. While an exhaustive list is + impossible to provide since this may be extended through SPIs by the user of Keycloak, + by default Keycloak as of 3.4 ships with at least:" + - V(docker-v2-allow-all-mapper) + - V(oidc-address-mapper) + - V(oidc-full-name-mapper) + - V(oidc-group-membership-mapper) + - V(oidc-hardcoded-claim-mapper) + - V(oidc-hardcoded-role-mapper) + - V(oidc-role-name-mapper) + - V(oidc-script-based-protocol-mapper) + - V(oidc-sha256-pairwise-sub-mapper) + - V(oidc-usermodel-attribute-mapper) + - V(oidc-usermodel-client-role-mapper) + - V(oidc-usermodel-property-mapper) + - V(oidc-usermodel-realm-role-mapper) + - V(oidc-usersessionmodel-note-mapper) + - V(saml-group-membership-mapper) + - V(saml-hardcode-attribute-mapper) + - V(saml-hardcode-role-mapper) + - V(saml-role-list-mapper) + - V(saml-role-name-mapper) + - V(saml-user-attribute-mapper) + - V(saml-user-property-mapper) + - V(saml-user-session-note-mapper) + - An exhaustive list of available mappers on your installation can be obtained on + the admin console by going to Server Info -> Providers and looking under + 'protocol-mapper'. + type: str + + config: + description: + - Dict specifying the configuration options for the protocol mapper; the + contents differ depending on the value of O(protocol_mappers[].protocolMapper) and are not documented + other than by the source of the mappers and its parent class(es). An example is given + below. It is easiest to obtain valid config values by dumping an already-existing + protocol mapper configuration through check-mode in the RV(existing) field. + type: dict + + attributes: + description: + - A dict of further attributes for this client. This can contain various configuration + settings; an example is given in the examples section. While an exhaustive list of + permissible options is not available; possible options as of Keycloak 3.4 are listed below. The Keycloak + API does not validate whether a given option is appropriate for the protocol used; if specified + anyway, Keycloak will simply not use it. + type: dict + suboptions: + saml.authnstatement: + description: + - For SAML clients, boolean specifying whether or not a statement containing method and timestamp + should be included in the login response. + + saml.client.signature: + description: + - For SAML clients, boolean specifying whether a client signature is required and validated. + + saml.encrypt: + description: + - Boolean specifying whether SAML assertions should be encrypted with the client's public key. + + saml.force.post.binding: + description: + - For SAML clients, boolean specifying whether always to use POST binding for responses. + + saml.onetimeuse.condition: + description: + - For SAML clients, boolean specifying whether a OneTimeUse condition should be included in login responses. + + saml.server.signature: + description: + - Boolean specifying whether SAML documents should be signed by the realm. + + saml.server.signature.keyinfo.ext: + description: + - For SAML clients, boolean specifying whether REDIRECT signing key lookup should be optimized through inclusion + of the signing key id in the SAML Extensions element. + + saml.signature.algorithm: + description: + - Signature algorithm used to sign SAML documents. One of V(RSA_SHA256), V(RSA_SHA1), V(RSA_SHA512), or V(DSA_SHA1). + + saml.signing.certificate: + description: + - SAML signing key certificate, base64-encoded. + + saml.signing.private.key: + description: + - SAML signing key private key, base64-encoded. + + saml_assertion_consumer_url_post: + description: + - SAML POST Binding URL for the client's assertion consumer service (login responses). + + saml_assertion_consumer_url_redirect: + description: + - SAML Redirect Binding URL for the client's assertion consumer service (login responses). + + saml_force_name_id_format: + description: + - For SAML clients, Boolean specifying whether to ignore requested NameID subject format and using the configured one instead. + + saml_name_id_format: + description: + - For SAML clients, the NameID format to use (one of V(username), V(email), V(transient), or V(persistent)) + + saml_signature_canonicalization_method: + description: + - SAML signature canonicalization method. This is one of four values, namely + V(http://www.w3.org/2001/10/xml-exc-c14n#) for EXCLUSIVE, + V(http://www.w3.org/2001/10/xml-exc-c14n#WithComments) for EXCLUSIVE_WITH_COMMENTS, + V(http://www.w3.org/TR/2001/REC-xml-c14n-20010315) for INCLUSIVE, and + V(http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments) for INCLUSIVE_WITH_COMMENTS. + + saml_single_logout_service_url_post: + description: + - SAML POST binding url for the client's single logout service. + + saml_single_logout_service_url_redirect: + description: + - SAML redirect binding url for the client's single logout service. + + user.info.response.signature.alg: + description: + - For OpenID-Connect clients, JWA algorithm for signed UserInfo-endpoint responses. One of V(RS256) or V(unsigned). + + request.object.signature.alg: + description: + - For OpenID-Connect clients, JWA algorithm which the client needs to use when sending + OIDC request object. One of V(any), V(none), V(RS256). + + use.jwks.url: + description: + - For OpenID-Connect clients, boolean specifying whether to use a JWKS URL to obtain client + public keys. + + jwks.url: + description: + - For OpenID-Connect clients, URL where client keys in JWK are stored. + + jwt.credential.certificate: + description: + - For OpenID-Connect clients, client certificate for validating JWT issued by + client and signed by its key, base64-encoded. + + x509.subjectdn: + description: + - For OpenID-Connect clients, subject which will be used to authenticate the client. + type: str + version_added: 9.5.0 + + x509.allow.regex.pattern.comparison: + description: + - For OpenID-Connect clients, boolean specifying whether to allow C(x509.subjectdn) as regular expression. + type: bool + version_added: 9.5.0 + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.attributes + +author: + - Eike Frost (@eikef) +''' + +EXAMPLES = ''' +- name: Create or update Keycloak client (minimal example), authentication with credentials + middleware_automation.keycloak.keycloak_client: + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + client_id: test + state: present + delegate_to: localhost + + +- name: Create or update Keycloak client (minimal example), authentication with token + middleware_automation.keycloak.keycloak_client: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + token: TOKEN + client_id: test + state: present + delegate_to: localhost + + +- name: Delete a Keycloak client + middleware_automation.keycloak.keycloak_client: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + client_id: test + state: absent + delegate_to: localhost + + +- name: Create or update a Keycloak client (minimal example), with x509 authentication + middleware_automation.keycloak.keycloak_client: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + state: present + client_id: test + client_authenticator_type: client-x509 + attributes: + x509.subjectdn: "CN=client" + x509.allow.regex.pattern.comparison: false + + +- name: Create or update a Keycloak client (with all the bells and whistles) + middleware_automation.keycloak.keycloak_client: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + realm: master + client_id: test + id: d8b127a3-31f6-44c8-a7e4-4ab9a3e78d95 + name: this_is_a_test + description: Description of this wonderful client + root_url: https://www.example.com/ + admin_url: https://www.example.com/admin_url + base_url: basepath + enabled: true + client_authenticator_type: client-secret + secret: REALLYWELLKEPTSECRET + redirect_uris: + - https://www.example.com/* + - http://localhost:8888/ + web_origins: + - https://www.example.com/* + not_before: 1507825725 + bearer_only: false + consent_required: false + standard_flow_enabled: true + implicit_flow_enabled: false + direct_access_grants_enabled: false + service_accounts_enabled: false + authorization_services_enabled: false + public_client: false + frontchannel_logout: false + protocol: openid-connect + full_scope_allowed: false + node_re_registration_timeout: -1 + client_template: test + use_template_config: false + use_template_scope: false + use_template_mappers: false + always_display_in_console: true + registered_nodes: + node01.example.com: 1507828202 + registration_access_token: eyJWT_TOKEN + surrogate_auth_required: false + default_roles: + - test01 + - test02 + authentication_flow_binding_overrides: + browser: 4c90336b-bf1d-4b87-916d-3677ba4e5fbb + protocol_mappers: + - config: + access.token.claim: true + claim.name: "family_name" + id.token.claim: true + jsonType.label: String + user.attribute: lastName + userinfo.token.claim: true + consentRequired: true + consentText: "${familyName}" + name: family name + protocol: openid-connect + protocolMapper: oidc-usermodel-property-mapper + - config: + attribute.name: Role + attribute.nameformat: Basic + single: false + consentRequired: false + name: role list + protocol: saml + protocolMapper: saml-role-list-mapper + attributes: + saml.authnstatement: true + saml.client.signature: true + saml.force.post.binding: true + saml.server.signature: true + saml.signature.algorithm: RSA_SHA256 + saml.signing.certificate: CERTIFICATEHERE + saml.signing.private.key: PRIVATEKEYHERE + saml_force_name_id_format: false + saml_name_id_format: username + saml_signature_canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#" + user.info.response.signature.alg: RS256 + request.object.signature.alg: RS256 + use.jwks.url: true + jwks.url: JWKS_URL_FOR_CLIENT_AUTH_JWT + jwt.credential.certificate: JWT_CREDENTIAL_CERTIFICATE_FOR_CLIENT_AUTH + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Client testclient has been updated" + +proposed: + description: Representation of proposed client. + returned: always + type: dict + sample: { + clientId: "test" + } + +existing: + description: Representation of existing client (sample is truncated). + returned: always + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } + +end_state: + description: Representation of client after module execution (sample is truncated). + returned: on success + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, get_token, KeycloakError, is_struct_included +from ansible.module_utils.basic import AnsibleModule +import copy + + +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. + + :param clientrep: the clientrep dict to be sanitized + :param remove_ids: If set to true, then the unique ID's of objects is removed to make the diff and checks for changed + not alert when the ID's of objects are not usually known, (e.g. for protocol_mappers) + :return: normalised clientrep dict + """ + # Avoid the dict passed in to be modified + clientrep = clientrep.copy() + + 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'])) + + if 'protocolMappers' in clientrep: + clientrep['protocolMappers'] = sorted(clientrep['protocolMappers'], key=lambda x: (x.get('name'), x.get('protocol'), x.get('protocolMapper'))) + for mapper in clientrep['protocolMappers']: + if remove_ids: + mapper.pop('id', None) + + # Set to a default value. + mapper['consentRequired'] = mapper.get('consentRequired', False) + + return clientrep + + +def sanitize_cr(clientrep): + """ Removes probably sensitive details from a client representation. + + :param clientrep: the clientrep dict to be sanitized + :return: sanitized clientrep dict + """ + result = copy.deepcopy(clientrep) + if 'secret' in result: + result['secret'] = 'no_log' + if 'attributes' in result: + attributes = result['attributes'] + if isinstance(attributes, dict) 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 + + :return: + """ + argument_spec = keycloak_argument_spec() + + protmapper_spec = dict( + consentRequired=dict(type='bool'), + consentText=dict(type='str'), + id=dict(type='str'), + name=dict(type='str'), + protocol=dict(type='str', choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), + protocolMapper=dict(type='str'), + config=dict(type='dict'), + ) + + authentication_flow_spec = dict( + browser=dict(type='str'), + browser_name=dict(type='str', aliases=['browserName']), + direct_grant=dict(type='str', aliases=['directGrant']), + direct_grant_name=dict(type='str', aliases=['directGrantName']), + ) + + meta_args = dict( + state=dict(default='present', choices=['present', 'absent']), + realm=dict(type='str', default='master'), + + id=dict(type='str'), + client_id=dict(type='str', aliases=['clientId']), + name=dict(type='str'), + description=dict(type='str'), + root_url=dict(type='str', aliases=['rootUrl']), + admin_url=dict(type='str', aliases=['adminUrl']), + base_url=dict(type='str', aliases=['baseUrl']), + surrogate_auth_required=dict(type='bool', aliases=['surrogateAuthRequired']), + enabled=dict(type='bool'), + client_authenticator_type=dict(type='str', choices=['client-secret', 'client-jwt', 'client-x509'], aliases=['clientAuthenticatorType']), + secret=dict(type='str', no_log=True), + registration_access_token=dict(type='str', aliases=['registrationAccessToken'], no_log=True), + default_roles=dict(type='list', elements='str', aliases=['defaultRoles']), + redirect_uris=dict(type='list', elements='str', aliases=['redirectUris']), + web_origins=dict(type='list', elements='str', aliases=['webOrigins']), + not_before=dict(type='int', aliases=['notBefore']), + bearer_only=dict(type='bool', aliases=['bearerOnly']), + consent_required=dict(type='bool', aliases=['consentRequired']), + standard_flow_enabled=dict(type='bool', aliases=['standardFlowEnabled']), + implicit_flow_enabled=dict(type='bool', aliases=['implicitFlowEnabled']), + direct_access_grants_enabled=dict(type='bool', aliases=['directAccessGrantsEnabled']), + service_accounts_enabled=dict(type='bool', aliases=['serviceAccountsEnabled']), + authorization_services_enabled=dict(type='bool', aliases=['authorizationServicesEnabled']), + public_client=dict(type='bool', aliases=['publicClient']), + frontchannel_logout=dict(type='bool', aliases=['frontchannelLogout']), + protocol=dict(type='str', choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), + attributes=dict(type='dict'), + full_scope_allowed=dict(type='bool', aliases=['fullScopeAllowed']), + node_re_registration_timeout=dict(type='int', aliases=['nodeReRegistrationTimeout']), + registered_nodes=dict(type='dict', aliases=['registeredNodes']), + client_template=dict(type='str', aliases=['clientTemplate']), + use_template_config=dict(type='bool', aliases=['useTemplateConfig']), + use_template_scope=dict(type='bool', aliases=['useTemplateScope']), + use_template_mappers=dict(type='bool', aliases=['useTemplateMappers']), + always_display_in_console=dict(type='bool', aliases=['alwaysDisplayInConsole']), + authentication_flow_binding_overrides=dict( + type='dict', + aliases=['authenticationFlowBindingOverrides'], + options=authentication_flow_spec, + required_one_of=[['browser', 'direct_grant', 'browser_name', 'direct_grant_name']], + mutually_exclusive=[['browser', 'browser_name'], ['direct_grant', 'direct_grant_name']], + ), + protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec, aliases=['protocolMappers']), + authorization_settings=dict(type='dict', aliases=['authorizationSettings']), + default_client_scopes=dict(type='list', elements='str', aliases=['defaultClientScopes']), + optional_client_scopes=dict(type='list', elements='str', aliases=['optionalClientScopes']), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['client_id', 'id'], + ['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get('realm') + cid = module.params.get('id') + state = module.params.get('state') + + # Filter and map the parameters names that apply to the client + client_params = [x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and + module.params.get(x) is not None] + + # See if it already exists in Keycloak + if cid is None: + before_client = kc.get_client_by_clientid(module.params.get('client_id'), realm=realm) + if before_client is not None: + cid = before_client['id'] + else: + before_client = kc.get_client_by_id(cid, realm=realm) + + if before_client is None: + before_client = {} + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for client_param in client_params: + new_param_value = module.params.get(client_param) + + # some lists in the Keycloak API are sorted, some are not. + if isinstance(new_param_value, list): + if client_param in ['attributes']: + try: + new_param_value = sorted(new_param_value) + except TypeError: + pass + # Unfortunately, the ansible argument spec checker introduces variables with null values when + # they are not specified + if client_param == 'protocol_mappers': + new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value] + elif client_param == 'authentication_flow_binding_overrides': + new_param_value = flow_binding_from_dict_to_model(new_param_value, realm, kc) + + changeset[camel(client_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_client = before_client.copy() + desired_client.update(changeset) + + result['proposed'] = sanitize_cr(changeset) + result['existing'] = sanitize_cr(before_client) + + # Cater for when it doesn't exist (an empty dict) + if not before_client: + if state == 'absent': + # Do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['changed'] = False + result['end_state'] = {} + result['msg'] = 'Client does not exist; doing nothing.' + module.exit_json(**result) + + # Process a creation + result['changed'] = True + + if 'clientId' not in desired_client: + module.fail_json(msg='client_id needs to be specified when creating a new client') + if 'protocol' not in desired_client: + desired_client['protocol'] = PROTOCOL_OPENID_CONNECT + + if module._diff: + result['diff'] = dict(before='', after=sanitize_cr(desired_client)) + + if module.check_mode: + module.exit_json(**result) + + # create it + kc.create_client(desired_client, realm=realm) + after_client = kc.get_client_by_clientid(desired_client['clientId'], realm=realm) + + result['end_state'] = sanitize_cr(after_client) + + result['msg'] = 'Client %s has been created.' % desired_client['clientId'] + module.exit_json(**result) + + else: + if state == 'present': + # Process an update + result['changed'] = True + + if module.check_mode: + # We can only compare the current client with the proposed updates we have + before_norm = normalise_cr(before_client, remove_ids=True) + desired_norm = normalise_cr(desired_client, remove_ids=True) + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_norm), + after=sanitize_cr(desired_norm)) + result['changed'] = not is_struct_included(desired_norm, before_norm, CLIENT_META_DATA) + + module.exit_json(**result) + + # do the update + kc.update_client(cid, desired_client, realm=realm) + + after_client = kc.get_client_by_id(cid, realm=realm) + if before_client == after_client: + result['changed'] = False + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_client), + after=sanitize_cr(after_client)) + + result['end_state'] = sanitize_cr(after_client) + + result['msg'] = 'Client %s has been updated.' % desired_client['clientId'] + module.exit_json(**result) + + else: + # Process a deletion (because state was not 'present') + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_client), after='') + + if module.check_mode: + module.exit_json(**result) + + # delete it + kc.delete_client(cid, realm=realm) + result['proposed'] = {} + + result['end_state'] = {} + + result['msg'] = 'Client %s has been deleted.' % before_client['clientId'] + + module.exit_json(**result) + + +if __name__ == '__main__': + main() 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 new file mode 100644 index 0000000..c48e9c9 --- /dev/null +++ b/plugins/modules/keycloak_role.py @@ -0,0 +1,439 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019, Adam Goossens +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_role + +short_description: Allows administration of Keycloak roles via Keycloak API + +version_added: 3.4.0 + +description: + - This module allows you to add, remove or modify Keycloak roles via the Keycloak REST API. + It requires access to the REST API via OpenID Connect; the user connecting and the client being + used must have the requisite access rights. In a default Keycloak installation, admin-cli + and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + + - The names of module options are snake_cased versions of the camelCase ones found in the + Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html). + + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will + be returned that way by this module. You may pass single values for attributes when calling the module, + and this will be translated into a list suitable for the API. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the role. + - On V(present), the role will be created if it does not yet exist, or updated with the parameters you provide. + - On V(absent), the role will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + name: + type: str + required: true + description: + - Name of the role. + - This parameter is required. + + description: + type: str + description: + - The role description. + + realm: + type: str + description: + - The Keycloak realm under which this role resides. + default: 'master' + + client_id: + type: str + description: + - If the role is a client role, the client id under which it resides. + - If this parameter is absent, the role is considered a realm role. + + attributes: + type: dict + description: + - A dict of key/value pairs to set as custom attributes for the role. + - Values may be single values (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 + - middleware_automation.keycloak.attributes + +author: + - Laurent Paumier (@laurpaum) +''' + +EXAMPLES = ''' +- name: Create a Keycloak realm role, authentication with credentials + middleware_automation.keycloak.keycloak_role: + name: my-new-kc-role + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Create a Keycloak realm role, authentication with token + middleware_automation.keycloak.keycloak_role: + name: my-new-kc-role + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + delegate_to: localhost + +- name: Create a Keycloak client role + middleware_automation.keycloak.keycloak_role: + name: my-new-kc-role + realm: MyCustomRealm + client_id: MyClient + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Delete a Keycloak role + middleware_automation.keycloak.keycloak_role: + name: my-role-for-deletion + state: absent + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Create a keycloak role with some custom attributes + middleware_automation.keycloak.keycloak_role: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + name: my-new-role + attributes: + attrib1: value1 + attrib2: value2 + attrib3: + - with + - numerous + - individual + - list + - items + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Role myrole has been updated" + +proposed: + description: Representation of proposed role. + returned: always + type: dict + sample: { + "description": "My updated test description" + } + +existing: + description: Representation of existing role. + returned: always + type: dict + sample: { + "attributes": {}, + "clientRole": true, + "composite": false, + "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", + "description": "My client test role", + "id": "561703dd-0f38-45ff-9a5a-0c978f794547", + "name": "myrole" + } + +end_state: + description: Representation of role after module execution (sample is truncated). + returned: on success + type: dict + sample: { + "attributes": {}, + "clientRole": true, + "composite": false, + "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", + "description": "My updated client test role", + "id": "561703dd-0f38-45ff-9a5a-0c978f794547", + "name": "myrole" + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, get_token, KeycloakError, is_struct_included +from ansible.module_utils.basic import AnsibleModule +import copy + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + composites_spec = dict( + name=dict(type='str', required=True), + client_id=dict(type='str', aliases=['clientId'], 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), + description=dict(type='str'), + realm=dict(type='str', default='master'), + client_id=dict(type='str'), + attributes=dict(type='dict'), + composites=dict(type='list', default=[], options=composites_spec, elements='dict'), + composite=dict(type='bool', default=False), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get('realm') + clientid = module.params.get('client_id') + name = module.params.get('name') + state = module.params.get('state') + + # attributes in Keycloak have their values returned as lists + # via the API. attributes is a dict, so we'll transparently convert + # the values to lists. + if module.params.get('attributes') is not None: + for key, val in module.params['attributes'].items(): + module.params['attributes'][key] = [val] if not isinstance(val, list) else val + + # Filter and map the parameters names that apply to the role + role_params = [x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'client_id'] and + module.params.get(x) is not None] + + # See if it already exists in Keycloak + if clientid is None: + before_role = kc.get_realm_role(name, realm) + else: + before_role = kc.get_client_role(name, clientid, realm) + + if before_role is None: + before_role = {} + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for param in role_params: + new_param_value = module.params.get(param) + old_value = before_role[param] if param in before_role else None + if new_param_value != old_value: + changeset[camel(param)] = copy.deepcopy(new_param_value) + + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) + desired_role = copy.deepcopy(before_role) + desired_role.update(changeset) + + result['proposed'] = changeset + result['existing'] = before_role + + # Cater for when it doesn't exist (an empty dict) + if not before_role: + if state == 'absent': + # Do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['changed'] = False + result['end_state'] = {} + result['msg'] = 'Role does not exist, doing nothing.' + module.exit_json(**result) + + # Process a creation + result['changed'] = True + + if name is None: + module.fail_json(msg='name must be specified when creating a new role') + + if module._diff: + result['diff'] = dict(before='', after=desired_role) + + if module.check_mode: + module.exit_json(**result) + + # create it + if clientid is None: + kc.create_realm_role(desired_role, realm) + after_role = kc.get_realm_role(name, realm) + else: + kc.create_client_role(desired_role, clientid, realm) + after_role = kc.get_client_role(name, clientid, realm) + + if after_role['composite']: + after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) + + result['end_state'] = after_role + + result['msg'] = 'Role {name} has been created'.format(name=name) + module.exit_json(**result) + + else: + if state == 'present': + compare_exclude = [] + if 'composites' in desired_role and isinstance(desired_role['composites'], list) and len(desired_role['composites']) > 0: + composites = kc.get_role_composites(rolerep=before_role, clientid=clientid, realm=realm) + before_role['composites'] = [] + for composite in composites: + before_composite = {} + if composite['clientRole']: + composite_client = kc.get_client_by_id(id=composite['containerId'], realm=realm) + before_composite['client_id'] = composite_client['clientId'] + else: + before_composite['client_id'] = None + before_composite['name'] = composite['name'] + before_composite['state'] = 'present' + before_role['composites'].append(before_composite) + else: + compare_exclude.append('composites') + # Process an update + # no changes + if is_struct_included(desired_role, before_role, exclude=compare_exclude): + result['changed'] = False + result['end_state'] = desired_role + result['msg'] = "No changes required to role {name}.".format(name=name) + module.exit_json(**result) + + # doing an update + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=before_role, after=desired_role) + + if module.check_mode: + module.exit_json(**result) + + # do the update + if clientid is None: + kc.update_realm_role(desired_role, realm) + after_role = kc.get_realm_role(name, realm) + else: + kc.update_client_role(desired_role, clientid, realm) + after_role = kc.get_client_role(name, clientid, realm) + if after_role['composite']: + after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) + + result['end_state'] = after_role + + result['msg'] = "Role {name} has been updated".format(name=name) + module.exit_json(**result) + + else: + # Process a deletion (because state was not 'present') + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=before_role, after='') + + if module.check_mode: + module.exit_json(**result) + + # delete it + if clientid is None: + kc.delete_realm_role(name, realm) + else: + kc.delete_client_role(name, clientid, realm) + + result['end_state'] = {} + + result['msg'] = "Role {name} has been deleted".format(name=name) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_user_federation.py b/plugins/modules/keycloak_user_federation.py new file mode 100644 index 0000000..864cfbc --- /dev/null +++ b/plugins/modules/keycloak_user_federation.py @@ -0,0 +1,1119 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_user_federation + +short_description: Allows administration of Keycloak user federations via Keycloak API + +version_added: 3.7.0 + +description: + - This module allows you to add, remove or modify Keycloak user federations via the Keycloak REST API. + It requires access to the REST API via OpenID Connect; the user connecting and the client being + used must have the requisite access rights. In a default Keycloak installation, admin-cli + and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + + - The names of module options are snake_cased versions of the camelCase ones found in the + Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/20.0.2/rest-api/index.html). + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the user federation. + - On V(present), the user federation will be created if it does not yet exist, or updated with + the parameters you provide. + - On V(absent), the user federation will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + description: + - The Keycloak realm under which this user federation resides. + default: 'master' + type: str + + id: + description: + - The unique ID for this user federation. If left empty, the user federation will be searched + by its O(name). + type: str + + name: + description: + - Display name of provider when linked in admin console. + type: str + + provider_id: + description: + - Provider for this user federation. Built-in providers are V(ldap), V(kerberos), and V(sssd). + Custom user storage providers can also be used. + aliases: + - providerId + type: str + + provider_type: + description: + - Component type for user federation (only supported value is V(org.keycloak.storage.UserStorageProvider)). + aliases: + - providerType + default: org.keycloak.storage.UserStorageProvider + type: str + + parent_id: + description: + - Unique ID for the parent of this user federation. Realm ID will be automatically used if left blank. + aliases: + - parentId + type: str + + remove_unspecified_mappers: + description: + - Remove mappers that are not specified in the configuration for this federation. + - Set to V(false) to keep mappers that are not listed in O(mappers). + type: bool + default: true + + bind_credential_update_mode: + description: + - The value of the config parameter O(config.bindCredential) is redacted in the Keycloak responses. + Comparing the redacted value with the desired value always evaluates to not equal. This means + the before and desired states are never equal if the parameter is set. + - Set to V(always) to include O(config.bindCredential) in the comparison of before and desired state. + Because of the redacted value returned by Keycloak the module 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 O(provider_id). Examples are given below for V(ldap), V(kerberos) and V(sssd). + It is easiest to obtain valid config values by dumping an already-existing user federation + configuration through check-mode in the RV(existing) field. + - The value V(sssd) has been supported since middleware_automation.keycloak 2.0.0. + type: dict + suboptions: + enabled: + description: + - Enable/disable this user federation. + default: true + type: bool + + priority: + description: + - Priority of provider when doing a user lookup. Lowest first. + default: 0 + type: int + + importEnabled: + description: + - If V(true), LDAP users will be imported into Keycloak DB and synced by the configured + sync policies. + default: true + type: bool + + editMode: + description: + - V(READ_ONLY) is a read-only LDAP store. V(WRITABLE) means data will be synced back to LDAP + on demand. V(UNSYNCED) means user data will be imported, but not synced back to LDAP. + type: str + choices: + - READ_ONLY + - WRITABLE + - UNSYNCED + + syncRegistrations: + description: + - Should newly created users be created within LDAP store? Priority effects which + provider is chosen to sync the new user. + default: false + type: bool + + vendor: + description: + - LDAP vendor (provider). + - Use short name. For instance, write V(rhds) for "Red Hat Directory Server". + type: str + + usernameLDAPAttribute: + description: + - Name of LDAP attribute, which is mapped as Keycloak username. For many LDAP server + vendors it can be V(uid). For Active directory it can be V(sAMAccountName) or V(cn). + The attribute should be filled for all LDAP user records you want to import from + LDAP to Keycloak. + type: str + + rdnLDAPAttribute: + description: + - Name of LDAP attribute, which is used as RDN (top attribute) of typical user DN. + Usually it's the same as Username LDAP attribute, however it is not required. For + example for Active directory, it is common to use V(cn) as RDN attribute when + username attribute might be V(sAMAccountName). + type: str + + uuidLDAPAttribute: + description: + - Name of LDAP attribute, which is used as unique object identifier (UUID) for objects + in LDAP. For many LDAP server vendors, it is V(entryUUID); however some are different. + For example for Active directory it should be V(objectGUID). If your LDAP server does + not support the notion of UUID, you can use any other attribute that is supposed to + be unique among LDAP users in tree. + type: str + + userObjectClasses: + description: + - All values of LDAP objectClass attribute for users in LDAP divided by comma. + For example V(inetOrgPerson, organizationalPerson). Newly created Keycloak users + will be written to LDAP with all those object classes and existing LDAP user records + are found just if they contain all those object classes. + type: str + + connectionUrl: + description: + - Connection URL to your LDAP server. + type: str + + usersDn: + description: + - Full DN of LDAP tree where your users are. This DN is the parent of LDAP users. + type: str + + customUserSearchFilter: + description: + - Additional LDAP Filter for filtering searched users. Leave this empty if you don't + need additional filter. + type: str + + searchScope: + description: + - For one level, the search applies only for users in the DNs specified by User DNs. + For subtree, the search applies to the whole subtree. See LDAP documentation for + more details. + default: '1' + type: str + choices: + - '1' + - '2' + + authType: + description: + - Type of the Authentication method used during LDAP Bind operation. It is used in + most of the requests sent to the LDAP server. + default: 'none' + type: str + choices: + - none + - simple + + bindDn: + description: + - DN of LDAP user which will be used by Keycloak to access LDAP server. + type: str + + bindCredential: + description: + - Password of LDAP admin. + type: str + + startTls: + description: + - Encrypts the connection to LDAP using STARTTLS, which will disable connection pooling. + default: false + type: bool + + usePasswordModifyExtendedOp: + description: + - Use the LDAPv3 Password Modify Extended Operation (RFC-3062). The password modify + extended operation usually requires that LDAP user already has password in the LDAP + server. So when this is used with 'Sync Registrations', it can be good to add also + 'Hardcoded LDAP attribute mapper' with randomly generated initial password. + default: false + type: bool + + validatePasswordPolicy: + description: + - Determines if Keycloak should validate the password with the realm password policy + before updating it. + default: false + type: bool + + trustEmail: + description: + - If enabled, email provided by this provider is not verified even if verification is + enabled for the realm. + default: false + type: bool + + useTruststoreSpi: + description: + - Specifies whether LDAP connection will use the truststore SPI with the truststore + configured in standalone.xml/domain.xml. V(always) means that it will always use it. + V(never) means that it will not use it. V(ldapsOnly) means that it will use if + your connection URL use ldaps. Note even if standalone.xml/domain.xml is not + configured, the default Java cacerts or certificate specified by + C(javax.net.ssl.trustStore) property will be used. + default: ldapsOnly + type: str + choices: + - always + - ldapsOnly + - never + + connectionTimeout: + description: + - LDAP Connection Timeout in milliseconds. + type: int + + readTimeout: + description: + - LDAP Read Timeout in milliseconds. This timeout applies for LDAP read operations. + type: int + + pagination: + description: + - Does the LDAP server support pagination. + default: true + type: bool + + connectionPooling: + description: + - Determines if Keycloak should use connection pooling for accessing LDAP server. + default: true + type: bool + + connectionPoolingAuthentication: + description: + - A list of space-separated authentication types of connections that may be pooled. + type: str + choices: + - none + - simple + - DIGEST-MD5 + + connectionPoolingDebug: + description: + - A string that indicates the level of debug output to produce. Example valid values are + V(fine) (trace connection creation and removal) and V(all) (all debugging information). + type: str + + connectionPoolingInitSize: + description: + - The number of connections per connection identity to create when initially creating a + connection for the identity. + type: int + + connectionPoolingMaxSize: + description: + - The maximum number of connections per connection identity that can be maintained + concurrently. + type: int + + connectionPoolingPrefSize: + description: + - The preferred number of connections per connection identity that should be maintained + concurrently. + type: int + + connectionPoolingProtocol: + description: + - A list of space-separated protocol types of connections that may be pooled. + Valid types are V(plain) and V(ssl). + type: str + + connectionPoolingTimeout: + description: + - The number of milliseconds that an idle connection may remain in the pool without + being closed and removed from the pool. + type: int + + allowKerberosAuthentication: + description: + - Enable/disable HTTP authentication of users with SPNEGO/Kerberos tokens. The data + about authenticated users will be provisioned from this LDAP server. + default: false + type: bool + + kerberosRealm: + description: + - Name of kerberos realm. + type: str + + krbPrincipalAttribute: + description: + - Name of the LDAP attribute, which refers to Kerberos principal. + This is used to lookup appropriate LDAP user after successful Kerberos/SPNEGO authentication in Keycloak. + When this is empty, the LDAP user will be looked based on LDAP username corresponding + to the first part of his Kerberos principal. For instance, for principal C(john@KEYCLOAK.ORG), + it will assume that LDAP username is V(john). + type: str + + serverPrincipal: + description: + - Full name of server principal for HTTP service including server and domain name. For + example V(HTTP/host.foo.org@FOO.ORG). Use V(*) to accept any service principal in the + KeyTab file. + type: str + + keyTab: + description: + - Location of Kerberos KeyTab file containing the credentials of server principal. For + example V(/etc/krb5.keytab). + type: str + + debug: + description: + - Enable/disable debug logging to standard output for Krb5LoginModule. + type: bool + + useKerberosForPasswordAuthentication: + description: + - Use Kerberos login module for authenticate username/password against Kerberos server + instead of authenticating against LDAP server with Directory Service API. + default: false + type: bool + + allowPasswordAuthentication: + description: + - Enable/disable possibility of username/password authentication against Kerberos database. + type: bool + + batchSizeForSync: + description: + - Count of LDAP users to be imported from LDAP to Keycloak within a single transaction. + default: 1000 + type: int + + fullSyncPeriod: + description: + - Period for full synchronization in seconds. + default: -1 + type: int + + changedSyncPeriod: + description: + - Period for synchronization of changed or newly created LDAP users in seconds. + default: -1 + type: int + + updateProfileFirstLogin: + description: + - Update profile on first login. + type: bool + + cachePolicy: + description: + - Cache Policy for this storage provider. + type: str + default: 'DEFAULT' + choices: + - DEFAULT + - EVICT_DAILY + - EVICT_WEEKLY + - MAX_LIFESPAN + - NO_CACHE + + evictionDay: + description: + - Day of the week the entry will become invalid on. + type: str + + evictionHour: + description: + - Hour of day the entry will become invalid on. + type: str + + evictionMinute: + description: + - Minute of day the entry will become invalid on. + type: str + + maxLifespan: + description: + - Max lifespan of cache entry in milliseconds. + type: int + + referral: + description: + - Specifies if LDAP referrals should be followed or ignored. Please note that enabling + referrals can slow down authentication as it allows the LDAP server to decide which other + LDAP servers to use. This could potentially include untrusted servers. + type: str + choices: + - ignore + - follow + + mappers: + description: + - A list of dicts defining mappers associated with this Identity Provider. + type: list + elements: dict + suboptions: + id: + description: + - Unique ID of this mapper. + type: str + + name: + description: + - Name of the mapper. If no ID is given, the mapper will be searched by name. + type: str + + parentId: + description: + - Unique ID for the parent of this mapper. ID of the user federation will automatically + be used if left blank. + type: str + + providerId: + description: + - The mapper type for this mapper (for instance V(user-attribute-ldap-mapper)). + type: str + + providerType: + description: + - Component type for this mapper. + type: str + default: org.keycloak.storage.ldap.mappers.LDAPStorageMapper + + config: + description: + - Dict specifying the configuration options for the mapper; the contents differ + depending on the value of I(identityProviderMapper). + type: dict + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.attributes + +author: + - Laurent Paumier (@laurpaum) +''' + +EXAMPLES = ''' +- name: Create LDAP user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com/auth + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-ldap + state: present + provider_id: ldap + provider_type: org.keycloak.storage.UserStorageProvider + config: + priority: 0 + enabled: true + cachePolicy: DEFAULT + batchSizeForSync: 1000 + editMode: READ_ONLY + importEnabled: true + syncRegistrations: false + vendor: other + usernameLDAPAttribute: uid + rdnLDAPAttribute: uid + uuidLDAPAttribute: entryUUID + userObjectClasses: inetOrgPerson, organizationalPerson + connectionUrl: ldaps://ldap.example.com:636 + usersDn: ou=Users,dc=example,dc=com + authType: simple + bindDn: cn=directory reader + bindCredential: password + searchScope: 1 + validatePasswordPolicy: false + trustEmail: false + useTruststoreSpi: ldapsOnly + connectionPooling: true + pagination: true + allowKerberosAuthentication: false + debug: false + useKerberosForPasswordAuthentication: false + mappers: + - name: "full name" + providerId: "full-name-ldap-mapper" + providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" + config: + ldap.full.name.attribute: cn + read.only: true + write.only: false + +- name: Create Kerberos user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com/auth + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-kerberos + state: present + provider_id: kerberos + provider_type: org.keycloak.storage.UserStorageProvider + config: + priority: 0 + enabled: true + cachePolicy: DEFAULT + kerberosRealm: EXAMPLE.COM + serverPrincipal: HTTP/host.example.com@EXAMPLE.COM + keyTab: keytab + allowPasswordAuthentication: false + updateProfileFirstLogin: false + +- name: Create sssd user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com/auth + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-sssd + state: present + provider_id: sssd + provider_type: org.keycloak.storage.UserStorageProvider + config: + priority: 0 + enabled: true + cachePolicy: DEFAULT + +- name: Delete user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com/auth + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-federation + state: absent +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "No changes required to user federation 164bb483-c613-482e-80fe-7f1431308799." + +proposed: + description: Representation of proposed user federation. + returned: always + type: dict + sample: { + "config": { + "allowKerberosAuthentication": "false", + "authType": "simple", + "batchSizeForSync": "1000", + "bindCredential": "**********", + "bindDn": "cn=directory reader", + "cachePolicy": "DEFAULT", + "connectionPooling": "true", + "connectionUrl": "ldaps://ldap.example.com:636", + "debug": "false", + "editMode": "READ_ONLY", + "enabled": "true", + "importEnabled": "true", + "pagination": "true", + "priority": "0", + "rdnLDAPAttribute": "uid", + "searchScope": "1", + "syncRegistrations": "false", + "trustEmail": "false", + "useKerberosForPasswordAuthentication": "false", + "useTruststoreSpi": "ldapsOnly", + "userObjectClasses": "inetOrgPerson, organizationalPerson", + "usernameLDAPAttribute": "uid", + "usersDn": "ou=Users,dc=example,dc=com", + "uuidLDAPAttribute": "entryUUID", + "validatePasswordPolicy": "false", + "vendor": "other" + }, + "name": "ldap", + "providerId": "ldap", + "providerType": "org.keycloak.storage.UserStorageProvider" + } + +existing: + description: Representation of existing user federation. + returned: always + type: dict + sample: { + "config": { + "allowKerberosAuthentication": "false", + "authType": "simple", + "batchSizeForSync": "1000", + "bindCredential": "**********", + "bindDn": "cn=directory reader", + "cachePolicy": "DEFAULT", + "changedSyncPeriod": "-1", + "connectionPooling": "true", + "connectionUrl": "ldaps://ldap.example.com:636", + "debug": "false", + "editMode": "READ_ONLY", + "enabled": "true", + "fullSyncPeriod": "-1", + "importEnabled": "true", + "pagination": "true", + "priority": "0", + "rdnLDAPAttribute": "uid", + "searchScope": "1", + "syncRegistrations": "false", + "trustEmail": "false", + "useKerberosForPasswordAuthentication": "false", + "useTruststoreSpi": "ldapsOnly", + "userObjectClasses": "inetOrgPerson, organizationalPerson", + "usernameLDAPAttribute": "uid", + "usersDn": "ou=Users,dc=example,dc=com", + "uuidLDAPAttribute": "entryUUID", + "validatePasswordPolicy": "false", + "vendor": "other" + }, + "id": "01122837-9047-4ae4-8ca0-6e2e891a765f", + "mappers": [ + { + "config": { + "always.read.value.from.ldap": "false", + "is.mandatory.in.ldap": "false", + "ldap.attribute": "mail", + "read.only": "true", + "user.model.attribute": "email" + }, + "id": "17d60ce2-2d44-4c2c-8b1f-1fba601b9a9f", + "name": "email", + "parentId": "01122837-9047-4ae4-8ca0-6e2e891a765f", + "providerId": "user-attribute-ldap-mapper", + "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" + } + ], + "name": "myfed", + "parentId": "myrealm", + "providerId": "ldap", + "providerType": "org.keycloak.storage.UserStorageProvider" + } + +end_state: + description: Representation of user federation after module execution. + returned: on success + type: dict + sample: { + "config": { + "allowPasswordAuthentication": "false", + "cachePolicy": "DEFAULT", + "enabled": "true", + "kerberosRealm": "EXAMPLE.COM", + "keyTab": "/etc/krb5.keytab", + "priority": "0", + "serverPrincipal": "HTTP/host.example.com@EXAMPLE.COM", + "updateProfileFirstLogin": "false" + }, + "id": "cf52ae4f-4471-4435-a0cf-bb620cadc122", + "mappers": [], + "name": "kerberos", + "parentId": "myrealm", + "providerId": "kerberos", + "providerType": "org.keycloak.storage.UserStorageProvider" + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves.urllib.parse import urlencode +from copy import deepcopy + + +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'] = {k: v[0] for k, v in compcopy['config'].items()} + if 'bindCredential' in compcopy['config']: + compcopy['config']['bindCredential'] = '**********' + if 'mappers' in compcopy: + for mapper in compcopy['mappers']: + if 'config' in mapper: + mapper['config'] = {k: v[0] for k, v in mapper['config'].items()} + return compcopy + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + config_spec = dict( + allowKerberosAuthentication=dict(type='bool', default=False), + allowPasswordAuthentication=dict(type='bool'), + authType=dict(type='str', choices=['none', 'simple'], default='none'), + batchSizeForSync=dict(type='int', default=1000), + bindCredential=dict(type='str', no_log=True), + bindDn=dict(type='str'), + cachePolicy=dict(type='str', choices=['DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN', 'NO_CACHE'], default='DEFAULT'), + changedSyncPeriod=dict(type='int', default=-1), + connectionPooling=dict(type='bool', default=True), + connectionPoolingAuthentication=dict(type='str', choices=['none', 'simple', 'DIGEST-MD5']), + connectionPoolingDebug=dict(type='str'), + connectionPoolingInitSize=dict(type='int'), + connectionPoolingMaxSize=dict(type='int'), + connectionPoolingPrefSize=dict(type='int'), + connectionPoolingProtocol=dict(type='str'), + connectionPoolingTimeout=dict(type='int'), + connectionTimeout=dict(type='int'), + connectionUrl=dict(type='str'), + customUserSearchFilter=dict(type='str'), + debug=dict(type='bool'), + editMode=dict(type='str', choices=['READ_ONLY', 'WRITABLE', 'UNSYNCED']), + enabled=dict(type='bool', default=True), + evictionDay=dict(type='str'), + evictionHour=dict(type='str'), + evictionMinute=dict(type='str'), + fullSyncPeriod=dict(type='int', default=-1), + importEnabled=dict(type='bool', default=True), + kerberosRealm=dict(type='str'), + keyTab=dict(type='str', no_log=False), + maxLifespan=dict(type='int'), + pagination=dict(type='bool', default=True), + priority=dict(type='int', default=0), + rdnLDAPAttribute=dict(type='str'), + readTimeout=dict(type='int'), + referral=dict(type='str', choices=['ignore', 'follow']), + searchScope=dict(type='str', choices=['1', '2'], default='1'), + serverPrincipal=dict(type='str'), + krbPrincipalAttribute=dict(type='str'), + startTls=dict(type='bool', default=False), + syncRegistrations=dict(type='bool', default=False), + trustEmail=dict(type='bool', default=False), + updateProfileFirstLogin=dict(type='bool'), + useKerberosForPasswordAuthentication=dict(type='bool', default=False), + usePasswordModifyExtendedOp=dict(type='bool', default=False, no_log=False), + useTruststoreSpi=dict(type='str', choices=['always', 'ldapsOnly', 'never'], default='ldapsOnly'), + userObjectClasses=dict(type='str'), + usernameLDAPAttribute=dict(type='str'), + usersDn=dict(type='str'), + uuidLDAPAttribute=dict(type='str'), + validatePasswordPolicy=dict(type='bool', default=False), + vendor=dict(type='str'), + ) + + mapper_spec = dict( + id=dict(type='str'), + name=dict(type='str'), + parentId=dict(type='str'), + providerId=dict(type='str'), + providerType=dict(type='str', default='org.keycloak.storage.ldap.mappers.LDAPStorageMapper'), + config=dict(type='dict'), + ) + + meta_args = dict( + config=dict(type='dict', options=config_spec), + state=dict(type='str', default='present', choices=['present', 'absent']), + realm=dict(type='str', default='master'), + id=dict(type='str'), + name=dict(type='str'), + provider_id=dict(type='str', aliases=['providerId']), + provider_type=dict(type='str', aliases=['providerType'], default='org.keycloak.storage.UserStorageProvider'), + parent_id=dict(type='str', aliases=['parentId']), + remove_unspecified_mappers=dict(type='bool', default=True), + bind_credential_update_mode=dict(type='str', default='always', choices=['always', 'only_indirect']), + mappers=dict(type='list', elements='dict', options=mapper_spec), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['id', 'name'], + ['token', 'auth_realm', 'auth_username', 'auth_password']]), + 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') + config = module.params.get('config') + mappers = module.params.get('mappers') + cid = module.params.get('id') + name = module.params.get('name') + + # Keycloak API expects config parameters to be arrays containing a single string element + if config is not None: + module.params['config'] = { + k: [str(v).lower() if not isinstance(v, str) else v] + for k, v in config.items() + if config[k] is not None + } + + if mappers is not None: + for mapper in mappers: + if mapper.get('config') is not None: + 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', 'remove_unspecified_mappers', 'bind_credential_update_mode'] + and module.params.get(x) is not None] + + # See if it already exists in Keycloak + if cid is None: + found = kc.get_components(urlencode(dict(type='org.keycloak.storage.UserStorageProvider', name=name)), realm) + if len(found) > 1: + module.fail_json(msg='No ID given and found multiple user federations with name `{name}`. Cannot continue.'.format(name=name)) + before_comp = next(iter(found), None) + if before_comp is not None: + cid = before_comp['id'] + else: + before_comp = kc.get_component(cid, realm) + + if before_comp is None: + before_comp = {} + + # if user federation exists, get associated mappers + if cid is not None and before_comp: + before_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name') or '') + + normalize_kc_comp(before_comp) + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for param in comp_params: + new_param_value = module.params.get(param) + old_value = before_comp[camel(param)] if camel(param) in before_comp else None + if param == 'mappers': + new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value] + if new_param_value != old_value: + changeset[camel(param)] = new_param_value + + # special handling of mappers list to allow change detection + if module.params.get('mappers') is not None: + if module.params['provider_id'] in ['kerberos', 'sssd']: + module.fail_json(msg='Cannot configure mappers for {type} provider.'.format(type=module.params['provider_id'])) + for change in module.params['mappers']: + change = {k: v for k, v in change.items() if v is not None} + if change.get('id') is None and change.get('name') is None: + module.fail_json(msg='Either `name` or `id` has to be specified on each mapper.') + if cid is None: + old_mapper = {} + elif change.get('id') is not None: + old_mapper = next((before_mapper for before_mapper in before_comp.get('mappers', []) if before_mapper["id"] == change['id']), None) + if old_mapper is None: + old_mapper = {} + else: + found = [before_mapper for before_mapper in before_comp.get('mappers', []) if before_mapper['name'] == change['name']] + if len(found) > 1: + module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=change['name'])) + if len(found) == 1: + old_mapper = found[0] + else: + old_mapper = {} + new_mapper = old_mapper.copy() + new_mapper.update(change) + # changeset contains all desired mappers: those existing, to update or to create + if changeset.get('mappers') is None: + changeset['mappers'] = list() + changeset['mappers'].append(new_mapper) + changeset['mappers'] = sorted(changeset['mappers'], key=lambda x: x.get('name') or '') + + # to keep unspecified existing mappers we add them to the desired mappers list, unless they're already present + if not module.params['remove_unspecified_mappers'] and 'mappers' in before_comp: + changeset_mapper_ids = [mapper['id'] for mapper in changeset['mappers'] if 'id' in mapper] + changeset['mappers'].extend([mapper for mapper in before_comp['mappers'] if mapper['id'] not in changeset_mapper_ids]) + + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) + desired_comp = before_comp.copy() + desired_comp.update(changeset) + + result['proposed'] = sanitize(changeset) + result['existing'] = sanitize(before_comp) + + # Cater for when it doesn't exist (an empty dict) + if not before_comp: + if state == 'absent': + # Do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['changed'] = False + result['end_state'] = {} + result['msg'] = 'User federation does not exist; doing nothing.' + module.exit_json(**result) + + # Process a creation + result['changed'] = True + + if module.check_mode: + if module._diff: + result['diff'] = dict(before='', after=sanitize(desired_comp)) + module.exit_json(**result) + + # create it + desired_mappers = desired_comp.pop('mappers', []) + after_comp = kc.create_component(desired_comp, realm) + cid = after_comp['id'] + updated_mappers = [] + # when creating a user federation, keycloak automatically creates default mappers + default_mappers = kc.get_components(urlencode(dict(parent=cid)), realm) + + # create new mappers or update existing default mappers + for desired_mapper in desired_mappers: + found = [default_mapper for default_mapper in default_mappers if default_mapper['name'] == desired_mapper['name']] + if len(found) > 1: + module.fail_json(msg='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(desired_mapper) + + if new_mapper.get('id') is not None: + kc.update_component(new_mapper, realm) + updated_mappers.append(new_mapper) + else: + if new_mapper.get('parentId') is None: + new_mapper['parentId'] = cid + updated_mappers.append(kc.create_component(new_mapper, realm)) + + if module.params['remove_unspecified_mappers']: + # we remove all unwanted default mappers + # we use ids so we dont accidently remove one of the previously updated default mapper + for default_mapper in default_mappers: + if 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=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_copy == before_copy: + result['changed'] = False + result['end_state'] = sanitize(desired_comp) + result['msg'] = "No changes required to user federation {id}.".format(id=cid) + module.exit_json(**result) + + # doing an update + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=sanitize(before_comp), after=sanitize(desired_comp)) + + if module.check_mode: + module.exit_json(**result) + + # do the update + desired_mappers = desired_comp.pop('mappers', []) + kc.update_component(desired_comp, realm) + + for before_mapper in before_comp.get('mappers', []): + # remove unwanted existing mappers that will not be updated + if 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'] + 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) + + elif state == 'absent': + # Process a deletion + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=sanitize(before_comp), after='') + + if module.check_mode: + module.exit_json(**result) + + # delete it + kc.delete_component(cid, realm) + + result['end_state'] = {} + + result['msg'] = "User federation {id} has been deleted".format(id=cid) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt index b2366a5..5de7845 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ ################################################# -# python dependencies required to be installed +# python dependencies required to be installed # on the controller host with: # pip install -r requirements.txt # -netaddr \ No newline at end of file +netaddr +lxml # for middleware_automation.common.maven_artifact \ No newline at end of file diff --git a/requirements.yml b/requirements.yml index ab434b2..06e5714 100644 --- a/requirements.yml +++ b/requirements.yml @@ -1,7 +1,5 @@ --- collections: - - name: middleware_automation.redhat_csp_download + - name: middleware_automation.common version: ">=1.2.1" - - name: middleware_automation.wildfly - version: ">=0.0.5" - - name: community.general + - name: ansible.posix diff --git a/roles/keycloak/README.md b/roles/keycloak/README.md index 71787b1..3d3b560 100644 --- a/roles/keycloak/README.md +++ b/roles/keycloak/README.md @@ -10,6 +10,7 @@ Requirements This role requires the `python3-netaddr` library installed on the controller node. * to install via yum/dnf: `dnf install python3-netaddr` +* to install via apt: `apt install python3-netaddr` * or via pip: `pip install netaddr==0.8.0` * or via the collection: `pip install -r requirements.txt` @@ -19,8 +20,12 @@ Dependencies The roles depends on: -* the `redhat_csp_download` role from [middleware_automation.redhat_csp_download](https://github.com/ansible-middleware/redhat-csp-download) collection if Red Hat Single Sign-on zip have to be downloaded from RHN. -* the `wildfly_driver` role from [middleware_automation.wildfly](https://github.com/ansible-middleware/wildfly) collection +* [middleware_automation.common](https://github.com/ansible-middleware/common) +* [ansible-posix](https://docs.ansible.com/ansible/latest/collections/ansible/posix/index.html) + +To install all the dependencies via galaxy: + + ansible-galaxy collection install -r requirements.yml Versions @@ -28,18 +33,19 @@ Versions | RH-SSO VERSION | Release Date | Keycloak Version | EAP Version | Notes | |:---------------|:------------------|:-----------------|:------------|:----------------| -|`7.5.0 GA` |September 20, 2021 |`15.0.2` | `7.4.0` |[Release Notes](https://access.redhat.com/documentation/en-us/red_hat_single_sign-on/7.5/html/release_notes/index)| +|`7.5.0 GA` |September 20, 2021 |`15.0.2` | `7.4.6` |[Release Notes](https://access.redhat.com/documentation/en-us/red_hat_single_sign-on/7.5/html/release_notes/index)| +|`7.6.0 GA` |June 30, 2022 |`18.0.3` | `7.4.6` |[Release Notes](https://access.redhat.com/documentation/en-us/red_hat_single_sign-on/7.6/html-single/release_notes/index)| Patching -------- -When variable `keycloak_rhsso_apply_patches` is `True` (default: `False`), the role will automatically apply the latest cumulative patch for the selected base version. +When variable `keycloak_rhsso_apply_patches` is `true` (default: `false`), the role will automatically apply the latest cumulative patch for the selected base version. | RH-SSO VERSION | Release Date | RH-SSO LATEST CP | Notes | |:---------------|:------------------|:-----------------|:----------------| -|`7.5.0 GA` |January 20, 2022 |`7.5.1 GA` |[Release Notes](https://access.redhat.com/articles/6646321)| - +|`7.5.0 GA` |January 20, 2022 |`7.5.3 GA` |[Release Notes](https://access.redhat.com/articles/6646321)| +|`7.6.0 GA` |November 11, 2022 |`7.6.1 GA` |[Release Notes](https://access.redhat.com/articles/6982711)| Role Defaults @@ -50,9 +56,12 @@ Role Defaults | Variable | Description | Default | |:---------|:------------|:---------| |`keycloak_ha_enabled`| Enable auto configuration for database backend, clustering and remote caches on infinispan | `False` | +|`keycloak_ha_discovery`| Discovery protocol for HA cluster members | `JDBC_PING` if `keycloak_db_enabled` else `TCPPING` | |`keycloak_db_enabled`| Enable auto configuration for database backend | `True` if `keycloak_ha_enabled` is True, else `False` | +|`keycloak_remote_cache_enabled`| Enable remote cache store when in clustered ha configurations | `True` if `keycloak_ha_enabled` else `False` | |`keycloak_admin_user`| Administration console user account | `admin` | |`keycloak_bind_address`| Address for binding service ports | `0.0.0.0` | +|`keycloak_management_port_bind_address`| Address for binding management ports | `127.0.0.1` | |`keycloak_host`| hostname | `localhost` | |`keycloak_http_port`| HTTP port | `8080` | |`keycloak_https_port`| TLS HTTP port | `8443` | @@ -60,13 +69,19 @@ Role Defaults |`keycloak_jgroups_port`| jgroups cluster tcp port | `7600` | |`keycloak_management_http_port`| Management port | `9990` | |`keycloak_management_https_port`| TLS management port | `9993` | -|`keycloak_prefer_ipv4`| Prefer IPv4 stack and addresses for port binding | `True` | +|`keycloak_prefer_ipv4`| Prefer IPv4 stack and addresses for port binding | `true` | |`keycloak_config_standalone_xml`| filename for configuration | `keycloak.xml` | |`keycloak_service_user`| posix account username | `keycloak` | |`keycloak_service_group`| posix account group | `keycloak` | -|`keycloak_service_pidfile`| pid file path for service | `/run/keycloak.pid` | +|`keycloak_service_restart_always`| systemd restart always behavior activation | `False` | +|`keycloak_service_restart_on_failure`| systemd restart on-failure behavior activation | `False` | +|`keycloak_service_startlimitintervalsec`| systemd StartLimitIntervalSec | `300` | +|`keycloak_service_startlimitburst`| systemd StartLimitBurst | `5` | +|`keycloak_service_restartsec`| systemd RestartSec | `10s` | +|`keycloak_service_pidfile`| pid file path for service | `/run/keycloak/keycloak.pid` | +|`keycloak_features` | List of `name`/`status` pairs of features (also known as profiles on RH-SSO) to `enable` or `disable`, example: `[ { name: 'docker', status: 'enabled' } ]` | `[]` |`keycloak_jvm_package`| RHEL java package runtime | `java-1.8.0-openjdk-headless` | -|`keycloak_java_home`| JAVA_HOME of installed JRE, leave empty for using specified keycloak_jvm_package RPM path | `None` | +|`keycloak_java_home`| `JAVA_HOME` of installed JRE, leave empty for using RPM path at `keycloak_jvm_package` | `None` | |`keycloak_java_opts`| Additional JVM options | `-Xms1024m -Xmx2048m` | @@ -74,39 +89,37 @@ Role Defaults | Variable | Description | Default | |:---------|:------------|:---------| -|`keycloak_rhsso_enable`| Enable Red Hat Single Sign-on installation | `False` | -|`keycloak_offline_install` | perform an offline install | `False`| -|`keycloak_download_url`| Download URL for keycloak | `https://github.com/keycloak/keycloak/releases/download//`| -|`keycloak_rhsso_download_url`| Download URL for RHSSO | `https://access.redhat.com/jbossnetwork/restricted/softwareDownload.html?softwareId=`| -|`keycloak_version`| keycloak.org package version | `15.0.2` | -|`keycloak_rhsso_version`| RHSSO version | `7.5.0` | -|`keycloak_rhsso_apply_patches`| Install RHSSO more recent cumulative patch | `False` | +|`keycloak_offline_install` | perform an offline install | `false`| +|`keycloak_download_url`| Download URL for keycloak | `https://github.com/keycloak/keycloak/releases/download//`| +|`keycloak_version`| keycloak.org package version | `18.0.2` | |`keycloak_dest`| Installation root path | `/opt/keycloak` | |`keycloak_download_url` | Download URL for keycloak | `https://github.com/keycloak/keycloak/releases/download/{{ keycloak_version }}/{{ keycloak_archive }}` | -|`keycloak_rhn_url` | Base download URI for customer portal | `https://access.redhat.com/jbossnetwork/restricted/softwareDownload.html?softwareId=` | -|`keycloak_configure_firewalld` | Ensure firewalld is running and configure keycloak ports | `False` | +|`keycloak_configure_firewalld` | Ensure firewalld is running and configure keycloak ports | `false` | * Miscellaneous configuration | Variable | Description | Default | |:---------|:------------|:--------| -|`keycloak_archive` | keycloak install archive filename | `keycloak-{{ keycloak_version }}.zip` | +|`keycloak_archive` | keycloak install archive filename | `keycloak-legacy-{{ keycloak_version }}.zip` | |`keycloak_download_url_9x` | Download URL for keycloak (deprecated) | `https://downloads.jboss.org/keycloak/{{ keycloak_version }}/{{ keycloak_archive }}` | |`keycloak_installdir` | Installation path | `{{ keycloak_dest }}/keycloak-{{ keycloak_version }}` | -|`keycloak_rhsso_archive` | Red Hat SSO install archive filename | `rh-sso-{{ keycloak_rhsso_version }}-server-dist.zip` | -|`keycloak_rhsso_installdir`| Installation path for Red Hat SSO | `{{ keycloak_dest }}/rh-sso-{{ keycloak_rhsso_version | regex_replace('^([0-9])\.([0-9]*).*', '\1.\2') }}` | -|`keycloak_rhsso_download_url`| Full download URI for Red Hat SSO | `{{ keycloak_rhn_url }}{{ rhsso_rhn_id }}` | -|`keycloak_jboss_home` | Installation work directory | `{{ keycloak_rhsso_installdir if keycloak_rhsso_enable else keycloak_installdir }}` | +|`keycloak_jboss_home` | Installation work directory | `{{ keycloak_rhsso_installdir }}` | +|`keycloak_jboss_port_offset` | Port offset for the JBoss socket binding | `0` | |`keycloak_config_dir` | Path for configuration | `{{ keycloak_jboss_home }}/standalone/configuration` | |`keycloak_config_path_to_standalone_xml` | Custom path for configuration | `{{ keycloak_jboss_home }}/standalone/configuration/{{ keycloak_config_standalone_xml }}` | |`keycloak_config_override_template` | Path to custom template for standalone.xml configuration | `''` | |`keycloak_auth_realm` | Name for rest authentication realm | `master` | |`keycloak_auth_client` | Authentication client for configuration REST calls | `admin-cli` | -|`keycloak_force_install` | Remove pre-existing versions of service | `False` | -|`keycloak_url` | URL for configuration rest calls | `http://{{ keycloak_host }}:{{ keycloak_http_port }}` | -|`keycloak_management_url` | URL for management console rest calls | `http://{{ keycloak_host }}:{{ keycloak_management_http_port }}` | -|`rhsso_rhn_id` | Customer Portal product ID for Red Hat SSO | `{{ rhsso_rhn_ids[keycloak_rhsso_version].id }}` | +|`keycloak_force_install` | Remove pre-existing versions of service | `false` | +|`keycloak_url` | URL for configuration rest calls | `http://{{ keycloak_host }}:{{ keycloak_http_port + keycloak_jboss_port_offset }}` | +|`keycloak_management_url` | URL for management console rest calls | `http://{{ keycloak_host }}:{{ keycloak_management_http_port + keycloak_jboss_port_offset }}` | +|`keycloak_frontend_url_force` | Force backend requests to use the frontend URL | `false` | +|`keycloak_db_background_validation` | Enable background validation of database connection | `false` | +|`keycloak_db_background_validation_millis`| How frequenly the connection pool is validated in the background | `10000` if background validation enabled | +|`keycloak_db_background_validate_on_match` | Enable validate on match for database connections | `false` | +|`keycloak_frontend_url` | frontend URL for keycloak endpoint | `http://localhost:8080/auth/` | +|`keycloak_log_target`| Set the destination of the keycloak log folder link | `/var/log/keycloak` | Role Variables @@ -117,25 +130,28 @@ The following are a set of _required_ variables for the role: | Variable | Description | |:---------|:------------| |`keycloak_admin_password`| Password for the administration console user account (minimum 12 characters) | -|`keycloak_frontend_url` | frontend URL for keycloak endpoint | `http://localhost:8080/auth` | +|`keycloak_frontend_url` | frontend URL for keycloak endpoint | `http://localhost:8080/auth/` | -The following variables are _required_ only when `keycloak_ha_enabled` is True: +The following parameters are _required_ only when `keycloak_ha_enabled` is true: | Variable | Description | Default | -|:---------|:------------|:---------| -|`keycloak_modcluster_url` | URL for the modcluster reverse proxy | `localhost` | -|`keycloak_jdbc_engine` | backend database engine when db is enabled: [ postgres, mariadb ] | `postgres` | -|`infinispan_url` | URL for the infinispan remote-cache server | `localhost:11122` | -|`infinispan_user` | username for connecting to infinispan | `supervisor` | -|`infinispan_pass` | password for connecting to infinispan | `supervisor` | -|`infinispan_sasl_mechanism`| Authentication type | `SCRAM-SHA-512` | -|`infinispan_use_ssl`| Enable hotrod TLS communication | `False` | -|`infinispan_trust_store_path`| Path to truststore with infinispan server certificate | `/etc/pki/java/cacerts` | -|`infinispan_trust_store_password`| Password for opening truststore | `changeit` | +|:---------|:------------|:--------| +|`keycloak_modcluster_enabled`| Enable configuration for modcluster subsystem | `True` if `keycloak_ha_enabled` is True, else `False` | +|`keycloak_modcluster_url` | _deprecated_ Host for the modcluster reverse proxy | `localhost` | +|`keycloak_modcluster_port` | _deprecated_ Port for the modcluster reverse proxy | `6666` | +|`keycloak_modcluster_urls` | List of {host,port} dicts for the modcluster reverse proxies | `[ { localhost:6666 } ]` | +|`keycloak_jdbc_engine` | backend database engine when db is enabled: [ postgres, mariadb, sqlserver ] | `postgres` | +|`keycloak_infinispan_url` | URL for the infinispan remote-cache server | `localhost:11122` | +|`keycloak_infinispan_user` | username for connecting to infinispan | `supervisor` | +|`keycloak_infinispan_pass` | password for connecting to infinispan | `supervisor` | +|`keycloak_infinispan_sasl_mechanism`| Authentication type | `SCRAM-SHA-512` | +|`keycloak_infinispan_use_ssl`| Enable hotrod TLS communication | `False` | +|`keycloak_infinispan_trust_store_path`| Path to truststore with infinispan server certificate | `/etc/pki/java/cacerts` | +|`keycloak_infinispan_trust_store_password`| Password for opening truststore | `changeit` | -The following variables are _required_ only when `keycloak_db_enabled` is True: +The following parameters are _required_ only when `keycloak_db_enabled` is true: | Variable | Description | Default | |:---------|:------------|:---------| @@ -145,12 +161,17 @@ The following variables are _required_ only when `keycloak_db_enabled` is True: |`keycloak_db_pass` | password for connecting to postgres | `keycloak-pass` | -Example Playbooks +The following variables are _optional_: + +| Variable | Description | +|:---------|:------------| +|`keycloak_db_valid_conn_sql` | Override the default database connection validation query sql | +|`keycloak_admin_url` | Override the default administration endpoint URL | +|`keycloak_jgroups_subnet`| Override the subnet match for jgroups cluster formation; if not defined, it will be inferred from local machine route configuration | + +Example Playbook ----------------- -_NOTE_: use ansible vaults or other security systems for storing credentials. - - * The following is an example playbook that makes use of the role to install keycloak from remote: ```yaml @@ -158,33 +179,10 @@ _NOTE_: use ansible vaults or other security systems for storing credentials. - hosts: ... vars: keycloak_admin_password: "remembertochangeme" - collections: - - middleware_automation.keycloak roles: - middleware_automation.keycloak.keycloak ``` -* The following is an example playbook that makes use of the role to install Red Hat Single Sign-On from RHN: - -```yaml ---- -- name: Playbook for RHSSO - hosts: keycloak - collections: - - middleware_automation.redhat_csp_download - roles: - - redhat_csp_download - tasks: - - name: Keycloak Role - include_role: - name: keycloak - vars: - keycloak_admin_password: "remembertochangeme" - keycloak_rhsso_enable: True - rhn_username: '' - rhn_password: '' -``` - * The following example playbook makes use of the role to install keycloak from the controller node: @@ -199,49 +197,10 @@ _NOTE_: use ansible vaults or other security systems for storing credentials. name: keycloak vars: keycloak_admin_password: "remembertochangeme" - keycloak_offline_install: True + keycloak_offline_install: true # This should be the filename of keycloak archive on Ansible node: keycloak-16.1.0.zip ``` - -* This playbook installs Red Hat Single Sign-On from an alternate url: - -```yaml ---- -- hosts: keycloak - collections: - - middleware_automation.keycloak - tasks: - - name: Keycloak Role - include_role: - name: keycloak - vars: - keycloak_admin_password: "remembertochangeme" - keycloak_rhsso_enable: True - keycloak_rhsso_download_url: "" - # This should be the full of remote source rhsso zip file and can contain basic authentication credentials -``` - - -* The following is an example playbook that makes use of the role to install Red Hat Single Sign-On offline from the controller node, and apply latest cumulative patch: - -```yaml ---- -- hosts: keycloak - collections: - - middleware_automation.keycloak - tasks: - - name: Keycloak Role - include_role: - name: keycloak - vars: - keycloak_admin_password: "remembertochangeme" - keycloak_rhsso_enable: True - keycloak_offline_install: True - keycloak_rhsso_apply_patches: True - # This should be the filename of rhsso zip file on Ansible node: rh-sso-7.5-server-dist.zip -``` - License ------- diff --git a/roles/keycloak/defaults/main.yml b/roles/keycloak/defaults/main.yml index 7ef632a..137111f 100644 --- a/roles/keycloak/defaults/main.yml +++ b/roles/keycloak/defaults/main.yml @@ -1,38 +1,38 @@ --- ### Configuration specific to keycloak -keycloak_version: 15.0.2 -keycloak_archive: "keycloak-{{ keycloak_version }}.zip" -keycloak_download_url: "https://github.com/keycloak/keycloak/releases/download/{{ keycloak_version }}/{{ keycloak_archive }}" +keycloak_version: 18.0.2 +keycloak_archive: "keycloak-legacy-{{ keycloak_version }}.zip" +keycloak_download_url: "https://github.com/keycloak/keycloak/releases/download/{{ keycloak_version }}/{{ keycloak_archive }}" keycloak_download_url_9x: "https://downloads.jboss.org/keycloak/{{ keycloak_version }}/{{ keycloak_archive }}" keycloak_installdir: "{{ keycloak_dest }}/keycloak-{{ keycloak_version }}" - -### Configuration specific to Red Hat Single Sign-On -keycloak_rhsso_version: 7.5.0 -rhsso_rhn_id: "{{ rhsso_rhn_ids[keycloak_rhsso_version].id }}" -keycloak_rhsso_archive: "rh-sso-{{ keycloak_rhsso_version }}-server-dist.zip" -keycloak_rhsso_installdir: "{{ keycloak_dest }}/rh-sso-{{ keycloak_rhsso_version | regex_replace('^([0-9])\\.([0-9]*).*', '\\1.\\2') }}" -keycloak_rhn_url: 'https://access.redhat.com/jbossnetwork/restricted/softwareDownload.html?softwareId=' -keycloak_rhsso_download_url: "{{ keycloak_rhn_url }}{{ rhsso_rhn_id }}" -keycloak_rhsso_apply_patches: False - -### keycloak/rhsso choice: by default install rhsso if rhn credentials are defined -keycloak_rhsso_enable: "{{ True if rhsso_rhn_id is defined and rhn_username is defined and rhn_password is defined else False }}" -# whether to install from local archive; filename must be keycloak_archive or keycloak_rhsso_archive depending on keycloak_rhsso_enable -keycloak_offline_install: False +keycloak_offline_install: false ### Install location and service settings -keycloak_jvm_package: java-1.8.0-openjdk-headless keycloak_java_home: keycloak_dest: /opt/keycloak -keycloak_jboss_home: "{{ keycloak_rhsso_installdir if keycloak_rhsso_enable else keycloak_installdir }}" +keycloak_jboss_home: "{{ keycloak_installdir }}" +keycloak_jboss_port_offset: 0 keycloak_config_dir: "{{ keycloak_jboss_home }}/standalone/configuration" keycloak_config_standalone_xml: "keycloak.xml" keycloak_config_path_to_standalone_xml: "{{ keycloak_jboss_home }}/standalone/configuration/{{ keycloak_config_standalone_xml }}" keycloak_config_override_template: '' +keycloak_config_path_to_properties: "{{ keycloak_jboss_home }}/standalone/configuration/profile.properties" +keycloak_service_runas: false keycloak_service_user: keycloak keycloak_service_group: keycloak -keycloak_service_pidfile: "/run/keycloak.pid" -keycloak_configure_firewalld: False +keycloak_service_pidfile: "/run/keycloak/keycloak.pid" +keycloak_service_name: keycloak +keycloak_service_desc: Keycloak +keycloak_service_start_delay: 10 +keycloak_service_start_retries: 25 +keycloak_service_restart_always: false +keycloak_service_restart_on_failure: false +keycloak_service_startlimitintervalsec: "300" +keycloak_service_startlimitburst: "5" +keycloak_service_restartsec: "10s" + +keycloak_configure_firewalld: false +keycloak_configure_iptables: false ### administrator console password keycloak_admin_password: '' @@ -44,44 +44,62 @@ keycloak_http_port: 8080 keycloak_https_port: 8443 keycloak_ajp_port: 8009 keycloak_jgroups_port: 7600 +keycloak_jgroups_subnet: +keycloak_management_port_bind_address: 127.0.0.1 keycloak_management_http_port: 9990 keycloak_management_https_port: 9993 keycloak_java_opts: "-Xms1024m -Xmx2048m" -keycloak_prefer_ipv4: True +keycloak_prefer_ipv4: true +keycloak_features: [] ### Enable configuration for database backend, clustering and remote caches on infinispan -keycloak_ha_enabled: False +keycloak_ha_enabled: false ### Enable database configuration, must be enabled when HA is configured keycloak_db_enabled: "{{ True if keycloak_ha_enabled else False }}" +### Discovery protocol for ha cluster members, valus [ 'JDBC_PING', 'TCPPING' ] +keycloak_ha_discovery: "{{ 'JDBC_PING' if keycloak_db_enabled else 'TCPPING' }}" +### Remote cache store on infinispan cluster +keycloak_remote_cache_enabled: "{{ True if keycloak_ha_enabled else False }}" ### Keycloak administration console user keycloak_admin_user: admin keycloak_auth_realm: master keycloak_auth_client: admin-cli -keycloak_force_install: False +keycloak_force_install: false -### mod_cluster reverse proxy +### mod_cluster reverse proxy list +keycloak_modcluster_enabled: "{{ True if keycloak_ha_enabled else False }}" keycloak_modcluster_url: localhost +keycloak_modcluster_port: 6666 +keycloak_modcluster_urls: + - host: "{{ keycloak_modcluster_url }}" + port: "{{ keycloak_modcluster_port }}" ### keycloak frontend url -keycloak_frontend_url: http://localhost:8080/auth +keycloak_frontend_url: http://localhost:8080/auth/ +keycloak_frontend_url_force: false +keycloak_admin_url: ### infinispan remote caches access (hotrod) -infinispan_user: supervisor -infinispan_pass: supervisor -infinispan_url: localhost -infinispan_sasl_mechanism: SCRAM-SHA-512 -infinispan_use_ssl: False +keycloak_infinispan_user: supervisor +keycloak_infinispan_pass: supervisor +keycloak_infinispan_url: localhost +keycloak_infinispan_sasl_mechanism: SCRAM-SHA-512 +keycloak_infinispan_use_ssl: false # if ssl is enabled, import ispn server certificate here -infinispan_trust_store_path: /etc/pki/java/cacerts -infinispan_trust_store_password: changeit +keycloak_infinispan_trust_store_path: /etc/pki/java/cacerts +keycloak_infinispan_trust_store_password: changeit -### database backend engine: values [ 'postgres', 'mariadb' ] +### database backend engine: values [ 'postgres', 'mariadb', 'sqlserver' ] keycloak_jdbc_engine: postgres ### database backend credentials keycloak_db_user: keycloak-user keycloak_db_pass: keycloak-pass +## connection validation +keycloak_db_background_validation: false +keycloak_db_background_validation_millis: "{{ 10000 if keycloak_db_background_validation else 0 }}" +keycloak_db_background_validate_on_match: false keycloak_jdbc_url: "{{ keycloak_default_jdbc[keycloak_jdbc_engine].url }}" keycloak_jdbc_driver_version: "{{ keycloak_default_jdbc[keycloak_jdbc_engine].version }}" # override the variables above, following defaults show minimum supported versions @@ -92,3 +110,15 @@ keycloak_default_jdbc: mariadb: url: 'jdbc:mariadb://localhost:3306/keycloak' version: 2.7.4 + sqlserver: + url: 'jdbc:sqlserver://localhost:1433;databaseName=keycloak;' + version: 12.2.0 +# role specific vars +keycloak_no_log: true + +### logging configuration +keycloak_log_target: /var/log/keycloak + +# locations +keycloak_url: "http://{{ keycloak_host }}:{{ keycloak_http_port + keycloak_jboss_port_offset }}" +keycloak_management_url: "http://{{ keycloak_host }}:{{ keycloak_management_http_port + keycloak_jboss_port_offset }}" diff --git a/roles/keycloak/meta/argument_specs.yml b/roles/keycloak/meta/argument_specs.yml index 983d59d..5f6052d 100644 --- a/roles/keycloak/meta/argument_specs.yml +++ b/roles/keycloak/meta/argument_specs.yml @@ -2,82 +2,38 @@ argument_specs: main: options: keycloak_version: - # line 3 of keycloak/defaults/main.yml - default: "15.0.2" + default: "18.0.2" description: "keycloak.org package version" type: "str" keycloak_archive: - # line 4 of keycloak/defaults/main.yml - default: "keycloak-{{ keycloak_version }}.zip" + default: "keycloak-legacy-{{ keycloak_version }}.zip" description: "keycloak install archive filename" type: "str" + keycloak_configure_iptables: + default: false + description: "Ensure iptables is running and configure keycloak ports" + type: "bool" keycloak_configure_firewalld: - # line 33 of keycloak/defaults/main.yml default: false description: "Ensure firewalld is running and configure keycloak ports" type: "bool" keycloak_download_url: - # line 5 of keycloak/defaults/main.yml default: "https://github.com/keycloak/keycloak/releases/download/{{ keycloak_version }}/{{ keycloak_archive }}" description: "Download URL for keycloak" type: "str" keycloak_download_url_9x: - # line 6 of keycloak/defaults/main.yml default: "https://downloads.jboss.org/keycloak/{{ keycloak_version }}/{{ keycloak_archive }}" description: "Download URL for keycloak (deprecated)" type: "str" keycloak_installdir: - # line 7 of keycloak/defaults/main.yml default: "{{ keycloak_dest }}/keycloak-{{ keycloak_version }}" description: "Installation path" type: "str" - keycloak_rhsso_version: - # line 10 of keycloak/defaults/main.yml - default: "7.5.0" - description: "Red Hat Single Sign-On version" - type: "str" - rhsso_rhn_id: - # line 11 of keycloak/defaults/main.yml - default: "{{ rhsso_rhn_ids[keycloak_rhsso_version].id }}" - description: "Customer Portal product ID for Red Hat SSO" - type: "str" - keycloak_rhsso_archive: - # line 12 of keycloak/defaults/main.yml - default: "rh-sso-{{ keycloak_rhsso_version }}-server-dist.zip" - description: "ed Hat SSO install archive filename" - type: "str" - keycloak_rhsso_apply_patches: - # line 16 of keycloak/defaults/main.yml - default: false - description: "Install RHSSO more recent cumulative patch" - type: "bool" - keycloak_rhsso_installdir: - # line 13 of keycloak/defaults/main.yml - default: "{{ keycloak_dest }}/rh-sso-{{ keycloak_rhsso_version | regex_replace('^([0-9])\\.([0-9]*).*', '\\1.\\2') }}" - description: "Installation path for Red Hat SSO" - type: "str" - keycloak_rhn_url: - # line 14 of keycloak/defaults/main.yml - default: "https://access.redhat.com/jbossnetwork/restricted/softwareDownload.html?softwareId=" - description: "Base download URI for customer portal" - type: "str" - keycloak_rhsso_download_url: - # line 15 of keycloak/defaults/main.yml - default: "{{ keycloak_rhn_url }}{{ rhsso_rhn_id }}" - description: "Full download URI for Red Hat SSO" - type: "str" - keycloak_rhsso_enable: - # line 18 of keycloak/defaults/main.yml - default: "{{ True if rhsso_rhn_id is defined and rhn_username is defined and rhn_password is defined else False }}" - description: "Enable Red Hat Single Sign-on installation" - type: "str" keycloak_offline_install: - # line 20 of keycloak/defaults/main.yml default: false description: "Perform an offline install" type: "bool" keycloak_jvm_package: - # line 23 of keycloak/defaults/main.yml default: "java-1.8.0-openjdk-headless" description: "RHEL java package runtime rpm" type: "str" @@ -85,212 +41,345 @@ argument_specs: description: "JAVA_HOME of installed JRE, leave empty for using specified keycloak_jvm_package RPM path" type: "str" keycloak_dest: - # line 24 of keycloak/defaults/main.yml default: "/opt/keycloak" description: "Root installation directory" type: "str" keycloak_jboss_home: - # line 25 of keycloak/defaults/main.yml - default: "{{ keycloak_rhsso_installdir if keycloak_rhsso_enable else keycloak_installdir }}" + default: "{{ keycloak_installdir }}" description: "Installation work directory" type: "str" + keycloak_jboss_port_offset: + default: 0 + description: "Port offset for the JBoss socket binding" + type: "int" keycloak_config_dir: - # line 26 of keycloak/defaults/main.yml default: "{{ keycloak_jboss_home }}/standalone/configuration" description: "Path for configuration" type: "str" keycloak_config_standalone_xml: - # line 27 of keycloak/defaults/main.yml default: "keycloak.xml" description: "Service configuration filename" type: "str" keycloak_config_path_to_standalone_xml: - # line 28 of keycloak/defaults/main.yml default: "{{ keycloak_jboss_home }}/standalone/configuration/{{ keycloak_config_standalone_xml }}" description: "Custom path for configuration" type: "str" keycloak_config_override_template: - # line 30 of keycloak/defaults/main.yml default: "" description: "Path to custom template for standalone.xml configuration" type: "str" + keycloak_service_runas: + default: false + description: "Enable execution of service as `keycloak_service_user`" + type: "bool" keycloak_service_user: - # line 29 of keycloak/defaults/main.yml default: "keycloak" description: "posix account username" type: "str" keycloak_service_group: - # line 30 of keycloak/defaults/main.yml default: "keycloak" description: "posix account group" type: "str" keycloak_service_pidfile: - # line 31 of keycloak/defaults/main.yml - default: "/run/keycloak.pid" + default: "/run/keycloak/keycloak.pid" description: "PID file path for service" type: "str" + keycloak_features: + default: "[]" + description: > + List of `name`/`status` pairs of features (also known as profiles on RH-SSO) to `enable` or `disable`, + example: `[ { name: 'docker', status: 'enabled' } ]` + type: "list" keycloak_bind_address: - # line 34 of keycloak/defaults/main.yml default: "0.0.0.0" description: "Address for binding service ports" type: "str" + keycloak_management_port_bind_address: + default: "127.0.0.1" + description: "Address for binding the management ports" + type: "str" keycloak_host: - # line 35 of keycloak/defaults/main.yml default: "localhost" description: "Hostname for service" type: "str" keycloak_http_port: - # line 36 of keycloak/defaults/main.yml default: 8080 description: "Listening HTTP port" type: "int" keycloak_https_port: - # line 37 of keycloak/defaults/main.yml default: 8443 description: "Listening HTTPS port" type: "int" keycloak_ajp_port: - # line 38 of keycloak/defaults/main.yml default: 8009 description: "Listening AJP port" type: "int" keycloak_jgroups_port: - # line 39 of keycloak/defaults/main.yml default: 7600 description: "jgroups cluster tcp port" type: "int" keycloak_management_http_port: - # line 40 of keycloak/defaults/main.yml default: 9990 description: "Management port (http)" type: "int" keycloak_management_https_port: - # line 41 of keycloak/defaults/main.yml default: 9993 description: "Management port (https)" type: "int" keycloak_java_opts: - # line 42 of keycloak/defaults/main.yml default: "-Xms1024m -Xmx2048m" description: "Additional JVM options" type: "str" keycloak_prefer_ipv4: - # line 43 of keycloak/defaults/main.yml default: true description: "Prefer IPv4 stack and addresses for port binding" type: "bool" keycloak_ha_enabled: - # line 46 of keycloak/defaults/main.yml default: false description: "Enable auto configuration for database backend, clustering and remote caches on infinispan" type: "bool" + keycloak_ha_discovery: + default: "{{ 'JDBC_PING' if keycloak_db_enabled else 'TCPPING' }}" + description: "Discovery protocol for HA cluster members" + type: "str" keycloak_db_enabled: - # line 48 of keycloak/defaults/main.yml default: "{{ True if keycloak_ha_enabled else False }}" description: "Enable auto configuration for database backend" - type: "str" + type: "bool" keycloak_admin_user: - # line 51 of keycloak/defaults/main.yml default: "admin" description: "Administration console user account" type: "str" keycloak_auth_realm: - # line 52 of keycloak/defaults/main.yml default: "master" description: "Name for rest authentication realm" type: "str" keycloak_auth_client: - # line 53 of keycloak/defaults/main.yml default: "admin-cli" description: "Authentication client for configuration REST calls" type: "str" keycloak_force_install: - # line 55 of keycloak/defaults/main.yml default: false description: "Remove pre-existing versions of service" type: "bool" + keycloak_modcluster_enabled: + default: "{{ True if keycloak_ha_enabled else False }}" + description: "Enable configuration for modcluster subsystem" + type: "bool" keycloak_modcluster_url: - # line 58 of keycloak/defaults/main.yml default: "localhost" description: "URL for the modcluster reverse proxy" type: "str" + keycloak_modcluster_port: + default: 6666 + description: "Port for the modcluster reverse proxy" + type: "int" + keycloak_modcluster_urls: + default: "[ { host: 'localhost', port: 6666 } ]" + description: "List of modproxy node URLs in the format { host, port } for the modcluster reverse proxy" + type: "list" keycloak_frontend_url: - # line 59 of keycloak/defaults/main.yml default: "http://localhost" description: "Frontend URL for keycloak endpoints when a reverse proxy is used" type: "str" - infinispan_user: - # line 62 of keycloak/defaults/main.yml + keycloak_frontend_url_force: + default: false + description: "Force backend requests to use the frontend URL" + type: "bool" + keycloak_infinispan_user: default: "supervisor" description: "Username for connecting to infinispan" type: "str" - infinispan_pass: - # line 63 of keycloak/defaults/main.yml + keycloak_infinispan_pass: default: "supervisor" description: "Password for connecting to infinispan" type: "str" - infinispan_url: - # line 64 of keycloak/defaults/main.yml + keycloak_infinispan_url: default: "localhost" description: "URL for the infinispan remote-cache server" type: "str" - infinispan_sasl_mechanism: - # line 65 of keycloak/defaults/main.yml + keycloak_infinispan_sasl_mechanism: default: "SCRAM-SHA-512" description: "Authentication type to infinispan server" type: "str" - infinispan_use_ssl: - # line 66 of keycloak/defaults/main.yml + keycloak_infinispan_use_ssl: default: false description: "Enable hotrod client TLS communication" type: "bool" - infinispan_trust_store_path: - # line 68 of keycloak/defaults/main.yml + keycloak_infinispan_trust_store_path: default: "/etc/pki/java/cacerts" description: "TODO document argument" type: "str" - infinispan_trust_store_password: - # line 69 of keycloak/defaults/main.yml + keycloak_infinispan_trust_store_password: default: "changeit" description: "Path to truststore containing infinispan server certificate" type: "str" keycloak_jdbc_engine: - # line 72 of keycloak/defaults/main.yml default: "postgres" - description: "Backend database flavour when db is enabled: [ postgres, mariadb ]" + description: "Backend database flavour when db is enabled: [ postgres, mariadb, sqlserver ]" type: "str" keycloak_db_user: - # line 74 of keycloak/defaults/main.yml default: "keycloak-user" description: "Username for connecting to database" type: "str" keycloak_db_pass: - # line 75 of keycloak/defaults/main.yml default: "keycloak-pass" description: "Password for connecting to database" type: "str" keycloak_jdbc_url: - # line 76 of keycloak/defaults/main.yml default: "{{ keycloak_default_jdbc[keycloak_jdbc_engine].url }}" description: "URL for connecting to backend database" type: "str" keycloak_jdbc_driver_version: - # line 77 of keycloak/defaults/main.yml default: "{{ keycloak_default_jdbc[keycloak_jdbc_engine].version }}" description: "Version for the JDBC driver to download" type: "str" keycloak_admin_password: - # line 4 of keycloak/vars/main.yml required: true description: "Password for the administration console user account" type: "str" keycloak_url: - # line 12 of keycloak/vars/main.yml - default: "http://{{ keycloak_host }}:{{ keycloak_http_port }}" + default: "http://{{ keycloak_host }}:{{ keycloak_http_port + keycloak_jboss_port_offset }}" description: "URL for configuration rest calls" type: "str" keycloak_management_url: - # line 13 of keycloak/vars/main.yml - default: "http://{{ keycloak_host }}:{{ keycloak_management_http_port }}" + default: "http://{{ keycloak_host }}:{{ keycloak_management_http_port + keycloak_jboss_port_offset }}" description: "URL for management console rest calls" type: "str" + keycloak_service_name: + default: "keycloak" + description: "systemd service name for keycloak" + type: "str" + keycloak_service_desc: + default: "Keycloak" + description: "systemd description for keycloak" + type: "str" + keycloak_service_start_delay: + default: "10" + description: "Expected delay in ms before the service is expected to be available after start." + type: "int" + keycloak_service_start_retries: + default: "25" + description: "How many time should Ansible retry to connect to the service after it was started, before failing." + type: "int" + keycloak_service_restart_always: + default: false + description: "systemd restart always behavior activation for keycloak" + type: "bool" + keycloak_service_restart_on_failure: + default: false + description: "systemd restart on-failure behavior activation for keycloak" + type: "bool" + keycloak_service_startlimitintervalsec: + default: 300 + description: "systemd StartLimitIntervalSec for keycloak" + type: "int" + keycloak_service_startlimitburst: + default: 5 + description: "systemd StartLimitBurst for keycloak" + type: "int" + keycloak_service_restartsec: + default: "5s" + description: "systemd RestartSec for keycloak" + type: "str" + keycloak_no_log: + default: true + type: "bool" + description: "Changes default behavior for no_log for debugging purpose, do not change for production system." + keycloak_remote_cache_enabled: + default: "{{ True if keycloak_ha_enabled else False }}" + description: "Enable remote cache store when in clustered ha configurations" + type: "bool" + keycloak_db_background_validation: + default: false + description: "Enable background validation of database connection" + type: "bool" + keycloak_db_background_validation_millis: + default: "{{ 10000 if keycloak_db_background_validation else 0 }}" + description: "How frequenly the connection pool is validated in the background" + type: 'int' + keycloak_db_background_validate_on_match: + default: false + description: "Enable validate on match for database connections" + type: "bool" + keycloak_db_valid_conn_sql: + required: false + description: "Override the default database connection validation query sql" + type: "str" + keycloak_admin_url: + required: false + description: "Override the default administration endpoint URL" + type: "str" + keycloak_jgroups_subnet: + required: false + description: > + Override the subnet match for jgroups cluster formation; if not defined, it will be inferred from local machine route configuration + type: "str" + keycloak_log_target: + default: '/var/log/keycloak' + type: "str" + description: "Set the destination of the keycloak log folder link" + keycloak_jdbc_download_url: + description: "Override the default Maven Central download URL for the JDBC driver" + type: "str" + keycloak_jdbc_download_user: + description: "Set a username with which to authenticate when downloading JDBC drivers from an alternative location" + type: "str" + keycloak_jdbc_download_pass: + description: > + Set a password with which to authenticate when downloading JDBC drivers from an alternative location (requires keycloak_jdbc_download_user) + type: "str" + keycloak_jdbc_download_validate_certs: + default: true + description: "Allow the option to ignore invalid certificates when downloading JDBC drivers from a custom URL" + type: "bool" + downstream: + options: + sso_version: + default: "7.6.0" + description: "Red Hat Single Sign-On version" + type: "str" + sso_archive: + default: "rh-sso-{{ sso_version }}-server-dist.zip" + description: "Red Hat SSO install archive filename" + type: "str" + sso_dest: + default: "/opt/sso" + description: "Root installation directory" + type: "str" + sso_installdir: + default: "{{ sso_dest }}/rh-sso-{{ sso_version.split('.')[0] }}.{{ sso_version.split('.')[1] }}" + description: "Installation path for Red Hat SSO" + type: "str" + sso_apply_patches: + default: false + description: "Install Red Hat SSO most recent cumulative patch" + type: "bool" + sso_enable: + default: true + description: "Enable Red Hat Single Sign-on installation" + type: "str" + sso_offline_install: + default: false + description: "Perform an offline install" + type: "bool" + sso_service_name: + default: "sso" + description: "systemd service name for Single Sign-On" + type: "str" + sso_service_desc: + default: "Red Hat Single Sign-On" + description: "systemd description for Red Hat Single Sign-On" + type: "str" + sso_patch_version: + required: false + description: "Red Hat Single Sign-On latest cumulative patch version to apply; defaults to latest version when sso_apply_patches is True" + type: "str" + sso_patch_bundle: + default: "rh-sso-{{ sso_patch_version | default('[0-9]+[.][0-9]+[.][0-9]+') }}-patch.zip" + description: "Red Hat SSO patch archive filename" + type: "str" + sso_product_category: + default: "core.service.rhsso" + description: "JBossNetwork API category for Single Sign-On" + type: "str" diff --git a/roles/keycloak/meta/main.yml b/roles/keycloak/meta/main.yml index 4760762..59499e6 100644 --- a/roles/keycloak/meta/main.yml +++ b/roles/keycloak/meta/main.yml @@ -1,7 +1,7 @@ --- collections: - - middleware_automation.redhat_csp_download - - middleware_automation.wildfly + - middleware_automation.common + - ansible.posix galaxy_info: role_name: keycloak @@ -12,12 +12,12 @@ galaxy_info: license: Apache License 2.0 - min_ansible_version: "2.9" + min_ansible_version: "2.16" platforms: - - name: EL - versions: - - 8 + - name: EL + versions: + - "8" galaxy_tags: - keycloak diff --git a/roles/keycloak/tasks/debian.yml b/roles/keycloak/tasks/debian.yml new file mode 100644 index 0000000..acfadcc --- /dev/null +++ b/roles/keycloak/tasks/debian.yml @@ -0,0 +1,10 @@ +--- +- name: Include firewall config tasks + ansible.builtin.include_tasks: + file: iptables.yml + apply: + tags: + - firewall + when: keycloak_configure_iptables + tags: + - firewall diff --git a/roles/keycloak/tasks/fastpackages.yml b/roles/keycloak/tasks/fastpackages.yml index 78bc556..a89f7f6 100644 --- a/roles/keycloak/tasks/fastpackages.yml +++ b/roles/keycloak/tasks/fastpackages.yml @@ -1,22 +1,31 @@ --- -- name: Check packages to be installed - block: - - name: "Check if packages are already installed" - ansible.builtin.command: "rpm -q {{ packages_list | join(' ') }}" - args: - warn: no - register: rpm_info - changed_when: rpm_info.failed +- name: "Check if packages are already installed" # noqa command-instead-of-module this runs faster + ansible.builtin.command: "rpm -q {{ packages_list | join(' ') }}" + register: rpm_info + changed_when: false + failed_when: false + when: ansible_facts.os_family == "RedHat" - rescue: - - name: "Add missing packages to the yum install list" - ansible.builtin.set_fact: - packages_to_install: "{{ packages_to_install | default([]) + rpm_info.stdout_lines | map('regex_findall', 'package (.+) is not installed$') | flatten }}" - when: rpm_info.failed +- name: "Add missing packages to the yum install list" + ansible.builtin.set_fact: + packages_to_install: "{{ packages_to_install | default([]) + rpm_info.stdout_lines | \ + map('regex_findall', 'package (.+) is not installed$') | default([]) | flatten }}" + when: ansible_facts.os_family == "RedHat" - name: "Install packages: {{ packages_to_install }}" - become: yes - ansible.builtin.yum: + become: true + ansible.builtin.dnf: name: "{{ packages_to_install }}" state: present - when: packages_to_install | default([]) | length > 0 \ No newline at end of file + when: + - packages_to_install | default([]) | length > 0 + - ansible_facts.os_family == "RedHat" + +- name: "Install packages: {{ packages_list }}" + become: true + ansible.builtin.package: + name: "{{ packages_list }}" + state: present + when: + - packages_list | default([]) | length > 0 + - ansible_facts.os_family == "Debian" diff --git a/roles/keycloak/tasks/firewalld.yml b/roles/keycloak/tasks/firewalld.yml index 58a6cac..f48f580 100644 --- a/roles/keycloak/tasks/firewalld.yml +++ b/roles/keycloak/tasks/firewalld.yml @@ -6,19 +6,19 @@ - firewalld - name: Enable and start the firewalld service - become: yes + become: true ansible.builtin.systemd: name: firewalld - enabled: yes + enabled: true state: started -- name: "Configure firewall for {{ keycloak.service_name }} ports" - become: yes - firewalld: +- name: "Configure firewall ports for {{ keycloak.service_name }}" + become: true + ansible.posix.firewalld: port: "{{ item }}" permanent: true state: enabled - immediate: yes + immediate: true loop: - "{{ keycloak_http_port }}/tcp" - "{{ keycloak_https_port }}/tcp" diff --git a/roles/keycloak/tasks/install.yml b/roles/keycloak/tasks/install.yml index 26c5466..b620b03 100644 --- a/roles/keycloak/tasks/install.yml +++ b/roles/keycloak/tasks/install.yml @@ -11,47 +11,56 @@ quiet: true - name: Check for an existing deployment - become: yes + become: true ansible.builtin.stat: path: "{{ keycloak_jboss_home }}" register: existing_deploy - name: Stop and restart if existing deployment exists and install forced + when: existing_deploy.stat.exists and keycloak_force_install | bool block: - name: "Stop the old {{ keycloak.service_name }} service" - become: yes - ignore_errors: yes + become: true + failed_when: false ansible.builtin.systemd: name: keycloak state: stopped - name: "Remove the old {{ keycloak.service_name }} deployment" - become: yes + become: true ansible.builtin.file: path: "{{ keycloak_jboss_home }}" state: absent - when: existing_deploy.stat.exists and keycloak_force_install|bool - name: Check for an existing deployment after possible forced removal - become: yes + become: true ansible.builtin.stat: path: "{{ keycloak_jboss_home }}" -- name: "Create {{ keycloak.service_name }} service user/group" - become: yes +- name: "Create service user/group for {{ keycloak.service_name }}" + become: true ansible.builtin.user: name: "{{ keycloak_service_user }}" home: /opt/keycloak - system: yes - create_home: no + system: true + create_home: false -- name: "Create {{ keycloak.service_name }} install location" - become: yes +- name: "Create install location for {{ keycloak.service_name }}" + become: true ansible.builtin.file: dest: "{{ keycloak_dest }}" state: directory owner: "{{ keycloak_service_user }}" group: "{{ keycloak_service_group }}" - mode: 0750 + mode: '0750' + +- name: Create pidfile folder + become: true + ansible.builtin.file: + dest: "{{ keycloak_service_pidfile | dirname }}" + state: directory + owner: "{{ keycloak_service_user if keycloak_service_runas else omit }}" + group: "{{ keycloak_service_group if keycloak_service_runas else omit }}" + mode: '0750' ## check remote archive - name: Set download archive path @@ -59,7 +68,7 @@ archive: "{{ keycloak_dest }}/{{ keycloak.bundle }}" - name: Check download archive path - become: yes + become: true ansible.builtin.stat: path: "{{ archive }}" register: archive_path @@ -75,44 +84,68 @@ ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user url: "{{ keycloak_download_url }}" dest: "{{ local_path.stat.path }}/{{ keycloak.bundle }}" - mode: 0644 + mode: '0644' delegate_to: localhost + run_once: true when: - archive_path is defined - archive_path.stat is defined - not archive_path.stat.exists - - not keycloak_rhsso_enable + - not sso_enable is defined or not sso_enable - not keycloak_offline_install -- name: Perform download from RHN - middleware_automation.redhat_csp_download.redhat_csp_download: - url: "{{ keycloak_rhsso_download_url }}" - dest: "{{ local_path.stat.path }}/{{ keycloak.bundle }}" - username: "{{ rhn_username }}" - password: "{{ rhn_password }}" - no_log: "{{ omit_rhn_output | default(true) }}" +- name: Perform download from RHN using JBoss Network API delegate_to: localhost + run_once: true when: - archive_path is defined - archive_path.stat is defined - not archive_path.stat.exists - - keycloak_rhsso_enable + - sso_enable is defined and sso_enable - not keycloak_offline_install - - keycloak_rhn_url in keycloak_rhsso_download_url + block: + - name: Retrieve product download using JBoss Network API + middleware_automation.common.product_search: + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_type: DISTRIBUTION + product_version: "{{ sso_version.split('.')[:2] | join('.') }}" + product_category: "{{ sso_product_category }}" + register: rhn_products + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + + - name: Determine install zipfile from search results + ansible.builtin.set_fact: + rhn_filtered_products: "{{ rhn_products.results | selectattr('file_path', 'match', '[^/]*/' + sso_archive + '$') }}" + delegate_to: localhost + run_once: true + + - name: Download Red Hat Single Sign-On + middleware_automation.common.product_download: # noqa risky-file-permissions delegated, uses controller host user + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_id: "{{ (rhn_filtered_products | first).id }}" + dest: "{{ local_path.stat.path }}/{{ keycloak.bundle }}" + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true - name: Download rhsso archive from alternate location ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user url: "{{ keycloak_rhsso_download_url }}" dest: "{{ local_path.stat.path }}/{{ keycloak.bundle }}" - mode: 0644 + mode: '0644' delegate_to: localhost + run_once: true when: - archive_path is defined - archive_path.stat is defined - not archive_path.stat.exists - - keycloak_rhsso_enable + - sso_enable is defined and sso_enable - not keycloak_offline_install - - not keycloak_rhn_url in keycloak_rhsso_download_url + - keycloak_rhsso_download_url is defined - name: Check downloaded archive ansible.builtin.stat: @@ -127,29 +160,29 @@ dest: "{{ archive }}" owner: "{{ keycloak_service_user }}" group: "{{ keycloak_service_group }}" - mode: 0640 + mode: '0640' register: new_version_downloaded when: - not archive_path.stat.exists - local_archive_path.stat is defined - local_archive_path.stat.exists - become: yes + become: true - name: "Check target directory: {{ keycloak.home }}" ansible.builtin.stat: path: "{{ keycloak.home }}" register: path_to_workdir - become: yes + become: true -- name: "Extract {{ 'Red Hat Single Sign-On' if keycloak_rhsso_enable else 'Keycloak' }} archive on target" +- name: "Extract {{ keycloak_service_desc }} archive on target" ansible.builtin.unarchive: - remote_src: yes + remote_src: true src: "{{ archive }}" dest: "{{ keycloak_dest }}" creates: "{{ keycloak.home }}" owner: "{{ keycloak_service_user }}" group: "{{ keycloak_service_group }}" - become: yes + become: true when: - new_version_downloaded.changed or not path_to_workdir.stat.exists notify: @@ -167,43 +200,98 @@ owner: "{{ keycloak_service_user }}" group: "{{ keycloak_service_group }}" recurse: true - become: yes + become: true + changed_when: false + +- name: Ensure permissions are correct on existing deploy + ansible.builtin.command: chown -R "{{ keycloak_service_user }}:{{ keycloak_service_group }}" "{{ keycloak.home }}" + when: keycloak_service_runas + become: true changed_when: false # driver and configuration - name: "Install {{ keycloak_jdbc_engine }} driver" - ansible.builtin.include_role: - name: middleware_automation.wildfly.wildfly_driver - vars: - wildfly_user: "{{ keycloak_service_user }}" - jdbc_driver_module_dir: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}" - jdbc_driver_version: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_version }}" - jdbc_driver_jar_filename: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_jar_filename }}" - jdbc_driver_jar_url: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_jar_url }}" - jdbc_driver_jar_installation_path: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}/{{ keycloak_jdbc[keycloak_jdbc_engine].driver_jar_filename }}" - jdbc_driver_module_name: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }}" + ansible.builtin.include_tasks: jdbc_driver.yml when: keycloak_jdbc[keycloak_jdbc_engine].enabled -- name: "Deploy {{ keycloak.service_name }} config to {{ keycloak_config_path_to_standalone_xml }} from {{ keycloak.config_template_source }}" - become: yes +- name: "Deploy custom {{ keycloak.service_name }} config to {{ keycloak_config_path_to_standalone_xml }} from {{ keycloak_config_override_template }}" + become: true ansible.builtin.template: - src: "templates/{{ keycloak.config_template_source }}" + src: "templates/{{ keycloak_config_override_template }}" dest: "{{ keycloak_config_path_to_standalone_xml }}" owner: "{{ keycloak_service_user }}" group: "{{ keycloak_service_group }}" - mode: 0640 + mode: '0640' notify: - restart keycloak - when: not keycloak_remotecache.enabled or keycloak_config_override_template|length > 0 + when: keycloak_config_override_template | length > 0 -- name: "Deploy {{ keycloak.service_name }} config with remote cache store to {{ keycloak_config_path_to_standalone_xml }}" - become: yes +- name: "Deploy standalone {{ keycloak.service_name }} config to {{ keycloak_config_path_to_standalone_xml }}" + become: true + ansible.builtin.template: + src: templates/standalone.xml.j2 + dest: "{{ keycloak_config_path_to_standalone_xml }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0640' + notify: + - restart keycloak + when: + - not keycloak_ha_enabled + - keycloak_config_override_template | length == 0 + +- name: Create tcpping cluster node list + ansible.builtin.set_fact: + keycloak_cluster_nodes: > + {{ keycloak_cluster_nodes | default([]) + [ + { + "name": item, + "address": 'jgroups-' + item, + "inventory_host": hostvars[item].ansible_default_ipv4.address | default(item) + '[' + (keycloak_jgroups_port | string) + ']', + "value": hostvars[item].ansible_default_ipv4.address | default(item) + } + ] }} + loop: "{{ ansible_play_batch }}" + when: keycloak_ha_enabled and keycloak_ha_discovery == 'TCPPING' + +- name: "Deploy HA {{ keycloak.service_name }} config to {{ keycloak_config_path_to_standalone_xml }}" + become: true + ansible.builtin.template: + src: templates/standalone-ha.xml.j2 + dest: "{{ keycloak_config_path_to_standalone_xml }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0640' + notify: + - restart keycloak + when: + - keycloak_ha_enabled + - not keycloak_remote_cache_enabled + - keycloak_config_override_template | length == 0 + +- name: "Deploy HA {{ keycloak.service_name }} config with infinispan remote cache store to {{ keycloak_config_path_to_standalone_xml }}" + become: true ansible.builtin.template: src: templates/standalone-infinispan.xml.j2 dest: "{{ keycloak_config_path_to_standalone_xml }}" owner: "{{ keycloak_service_user }}" group: "{{ keycloak_service_group }}" - mode: 0640 + mode: '0640' notify: - restart keycloak - when: keycloak_remotecache.enabled + when: + - keycloak_ha_enabled + - keycloak_remote_cache_enabled + - keycloak_config_override_template | length == 0 + +- name: "Deploy profile.properties file to {{ keycloak_config_path_to_properties }}" + become: true + ansible.builtin.template: + src: keycloak-profile.properties.j2 + dest: "{{ keycloak_config_path_to_properties }}" + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0640' + notify: + - restart keycloak + when: keycloak_features | length > 0 diff --git a/roles/keycloak/tasks/iptables.yml b/roles/keycloak/tasks/iptables.yml new file mode 100644 index 0000000..8ebc16e --- /dev/null +++ b/roles/keycloak/tasks/iptables.yml @@ -0,0 +1,23 @@ +--- +- name: Ensure required package iptables are installed + ansible.builtin.include_tasks: fastpackages.yml + vars: + packages_list: + - iptables + +- name: "Configure firewall ports for {{ keycloak.service_name }}" + become: true + ansible.builtin.iptables: + destination_port: "{{ item }}" + action: "insert" + rule_num: 6 # magic number I forget why + chain: "INPUT" + policy: "ACCEPT" + protocol: tcp + loop: + - "{{ keycloak_http_port }}" + - "{{ keycloak_https_port }}" + - "{{ keycloak_management_http_port }}" + - "{{ keycloak_management_https_port }}" + - "{{ keycloak_jgroups_port }}" + - "{{ keycloak_ajp_port }}" diff --git a/roles/keycloak/tasks/jdbc_driver.yml b/roles/keycloak/tasks/jdbc_driver.yml new file mode 100644 index 0000000..bec80e3 --- /dev/null +++ b/roles/keycloak/tasks/jdbc_driver.yml @@ -0,0 +1,46 @@ +--- +- name: "Check module directory: {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}" + ansible.builtin.stat: + path: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}" + register: dest_path + become: true + +- name: "Set up module dir for JDBC Driver {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }}" + ansible.builtin.file: + path: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}" + state: directory + recurse: true + owner: "{{ keycloak_service_user }}" + group: "{{ keycloak_service_group }}" + mode: '0750' + become: true + when: + - not dest_path.stat.exists +- name: "Verify valid parameters for download credentials when specified" + ansible.builtin.fail: + msg: >- + When JDBC driver download credentials are set, both the username and the password MUST be set + when: > + (keycloak_jdbc_download_user is undefined and keycloak_jdbc_download_pass is not undefined) or + (keycloak_jdbc_download_pass is undefined and keycloak_jdbc_download_user is not undefined) + +- name: "Retrieve JDBC Driver from {{ keycloak_jdbc[keycloak_jdbc_engine].driver_jar_url }}" + ansible.builtin.get_url: + url: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_jar_url }}" + dest: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}/{{ keycloak_jdbc[keycloak_jdbc_engine].driver_jar_filename }}" + group: "{{ keycloak_service_group }}" + owner: "{{ keycloak_service_user }}" + url_username: "{{ keycloak_jdbc_download_user | default(omit) }}" + url_password: "{{ keycloak_jdbc_download_pass | default(omit) }}" + validate_certs: "{{ keycloak_jdbc_download_validate_certs | default(omit) }}" + mode: '0640' + become: true + +- name: "Deploy module.xml for JDBC Driver" + ansible.builtin.template: + src: "templates/jdbc_driver_module.xml.j2" + dest: "{{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_dir }}/module.xml" + group: "{{ keycloak_service_group }}" + owner: "{{ keycloak_service_user }}" + mode: '0640' + become: true diff --git a/roles/keycloak/tasks/main.yml b/roles/keycloak/tasks/main.yml index ba5ec87..f826b63 100644 --- a/roles/keycloak/tasks/main.yml +++ b/roles/keycloak/tasks/main.yml @@ -1,30 +1,47 @@ --- # tasks file for keycloak - - name: Check prerequisites - ansible.builtin.include_tasks: prereqs.yml + ansible.builtin.include_tasks: + file: prereqs.yml + apply: + tags: + - prereqs tags: - prereqs -- name: Include firewall config tasks - ansible.builtin.include_tasks: firewalld.yml - when: keycloak_configure_firewalld +- name: Distro specific tasks + ansible.builtin.include_tasks: + file: "{{ ansible_os_family | lower }}.yml" + apply: + tags: + - unbound tags: - - firewall + - unbound - name: Include install tasks - ansible.builtin.include_tasks: install.yml + ansible.builtin.include_tasks: + file: install.yml + apply: + tags: + - install tags: - install - name: Include systemd tasks - ansible.builtin.include_tasks: systemd.yml + ansible.builtin.include_tasks: + file: systemd.yml + apply: + tags: + - systemd tags: - systemd - name: Include patch install tasks ansible.builtin.include_tasks: rhsso_patch.yml - when: keycloak_rhsso_apply_patches and keycloak_rhsso_enable + when: + - sso_apply_patches is defined and sso_apply_patches + - sso_enable is defined and sso_enable + - ansible_facts.os_family == "RedHat" tags: - install - patch @@ -33,7 +50,8 @@ ansible.builtin.file: state: link src: "{{ keycloak_jboss_home }}/standalone/log" - dest: /var/log/keycloak + dest: "{{ keycloak_log_target }}" + become: true - name: Set admin credentials and restart if not already created block: @@ -42,7 +60,7 @@ url: "{{ keycloak_url }}/auth/realms/master/protocol/openid-connect/token" method: POST body: "client_id={{ keycloak_auth_client }}&username={{ keycloak_admin_user }}&password={{ keycloak_admin_password }}&grant_type=password" - validate_certs: no + validate_certs: false register: keycloak_auth_response until: keycloak_auth_response.status == 200 retries: 2 @@ -56,8 +74,8 @@ - "-rmaster" - "-u{{ keycloak_admin_user }}" - "-p{{ keycloak_admin_password }}" - changed_when: yes - become: yes + changed_when: true + become: true - name: "Restart {{ keycloak.service_name }}" ansible.builtin.include_tasks: tasks/restart_keycloak.yml - name: "Wait until {{ keycloak.service_name }} becomes active {{ keycloak.health_url }}" diff --git a/roles/keycloak/tasks/prereqs.yml b/roles/keycloak/tasks/prereqs.yml index 31735d5..d97390c 100644 --- a/roles/keycloak/tasks/prereqs.yml +++ b/roles/keycloak/tasks/prereqs.yml @@ -3,44 +3,56 @@ ansible.builtin.assert: that: - keycloak_admin_password | length > 12 - quiet: True - fail_msg: "The console administrator password is empty or invalid. Please set the keycloak_admin_password variable to a 12+ char long string" + quiet: true + fail_msg: > + The console administrator password is empty or invalid. Please set the keycloak_admin_password variable to a 12+ char long string success_msg: "{{ 'Console administrator password OK' }}" - name: Validate configuration ansible.builtin.assert: - that: - - (keycloak_ha_enabled and keycloak_db_enabled) or (not keycloak_ha_enabled and keycloak_db_enabled) or (not keycloak_ha_enabled and not keycloak_db_enabled) - quiet: True + that: > + (keycloak_ha_enabled and keycloak_db_enabled) or + (not keycloak_ha_enabled and keycloak_db_enabled) or + (not keycloak_ha_enabled and not keycloak_db_enabled) + quiet: true fail_msg: "Cannot install HA setup without a backend database service. Check keycloak_ha_enabled and keycloak_db_enabled" success_msg: "{{ 'Configuring HA' if keycloak_ha_enabled else 'Configuring standalone' }}" - name: Validate credentials ansible.builtin.assert: that: - - (rhn_username is defined and keycloak_rhsso_enable) or not keycloak_rhsso_enable or keycloak_offline_install - - (rhn_password is defined and keycloak_rhsso_enable) or not keycloak_rhsso_enable or keycloak_offline_install - quiet: True + - (rhn_username is defined and sso_enable is defined and sso_enable) or not sso_enable is defined or not sso_enable or keycloak_offline_install + - (rhn_password is defined and sso_enable is defined and sso_enable) or not sso_enable is defined or not sso_enable or keycloak_offline_install + quiet: true fail_msg: "Cannot install Red Hat SSO without RHN credentials. Check rhn_username and rhn_password are defined" - success_msg: "{{ 'Installing Red Hat Single Sign-On' if keycloak_rhsso_enable else 'Installing keycloak.org' }}" + success_msg: "Installing {{ keycloak_service_desc }}" - name: Validate persistence configuration ansible.builtin.assert: that: - - keycloak_jdbc_engine is defined and keycloak_jdbc_engine in [ 'postgres', 'mariadb' ] + - keycloak_jdbc_engine is defined and keycloak_jdbc_engine in [ 'postgres', 'mariadb', 'sqlserver' ] - keycloak_jdbc_url | length > 0 - keycloak_db_user | length > 0 - keycloak_db_pass | length > 0 - quiet: True + quiet: true fail_msg: "Configuration for the JDBC persistence is invalid or incomplete" success_msg: "Configuring JDBC persistence using {{ keycloak_jdbc_engine }} database" when: keycloak_db_enabled +- name: Validate OS family + ansible.builtin.assert: + that: + - ansible_os_family in ["RedHat", "Debian"] + quiet: true + fail_msg: "Can only install on RedHat or Debian OS families; found {{ ansible_os_family }}" + success_msg: "Installing on {{ ansible_os_family }}" + +- name: Load OS specific variables + ansible.builtin.include_vars: "vars/{{ ansible_os_family | lower }}.yml" + tags: + - always + - name: Ensure required packages are installed ansible.builtin.include_tasks: fastpackages.yml vars: - packages_list: - - "{{ keycloak_jvm_package }}" - - unzip - - procps-ng - - initscripts \ No newline at end of file + packages_list: "{{ keycloak_prereq_package_list }}" diff --git a/roles/keycloak/tasks/redhat.yml b/roles/keycloak/tasks/redhat.yml new file mode 100644 index 0000000..ece5772 --- /dev/null +++ b/roles/keycloak/tasks/redhat.yml @@ -0,0 +1,10 @@ +--- +- name: Include firewall config tasks + ansible.builtin.include_tasks: + file: firewalld.yml + apply: + tags: + - firewall + when: keycloak_configure_firewalld + tags: + - firewall diff --git a/roles/keycloak/tasks/restart_keycloak.yml b/roles/keycloak/tasks/restart_keycloak.yml index eff9ddf..7284bd0 100644 --- a/roles/keycloak/tasks/restart_keycloak.yml +++ b/roles/keycloak/tasks/restart_keycloak.yml @@ -2,6 +2,27 @@ - name: "Restart and enable {{ keycloak.service_name }} service" ansible.builtin.systemd: name: keycloak - enabled: yes + enabled: true state: restarted - become: yes + daemon_reload: true + become: true + delegate_to: "{{ ansible_play_hosts | first }}" + run_once: true + +- name: "Wait until {{ keycloak.service_name }} becomes active {{ keycloak.health_url }}" + ansible.builtin.uri: + url: "{{ keycloak.health_url }}" + register: keycloak_status + until: keycloak_status.status == 200 + delegate_to: "{{ ansible_play_hosts | first }}" + run_once: true + retries: "{{ keycloak_service_start_retries }}" + delay: "{{ keycloak_service_start_delay }}" + +- name: "Restart and enable {{ keycloak.service_name }} service" + ansible.builtin.systemd: + name: keycloak + enabled: true + state: restarted + become: true + when: inventory_hostname != ansible_play_hosts | first diff --git a/roles/keycloak/tasks/rhsso_cli.yml b/roles/keycloak/tasks/rhsso_cli.yml index c51cdc7..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 \ No newline at end of file + register: cli_result diff --git a/roles/keycloak/tasks/rhsso_patch.yml b/roles/keycloak/tasks/rhsso_patch.yml index f517e7a..23d75bf 100644 --- a/roles/keycloak/tasks/rhsso_patch.yml +++ b/roles/keycloak/tasks/rhsso_patch.yml @@ -2,65 +2,141 @@ ## check remote patch archive - name: Set download patch archive path ansible.builtin.set_fact: - patch_archive: "{{ keycloak_dest }}/{{ keycloak.patch_bundle }}" + patch_archive: "{{ keycloak_dest }}/{{ sso_patch_bundle }}" + patch_bundle: "{{ sso_patch_bundle }}" + patch_version: "{{ sso_patch_version }}" + when: sso_patch_version is defined - name: Check download patch archive path ansible.builtin.stat: path: "{{ patch_archive }}" register: patch_archive_path + when: sso_patch_version is defined + become: true -- name: Perform download from RHN - middleware_automation.redhat_csp_download.redhat_csp_download: - url: "{{ keycloak_rhn_url }}{{ rhsso_rhn_ids[keycloak_rhsso_version].latest_cp.id }}" - dest: "{{ local_path.stat.path }}/{{ keycloak.patch_bundle }}" - username: "{{ rhn_username }}" - password: "{{ rhn_password }}" - no_log: "{{ omit_rhn_output | default(true) }}" +- name: Perform patch download from RHN via JBossNetwork API delegate_to: localhost + run_once: true when: - - patch_archive_path is defined - - patch_archive_path.stat is defined - - not patch_archive_path.stat.exists - - keycloak_rhsso_enable + - sso_enable is defined and sso_enable - not keycloak_offline_install + - sso_apply_patches + block: + - name: Retrieve product download using JBossNetwork API + middleware_automation.common.product_search: + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_type: BUGFIX + product_version: "{{ sso_version.split('.')[:2] | join('.') }}" + product_category: "{{ sso_product_category }}" + register: rhn_products + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + + - name: Determine patch versions list + ansible.builtin.set_fact: + filtered_versions: "{{ rhn_products.results | map(attribute='file_path') | \ + select('match', '^[^/]*/rh-sso-.*[0-9]*[.][0-9]*[.][0-9]*.*$') | \ + map('regex_replace', '[^/]*/rh-sso-([0-9]*[.][0-9]*[.][0-9]*(-[0-9])?)-.*', '\\1') | list | unique }}" + when: sso_patch_version is not defined or sso_patch_version | length == 0 + delegate_to: localhost + run_once: true + + - name: Determine latest version + ansible.builtin.set_fact: + sso_latest_version: "{{ filtered_versions | middleware_automation.common.version_sort | last }}" + when: sso_patch_version is not defined or sso_patch_version | length == 0 + delegate_to: localhost + run_once: true + + - name: Determine install zipfile from search results + ansible.builtin.set_fact: + rhn_filtered_products: "{{ rhn_products.results | selectattr('file_path', 'match', '[^/]*/rh-sso-' + sso_latest_version + '-patch.zip$') }}" + patch_bundle: "rh-sso-{{ sso_latest_version }}-patch.zip" + patch_version: "{{ sso_latest_version }}" + when: sso_patch_version is not defined or sso_patch_version | length == 0 + delegate_to: localhost + run_once: true + + - name: "Determine selected patch from supplied version: {{ sso_patch_version }}" + ansible.builtin.set_fact: + rhn_filtered_products: "{{ rhn_products.results | selectattr('file_path', 'match', '[^/]*/' + sso_patch_bundle + '$') }}" + patch_bundle: "{{ sso_patch_bundle }}" + patch_version: "{{ sso_patch_version }}" + when: sso_patch_version is defined + delegate_to: localhost + run_once: true + + - name: Download Red Hat Single Sign-On patch + middleware_automation.common.product_download: # noqa risky-file-permissions delegated, uses controller host user + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_id: "{{ (rhn_filtered_products | sort | last).id }}" + dest: "{{ local_path.stat.path }}/{{ patch_bundle }}" + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + +- name: Set download patch archive path + ansible.builtin.set_fact: + patch_archive: "{{ keycloak_dest }}/{{ patch_bundle }}" + +- name: Check download patch archive path + ansible.builtin.stat: + path: "{{ patch_archive }}" + register: patch_archive_path + become: true ## copy and unpack - name: Copy patch archive to target nodes ansible.builtin.copy: - src: "{{ local_path.stat.path }}/{{ keycloak.patch_bundle }}" + src: "{{ local_path.stat.path }}/{{ patch_bundle }}" dest: "{{ patch_archive }}" owner: "{{ keycloak_service_user }}" group: "{{ keycloak_service_group }}" - mode: 0640 + mode: '0640' register: new_version_downloaded when: - not patch_archive_path.stat.exists - local_archive_path.stat is defined - local_archive_path.stat.exists - become: yes + become: true - name: "Check installed patches" ansible.builtin.include_tasks: rhsso_cli.yml vars: - query: "patch info" + cli_query: "patch info" + args: + apply: + become: true + become_user: "{{ keycloak_service_user }}" - name: "Perform patching" - when: + when: - cli_result is defined - cli_result.stdout is defined - - rhsso_rhn_ids[keycloak_rhsso_version].latest_cp.v not in cli_result.stdout + - patch_version | regex_replace('-[0-9]$', '') not in cli_result.stdout block: - - name: "Apply patch {{ rhsso_rhn_ids[keycloak_rhsso_version].latest_cp.v }} to server" + - 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 + become_user: "{{ keycloak_service_user }}" - 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: + apply: + become: true + become_user: "{{ keycloak_service_user }}" - name: "Wait until {{ keycloak.service_name }} becomes active {{ keycloak.health_url }}" ansible.builtin.uri: @@ -73,15 +149,23 @@ - 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 + become_user: "{{ keycloak_service_user }}" + - name: "Verify installed patch version" ansible.builtin.assert: that: - - rhsso_rhn_ids[keycloak_rhsso_version].latest_cp.v not in cli_result.stdout + - patch_version not in cli_result.stdout fail_msg: "Patch installation failed" success_msg: "Patch installation successful" - name: "Skipping patch" ansible.builtin.debug: - msg: "Latest cumulative patch {{ rhsso_rhn_ids[keycloak_rhsso_version].latest_cp.v }} already installed, skipping patch installation." + msg: "Cumulative patch {{ patch_version }} already installed, skipping patch installation." + when: + - cli_result is defined + - cli_result.stdout is defined + - patch_version in cli_result.stdout diff --git a/roles/keycloak/tasks/start_keycloak.yml b/roles/keycloak/tasks/start_keycloak.yml index bdf42f9..5aed248 100644 --- a/roles/keycloak/tasks/start_keycloak.yml +++ b/roles/keycloak/tasks/start_keycloak.yml @@ -2,14 +2,15 @@ - name: "Start {{ keycloak.service_name }} service" ansible.builtin.systemd: name: keycloak - enabled: yes + enabled: true state: started - become: yes + daemon_reload: true + become: true - name: "Wait until {{ keycloak.service_name }} becomes active {{ keycloak.health_url }}" ansible.builtin.uri: url: "{{ keycloak.health_url }}" register: keycloak_status until: keycloak_status.status == 200 - retries: 25 - delay: 10 \ No newline at end of file + retries: "{{ keycloak_service_start_retries }}" + delay: "{{ keycloak_service_start_delay }}" diff --git a/roles/keycloak/tasks/stop_keycloak.yml b/roles/keycloak/tasks/stop_keycloak.yml index fd87802..7f30433 100644 --- a/roles/keycloak/tasks/stop_keycloak.yml +++ b/roles/keycloak/tasks/stop_keycloak.yml @@ -2,6 +2,6 @@ - name: "Stop {{ keycloak.service_name }}" ansible.builtin.systemd: name: keycloak - enabled: yes + enabled: true state: stopped - become: yes + become: true diff --git a/roles/keycloak/tasks/systemd.yml b/roles/keycloak/tasks/systemd.yml index 871180f..1653406 100644 --- a/roles/keycloak/tasks/systemd.yml +++ b/roles/keycloak/tasks/systemd.yml @@ -1,34 +1,23 @@ --- - name: "Configure {{ keycloak.service_name }} service script wrapper" - become: yes + become: true ansible.builtin.template: src: keycloak-service.sh.j2 dest: "{{ keycloak_dest }}/keycloak-service.sh" owner: root group: root - mode: 0755 + mode: '0755' notify: - restart keycloak -- name: Determine JAVA_HOME for selected JVM RPM # noqa blocked_modules - ansible.builtin.shell: | - set -o pipefail - rpm -ql {{ keycloak_jvm_package }} | grep -Po '/usr/lib/jvm/.*(?=/bin/java$)' - args: - executable: /bin/bash - changed_when: False - register: rpm_java_home - - name: "Configure sysconfig file for {{ keycloak.service_name }} service" - become: yes + become: true ansible.builtin.template: src: keycloak-sysconfig.j2 - dest: /etc/sysconfig/keycloak + dest: "{{ keycloak_sysconf_file }}" owner: root group: root - mode: 0644 - vars: - keycloak_rpm_java_home: "{{ rpm_java_home.stdout }}" + mode: '0644' notify: - restart keycloak @@ -38,21 +27,15 @@ dest: /etc/systemd/system/keycloak.service owner: root group: root - mode: 0644 - become: yes + mode: '0644' + become: true register: systemdunit notify: - restart keycloak -- name: Reload systemd - become: yes - ansible.builtin.systemd: - daemon_reload: yes - when: systemdunit.changed - - name: "Start and wait for {{ keycloak.service_name }} service (first node db)" ansible.builtin.include_tasks: start_keycloak.yml - run_once: yes + run_once: true when: keycloak_db_enabled - name: "Start and wait for {{ keycloak.service_name }} service (remaining nodes)" @@ -61,7 +44,7 @@ - name: Check service status ansible.builtin.command: "systemctl status keycloak" register: keycloak_service_status - changed_when: False + changed_when: false - name: Verify service status ansible.builtin.assert: diff --git a/roles/keycloak/templates/15.0.8/standalone-infinispan.xml.j2 b/roles/keycloak/templates/15.0.8/standalone-infinispan.xml.j2 new file mode 100644 index 0000000..25d6cb0 --- /dev/null +++ b/roles/keycloak/templates/15.0.8/standalone-infinispan.xml.j2 @@ -0,0 +1,761 @@ + +{{ ansible_managed | comment('xml') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + {{ keycloak_jdbc[keycloak_jdbc_engine].connection_url }} + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }} + + 20 + + + {{ keycloak_jdbc[keycloak_jdbc_engine].db_user }} + {{ keycloak_jdbc[keycloak_jdbc_engine].db_password }} + +{% else %} + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + +{% endif %} + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_class }} + {{ keycloak_jdbc[keycloak_jdbc_engine].xa_datasource_class }} + +{% endif %} + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% for cachename in [ "sessions", "offlineSessions", "clientSessions", "offlineClientSessions", "loginFailures", "actionTokens", "authenticationSessions" ] %} + + + true + org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory + false + {{ keycloak_remotecache.username }} + {{ keycloak_remotecache.password }} + {{ keycloak_remotecache.realm | default('default') }} + {{ keycloak_remotecache.server_name }} + {{ keycloak_remotecache.sasl_mechanism }} + {{ keycloak_remotecache.use_ssl }} + {{ keycloak_remotecache.trust_store_path }} + JKS + {{ keycloak_remotecache.trust_store_password }} + TOPOLOGY_AWARE + + +{% endfor %} + + + true + org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory + false + {{ keycloak_remotecache.username }} + {{ keycloak_remotecache.password }} + {{ keycloak_remotecache.realm | default('default') }} + {{ keycloak_remotecache.server_name }} + {{ keycloak_remotecache.sasl_mechanism }} + {{ keycloak_remotecache.use_ssl }} + {{ keycloak_remotecache.trust_store_path }} + JKS + {{ keycloak_remotecache.trust_store_password }} + TOPOLOGY_AWARE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + java:jboss/datasources/KeycloakDS + {{ keycloak_jdbc[keycloak_jdbc_engine].initialize_db }} + INSERT INTO JGROUPSPING (own_addr, cluster_name, ping_data) values (?, ?, ?) + DELETE FROM JGROUPSPING WHERE own_addr=? AND cluster_name=? + SELECT ping_data FROM JGROUPSPING WHERE cluster_name=? + +{% endif %} + + + + + + + + + 30000 + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + +{% if keycloak_ha_enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + default + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if ansible_default_ipv4 is defined %} + +{% else %} + +{% endif %} + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + +{% endif %} + + + + + diff --git a/roles/keycloak/templates/15.0.8/standalone.xml.j2 b/roles/keycloak/templates/15.0.8/standalone.xml.j2 new file mode 100644 index 0000000..01c317b --- /dev/null +++ b/roles/keycloak/templates/15.0.8/standalone.xml.j2 @@ -0,0 +1,658 @@ + +{{ ansible_managed | comment('xml') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + {{ keycloak_jdbc[keycloak_jdbc_engine].connection_url }} + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }} + + 20 + + + {{ keycloak_jdbc[keycloak_jdbc_engine].db_user }} + {{ keycloak_jdbc[keycloak_jdbc_engine].db_password }} + +{% else %} + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + +{% endif %} + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_class }} + {{ keycloak_jdbc[keycloak_jdbc_engine].xa_datasource_class }} + +{% endif %} + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + +{% if keycloak_ha_enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + default + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + +{% endif %} + + diff --git a/roles/keycloak/templates/9.0.2/standalone-infinispan.xml.j2 b/roles/keycloak/templates/9.0.2/standalone-infinispan.xml.j2 index 2b2842b..4f90ad8 100644 --- a/roles/keycloak/templates/9.0.2/standalone-infinispan.xml.j2 +++ b/roles/keycloak/templates/9.0.2/standalone-infinispan.xml.j2 @@ -725,7 +725,7 @@ {% if ansible_default_ipv4 is defined %} - + {% else %} {% endif %} @@ -734,7 +734,7 @@ - + diff --git a/roles/keycloak/templates/9.0.2/standalone.xml.j2 b/roles/keycloak/templates/9.0.2/standalone.xml.j2 index 823357f..4188e92 100644 --- a/roles/keycloak/templates/9.0.2/standalone.xml.j2 +++ b/roles/keycloak/templates/9.0.2/standalone.xml.j2 @@ -598,7 +598,7 @@ - + diff --git a/roles/keycloak/templates/jdbc_driver_module.xml.j2 b/roles/keycloak/templates/jdbc_driver_module.xml.j2 new file mode 100644 index 0000000..c513693 --- /dev/null +++ b/roles/keycloak/templates/jdbc_driver_module.xml.j2 @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/roles/keycloak/templates/keycloak-profile.properties.j2 b/roles/keycloak/templates/keycloak-profile.properties.j2 new file mode 100644 index 0000000..c618dc2 --- /dev/null +++ b/roles/keycloak/templates/keycloak-profile.properties.j2 @@ -0,0 +1,3 @@ +{% for feature in keycloak.features %} +feature.{{ feature.name }}={{ feature.status | default('enabled') }} +{% endfor %} \ No newline at end of file diff --git a/roles/keycloak/templates/keycloak-service.sh.j2 b/roles/keycloak/templates/keycloak-service.sh.j2 index 577959e..98efb34 100755 --- a/roles/keycloak/templates/keycloak-service.sh.j2 +++ b/roles/keycloak/templates/keycloak-service.sh.j2 @@ -1,5 +1,5 @@ #!/bin/bash -eu -# {{ ansible_managed }} +{{ ansible_managed | comment }} set +u -o pipefail diff --git a/roles/keycloak/templates/keycloak-sysconfig.j2 b/roles/keycloak/templates/keycloak-sysconfig.j2 index 68474c3..33889df 100644 --- a/roles/keycloak/templates/keycloak-sysconfig.j2 +++ b/roles/keycloak/templates/keycloak-sysconfig.j2 @@ -1,6 +1,6 @@ -# {{ ansible_managed }} +{{ ansible_managed | comment }} JAVA_OPTS='{{ keycloak_java_opts }}' -JAVA_HOME={{ keycloak_java_home | default(keycloak_rpm_java_home, true) }} +JAVA_HOME={{ keycloak_java_home | default(keycloak_pkg_java_home, true) }} JBOSS_HOME={{ keycloak.home }} KEYCLOAK_BIND_ADDRESS={{ keycloak_bind_address }} KEYCLOAK_HTTP_PORT={{ keycloak_http_port }} @@ -8,4 +8,12 @@ KEYCLOAK_HTTPS_PORT={{ keycloak_https_port }} KEYCLOAK_MANAGEMENT_HTTP_PORT={{ keycloak_management_http_port }} KEYCLOAK_MANAGEMENT_HTTPS_PORT={{ keycloak_management_https_port }} JBOSS_PIDFILE='{{ keycloak_service_pidfile }}' -LAUNCH_JBOSS_IN_BACKGROUND=1 \ No newline at end of file + +WILDFLY_OPTS=-Djboss.bind.address=${KEYCLOAK_BIND_ADDRESS} \ + -Djboss.http.port=${KEYCLOAK_HTTP_PORT} \ + -Djboss.https.port=${KEYCLOAK_HTTPS_PORT} \ + -Djboss.management.http.port=${KEYCLOAK_MANAGEMENT_HTTP_PORT} \ + -Djboss.management.https.port=${KEYCLOAK_MANAGEMENT_HTTPS_PORT} \ + -Djboss.node.name={{ inventory_hostname }} \ + {% if keycloak_prefer_ipv4 %}-Djava.net.preferIPv4Stack=true -Djava.net.preferIPv4Addresses=true {% endif %}\ + {% if keycloak_config_standalone_xml is defined %}--server-config={{ keycloak_config_standalone_xml }}{% endif %} diff --git a/roles/keycloak/templates/keycloak.service.j2 b/roles/keycloak/templates/keycloak.service.j2 index 0fcecbf..9a04e88 100644 --- a/roles/keycloak/templates/keycloak.service.j2 +++ b/roles/keycloak/templates/keycloak.service.j2 @@ -1,17 +1,29 @@ -# {{ ansible_managed }} +{{ ansible_managed | comment }} [Unit] Description={{ keycloak.service_name }} Server After=network.target +StartLimitIntervalSec={{ keycloak_service_startlimitintervalsec }} +StartLimitBurst={{ keycloak_service_startlimitburst }} + [Service] -Type=forking -EnvironmentFile=-/etc/sysconfig/keycloak +{% if keycloak_service_runas %} +User={{ keycloak_service_user }} +Group={{ keycloak_service_group }} +{% endif -%} +EnvironmentFile=-{{ keycloak_sysconf_file }} PIDFile={{ keycloak_service_pidfile }} -ExecStart={{ keycloak_dest }}/keycloak-service.sh start -ExecStop={{ keycloak_dest }}/keycloak-service.sh stop +ExecStart={{ keycloak.home }}/bin/standalone.sh $WILDFLY_OPTS +WorkingDirectory={{ keycloak.home }} TimeoutStartSec=30 TimeoutStopSec=30 LimitNOFILE=102642 +{% if keycloak_service_restart_always %} +Restart=always +{% elif keycloak_service_restart_on_failure %} +Restart=on-failure +{% endif %} +RestartSec={{ keycloak_service_restartsec }} [Install] WantedBy=multi-user.target diff --git a/roles/keycloak/templates/standalone-ha.xml.j2 b/roles/keycloak/templates/standalone-ha.xml.j2 new file mode 100644 index 0000000..d027c35 --- /dev/null +++ b/roles/keycloak/templates/standalone-ha.xml.j2 @@ -0,0 +1,707 @@ + +{{ ansible_managed | comment('xml') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + h2 + + sa + sa + + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + {{ keycloak_jdbc[keycloak_jdbc_engine].connection_url }} + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_module_name }} + + 20 + + + {{ keycloak_jdbc[keycloak_jdbc_engine].db_user }} + {{ keycloak_jdbc[keycloak_jdbc_engine].db_password }} + + + {{ keycloak_jdbc[keycloak_jdbc_engine].validate_query }} + {{ keycloak_db_background_validate_on_match }} +{% if keycloak_db_background_validation_millis | int > 0 or keycloak_db_background_validation %} + {{ keycloak_db_background_validation }} + {{ keycloak_db_background_validation_millis }} +{% endif %} + +{% else %} + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + h2 + + sa + sa + +{% endif %} + + +{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + {{ keycloak_jdbc[keycloak_jdbc_engine].driver_class }} + {{ keycloak_jdbc[keycloak_jdbc_engine].xa_datasource_class }} + +{% endif %} + + org.h2.jdbcx.JdbcDataSource + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_ha_discovery == 'JDBC_PING' and keycloak_jdbc[keycloak_jdbc_engine].enabled %} + + java:jboss/datasources/KeycloakDS + {{ keycloak_jdbc[keycloak_jdbc_engine].initialize_db }} + INSERT INTO JGROUPSPING (own_addr, cluster_name, ping_data) values (?, ?, ?) + DELETE FROM JGROUPSPING WHERE own_addr=? AND cluster_name=? + SELECT ping_data FROM JGROUPSPING WHERE cluster_name=? + +{% elif keycloak_ha_discovery == 'TCPPING' %} + + {{ keycloak_cluster_nodes | map(attribute='inventory_host') | join (',') }} + 0 + 3000 + 2 + +{% endif %} + + + + + + + + + 30000 + + + + + + + + + + + + + + + + auth + + + classpath:${jboss.home.dir}/providers/* + + + master + 900 + + 2592000 + true + true + ${jboss.home.dir}/themes + +{% if keycloak_ha_enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + jpa + + + basic + + + + + + + + + + + + + + + + + + + default + + + + + + + + ${keycloak.jta.lookup.provider:jboss} + + + + + + + + + + + ${keycloak.x509cert.lookup.provider:default} + + + + default + + + + +{% if keycloak_modcluster.admin_url | length > 0 %} + +{% endif %} + + + + + + + + + + +{% if keycloak_modcluster.enabled %} + + + + + + + +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if keycloak_jgroups_subnet is defined and keycloak_jgroups_subnet is not none and keycloak_jgroups_subnet | string | length > 0 %} + +{% elif ansible_default_ipv4 is defined and (ansible_default_ipv4.network + '/' + ansible_default_ipv4.netmask) | ansible.utils.ipaddr('net') | length > 0 %} + +{% else %} + +{% endif %} + + + + + + + + + + + + + + + + + +{% if keycloak_modcluster.enabled %} +{% for modcluster in keycloak_modcluster.reverse_proxy_urls %} + + + +{% endfor %} +{% endif %} +{% if keycloak_ha_discovery == 'TCPPING' %} +{% for node in keycloak_cluster_nodes %} + + + +{% endfor %} +{% endif %} + + + + + diff --git a/roles/keycloak/templates/standalone-infinispan.xml.j2 b/roles/keycloak/templates/standalone-infinispan.xml.j2 index 8e58b53..18e5a7c 100644 --- a/roles/keycloak/templates/standalone-infinispan.xml.j2 +++ b/roles/keycloak/templates/standalone-infinispan.xml.j2 @@ -1,5 +1,5 @@ - +{{ ansible_managed | comment('xml') }} @@ -16,7 +16,6 @@ - @@ -31,31 +30,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - @@ -70,8 +44,8 @@ - - + + @@ -162,6 +136,14 @@ {{ keycloak_jdbc[keycloak_jdbc_engine].db_user }} {{ keycloak_jdbc[keycloak_jdbc_engine].db_password }} + + {{ keycloak_jdbc[keycloak_jdbc_engine].validate_query }} + {{ keycloak_db_background_validate_on_match }} +{% if keycloak_db_background_validation_millis | int > 0 or keycloak_db_background_validation %} + {{ keycloak_db_background_validation }} + {{ keycloak_db_background_validation_millis }} +{% endif %} + {% else %} jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE h2 @@ -244,6 +226,9 @@ + + + @@ -317,6 +302,13 @@ + + + + + + + @@ -504,7 +496,7 @@ -{% if keycloak_jdbc[keycloak_jdbc_engine].enabled %} +{% if keycloak_ha_discovery == 'JDBC_PING' and keycloak_jdbc[keycloak_jdbc_engine].enabled %} java:jboss/datasources/KeycloakDS {{ keycloak_jdbc[keycloak_jdbc_engine].initialize_db }} @@ -512,6 +504,13 @@ DELETE FROM JGROUPSPING WHERE own_addr=? AND cluster_name=? SELECT ping_data FROM JGROUPSPING WHERE cluster_name=? +{% elif keycloak_ha_discovery == 'TCPPING' %} + + {{ keycloak_cluster_nodes | map(attribute='inventory_host') | join (',') }} + 0 + 3000 + 2 + {% endif %} @@ -620,7 +619,10 @@ - + +{% if keycloak_modcluster.admin_url | length > 0 %} + +{% endif %} @@ -631,54 +633,22 @@ -{% if keycloak_modcluster.enabled %} +{% if keycloak_modcluster.enabled %} - + -{% endif %} +{% endif %} - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -701,10 +671,10 @@ - + - + @@ -715,6 +685,9 @@ + + + @@ -724,20 +697,22 @@ - + -{% if ansible_default_ipv4 is defined %} - +{% if keycloak_jgroups_subnet is defined and keycloak_jgroups_subnet is not none and keycloak_jgroups_subnet | string | length > 0 %} + +{% elif ansible_default_ipv4 is defined and (ansible_default_ipv4.network + '/' + ansible_default_ipv4.netmask) | ansible.utils.ipaddr('net') | length > 0 %} + {% else %} {% endif %} - + - + @@ -750,9 +725,18 @@ {% if keycloak_modcluster.enabled %} - - +{% for modcluster in keycloak_modcluster.reverse_proxy_urls %} + + +{% endfor %} +{% endif %} +{% if keycloak_ha_discovery == 'TCPPING' %} +{% for node in keycloak_cluster_nodes %} + + + +{% endfor %} {% endif %} diff --git a/roles/keycloak/templates/standalone.xml.j2 b/roles/keycloak/templates/standalone.xml.j2 index 3207ef9..6c3c0f8 100644 --- a/roles/keycloak/templates/standalone.xml.j2 +++ b/roles/keycloak/templates/standalone.xml.j2 @@ -1,5 +1,5 @@ - +{{ ansible_managed | comment('xml') }} @@ -15,7 +15,6 @@ - @@ -30,31 +29,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - @@ -69,8 +43,8 @@ - - + + @@ -149,6 +123,14 @@ {{ keycloak_jdbc[keycloak_jdbc_engine].db_user }} {{ keycloak_jdbc[keycloak_jdbc_engine].db_password }} + + {{ keycloak_jdbc[keycloak_jdbc_engine].validate_query }} + {{ keycloak_db_background_validate_on_match }} +{% if keycloak_db_background_validation_millis | int > 0 or keycloak_db_background_validation %} + {{ keycloak_db_background_validation }} + {{ keycloak_db_background_validation_millis }} +{% endif %} + {% else %} jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE h2 @@ -231,6 +213,9 @@ + + + @@ -304,6 +289,13 @@ + + + + + + + @@ -533,7 +525,10 @@ - + +{% if keycloak_modcluster.admin_url | length > 0 %} + +{% endif %} @@ -544,54 +539,22 @@ -{% if keycloak_modcluster.enabled %} +{% if keycloak_modcluster.enabled %} - + -{% endif %} +{% endif %} - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -614,10 +577,10 @@ - + - + @@ -627,18 +590,21 @@ + + + - + - + - + @@ -649,10 +615,12 @@ -{% if keycloak_modcluster.enabled %} - - +{% if keycloak_modcluster.enabled %} +{% for modcluster in keycloak_modcluster.reverse_proxy_urls %} + + -{% endif %} +{% endfor %} +{% endif %} diff --git a/roles/keycloak/vars/debian.yml b/roles/keycloak/vars/debian.yml new file mode 100644 index 0000000..b005b0a --- /dev/null +++ b/roles/keycloak/vars/debian.yml @@ -0,0 +1,12 @@ +--- +keycloak_varjvm_package: "{{ keycloak_jvm_package | default('openjdk-11-jdk-headless') }}" +keycloak_prereq_package_list: + - "{{ keycloak_varjvm_package }}" + - unzip + - procps + - apt + - tzdata +keycloak_configure_iptables: true +keycloak_sysconf_file: /etc/default/keycloak +keycloak_pkg_java_home: "/usr/lib/jvm/java-{{ keycloak_varjvm_package | \ + regex_search('(?!:openjdk-)[0-9.]+') }}-openjdk-{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}" diff --git a/roles/keycloak/vars/main.yml b/roles/keycloak/vars/main.yml index 5a6b059..fe706db 100644 --- a/roles/keycloak/vars/main.yml +++ b/roles/keycloak/vars/main.yml @@ -1,26 +1,18 @@ --- # internal variables below -rhsso_rhn_ids: - '7.5.0': # noqa vars_in_vars_files_have_valid_names - id: '101971' - latest_cp: - id: '103836' - v: '7.5.1' -# locations -keycloak_url: "http://{{ keycloak_host }}:{{ keycloak_http_port }}" -keycloak_management_url: "http://{{ keycloak_host }}:{{ keycloak_management_http_port }}" keycloak: home: "{{ keycloak_jboss_home }}" config_dir: "{{ keycloak_config_dir }}" - bundle: "{{ keycloak_rhsso_archive if keycloak_rhsso_enable else keycloak_archive }}" - patch_bundle: "rh-sso-{{ rhsso_rhn_ids[keycloak_rhsso_version].latest_cp.v }}-patch.zip" - service_name: "{{ 'rhsso' if keycloak_rhsso_enable else 'keycloak' }}" + bundle: "{{ keycloak_archive }}" + service_name: "{{ keycloak_service_name }}" health_url: "{{ keycloak_management_url }}/health" cli_path: "{{ keycloak_jboss_home }}/bin/jboss-cli.sh" - config_template_source: "{{ keycloak_config_override_template if keycloak_config_override_template | length > 0 else 'standalone.xml.j2' }}" + config_template_source: "{{ keycloak_config_override_template if keycloak_config_override_template | length > 0 \ + else 'standalone-ha.xml.j2' if keycloak_remote_cache_enabled else 'standalone.xml.j2' }}" + features: "{{ keycloak_features }}" # database keycloak_jdbc: @@ -32,10 +24,12 @@ keycloak_jdbc: driver_module_dir: "{{ keycloak_jboss_home }}/modules/org/postgresql/main" driver_version: "{{ keycloak_jdbc_driver_version }}" driver_jar_filename: "postgresql-{{ keycloak_jdbc_driver_version }}.jar" - driver_jar_url: "https://repo.maven.apache.org/maven2/org/postgresql/postgresql/{{ keycloak_jdbc_driver_version }}/postgresql-{{ keycloak_jdbc_driver_version }}.jar" + driver_jar_url: > + {{ keycloak_maven_central }}org/postgresql/postgresql/{{ keycloak_jdbc_driver_version }}/postgresql-{{ keycloak_jdbc_driver_version }}.jar connection_url: "{{ keycloak_jdbc_url }}" db_user: "{{ keycloak_db_user }}" db_password: "{{ keycloak_db_pass }}" + validate_query: "{{ keycloak_db_valid_conn_sql | default('select 1') }}" initialize_db: > CREATE TABLE IF NOT EXISTS JGROUPSPING ( own_addr varchar(200) NOT NULL, @@ -46,15 +40,17 @@ keycloak_jdbc: mariadb: enabled: "{{ (keycloak_ha_enabled or keycloak_db_enabled) and keycloak_jdbc_engine == 'mariadb' }}" driver_class: org.mariadb.jdbc.Driver - xa_datasource_class: org.mariadb.jdbc.MySQLDataSource + xa_datasource_class: org.mariadb.jdbc.MariaDbDataSource driver_module_name: "org.mariadb" driver_module_dir: "{{ keycloak_jboss_home }}/modules/org/mariadb/main" driver_version: "{{ keycloak_jdbc_driver_version }}" driver_jar_filename: "mariadb-java-client-{{ keycloak_jdbc_driver_version }}.jar" - driver_jar_url: "https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/{{ keycloak_jdbc_driver_version }}/mariadb-java-client-{{ keycloak_jdbc_driver_version }}.jar" + driver_jar_url: > + {{ keycloak_maven_central }}org/mariadb/jdbc/mariadb-java-client/{{ keycloak_jdbc_driver_version }}/mariadb-java-client-{{ keycloak_jdbc_driver_version }}.jar connection_url: "{{ keycloak_jdbc_url }}" - db_user: "{{ keycloak_db_user }}" + db_user: "{{ keycloak_db_user }}" db_password: "{{ keycloak_db_pass }}" + validate_query: "{{ keycloak_db_valid_conn_sql | default('select 1') }}" initialize_db: > CREATE TABLE IF NOT EXISTS JGROUPSPING ( own_addr varchar(200) NOT NULL, @@ -63,21 +59,49 @@ keycloak_jdbc: ping_data varbinary(5000) DEFAULT NULL, PRIMARY KEY (own_addr, cluster_name)) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin + sqlserver: + enabled: "{{ (keycloak_ha_enabled or keycloak_db_enabled) and keycloak_jdbc_engine == 'sqlserver' }}" + driver_class: com.microsoft.sqlserver.jdbc.SQLServerDriver + xa_datasource_class: com.microsoft.sqlserver.jdbc.SQLServerXADataSource + driver_module_name: "com.microsoft.sqlserver" + driver_module_dir: "{{ keycloak_jboss_home }}/modules/com/microsoft/sqlserver/main" + driver_version: "{{ keycloak_jdbc_driver_version }}" + driver_jar_filename: "mssql-java-client-{{ keycloak_jdbc_driver_version }}.jar" + driver_jar_url: > + {{ keycloak_maven_central }}com/microsoft/sqlserver/mssql-jdbc/{{ keycloak_jdbc_driver_version }}.jre11/mssql-jdbc-{{ keycloak_jdbc_driver_version }}.jre11.jar + connection_url: "{{ keycloak_jdbc_url }}" + db_user: "{{ keycloak_db_user }}" + db_password: "{{ keycloak_db_pass }}" + validate_query: "{{ keycloak_db_valid_conn_sql | default('select 1') }}" + initialize_db: > + IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[JGROUPSPING]') AND type in (N'U')) + BEGIN + CREATE TABLE JGROUPSPING ( + own_addr varchar(200) NOT NULL, + cluster_name varchar(200) NOT NULL, + updated DATETIME2 DEFAULT SYSUTCDATETIME(), + ping_data varbinary(5000) DEFAULT NULL, + PRIMARY KEY (own_addr, cluster_name)) + END # reverse proxy mod_cluster keycloak_modcluster: - enabled: "{{ keycloak_ha_enabled }}" - reverse_proxy_url: "{{ keycloak_modcluster_url }}" + enabled: "{{ keycloak_ha_enabled or keycloak_modcluster_enabled }}" + reverse_proxy_urls: "{{ keycloak_modcluster_urls }}" frontend_url: "{{ keycloak_frontend_url }}" + force_frontend_url: "{{ keycloak_frontend_url_force }}" + admin_url: "{{ keycloak_admin_url | default('') }}" # infinispan keycloak_remotecache: enabled: "{{ keycloak_ha_enabled }}" - username: "{{ infinispan_user }}" - password: "{{ infinispan_pass }}" + username: "{{ keycloak_infinispan_user }}" + password: "{{ keycloak_infinispan_pass }}" realm: default - sasl_mechanism: "{{ infinispan_sasl_mechanism }}" - server_name: "{{ infinispan_url }}" - use_ssl: "{{ infinispan_use_ssl }}" - trust_store_path: "{{ infinispan_trust_store_path }}" - trust_store_password: "{{ infinispan_trust_store_password }}" \ No newline at end of file + sasl_mechanism: "{{ keycloak_infinispan_sasl_mechanism }}" + server_name: "{{ keycloak_infinispan_url }}" + use_ssl: "{{ keycloak_infinispan_use_ssl }}" + trust_store_path: "{{ keycloak_infinispan_trust_store_path }}" + trust_store_password: "{{ keycloak_infinispan_trust_store_password }}" + +keycloak_maven_central: https://repo1.maven.org/maven2/ diff --git a/roles/keycloak/vars/redhat.yml b/roles/keycloak/vars/redhat.yml new file mode 100644 index 0000000..6c36847 --- /dev/null +++ b/roles/keycloak/vars/redhat.yml @@ -0,0 +1,10 @@ +--- +keycloak_varjvm_package: "{{ keycloak_jvm_package | default('java-1.8.0-openjdk-headless') }}" +keycloak_prereq_package_list: + - "{{ keycloak_varjvm_package }}" + - unzip + - procps-ng + - initscripts + - tzdata-java +keycloak_sysconf_file: /etc/sysconfig/keycloak +keycloak_pkg_java_home: "/etc/alternatives/jre_{{ keycloak_varjvm_package | regex_search('(?<=java-)[0-9.]+') }}" diff --git a/roles/keycloak_quarkus/README.md b/roles/keycloak_quarkus/README.md index 769e2f7..c461203 100644 --- a/roles/keycloak_quarkus/README.md +++ b/roles/keycloak_quarkus/README.md @@ -1,88 +1,181 @@ keycloak_quarkus ================ + +Install [keycloak](https://keycloak.org/) >= 20.0.0 (quarkus) server configurations. + -Install [keycloak](https://keycloak.org/) >= 17.0.0 (quarkus) server configurations. +Requirements +------------ +This role requires the `python3-netaddr` and `lxml` library installed on the controller node. + +* to install via yum/dnf: `dnf install python3-netaddr python3-lxml` +* to install via apt: `apt install python3-netaddr python3-lxml` +* or via the collection: `pip install -r requirements.txt` + + +Dependencies +------------ + +The roles depends on: + +* [middleware_automation.common](https://github.com/ansible-middleware/common) +* [ansible-posix](https://docs.ansible.com/ansible/latest/collections/ansible/posix/index.html) + +To install all the dependencies via galaxy: + + ansible-galaxy collection install -r requirements.yml Role Defaults ------------- -* Installation options +#### Installation options | Variable | Description | Default | |:---------|:------------|:--------| -|`keycloak_quarkus_version`| keycloak.org package version | `17.0.1` | +|`keycloak_quarkus_version`| keycloak.org package version | `26.2.4` | +|`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 +#### Service configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_bootstrap_admin_user`| Administration console user account | `admin` | +|`keycloak_quarkus_admin_user`| Deprecated, use `keycloak_quarkus_bootstrap_admin_user` instead. | | +|`keycloak_quarkus_bind_address`| Deprecated, use `keycloak_quarkus_http_host` instead | `0.0.0.0` | +|`keycloak_quarkus_host`| Deprecated, use `keycloak_quarkus_hostname` instead. | | +|`keycloak_quarkus_port`| Deprecated, use `keycloak_quarkus_hostname` instead. | | +|`keycloak_quarkus_path`| Deprecated, use `keycloak_quarkus_hostname` instead. | | +|`keycloak_quarkus_service_user`| Posix account username | `keycloak` | +|`keycloak_quarkus_service_group`| Posix account group | `keycloak` | +|`keycloak_quarkus_service_restart_always`| systemd restart always behavior activation | `False` | +|`keycloak_quarkus_service_restart_on_failure`| systemd restart on-failure behavior activation | `False` | +|`keycloak_quarkus_service_restartsec`| systemd RestartSec | `10s` | +|`keycloak_quarkus_jvm_package`| RHEL java package runtime | `java-21-openjdk-headless` | +|`keycloak_quarkus_java_home`| JAVA_HOME of installed JRE, leave empty for using specified keycloak_quarkus_jvm_package RPM path | `None` | +|`keycloak_quarkus_java_heap_opts`| Heap memory JVM setting | `-Xms1024m -Xmx2048m` | +|`keycloak_quarkus_java_jvm_opts`| Other JVM settings | same as keycloak | +|`keycloak_quarkus_java_opts`| JVM arguments; if overridden, it takes precedence over `keycloak_quarkus_java_*` | `{{ keycloak_quarkus_java_heap_opts + ' ' + keycloak_quarkus_java_jvm_opts }}` | +|`keycloak_quarkus_additional_env_vars` | List of additional env variables of { key: str, value: str} to be put in sysconfig file | `[]` | +|`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 | `""` | +|`keycloak_quarkus_configure_firewalld` | Ensure firewalld is running and configure keycloak ports | `False` | +|`keycloak_quarkus_configure_iptables` | Ensure iptables is configured for keycloak ports | `False` | + + +#### High-availability | Variable | Description | Default | |:---------|:------------|:--------| |`keycloak_quarkus_ha_enabled`| Enable auto configuration for database backend, clustering and remote caches on infinispan | `False` | +|`keycloak_quarkus_ha_discovery`| Discovery protocol for HA cluster members | `JDBCPING` | |`keycloak_quarkus_db_enabled`| Enable auto configuration for database backend | `True` if `keycloak_quarkus_ha_enabled` is True, else `False` | -|`keycloak_quarkus_admin_user`| Administration console user account | `admin` | -|`keycloak_quarkus_bind_address`| Address for binding service ports | `0.0.0.0` | -|`keycloak_quarkus_host`| hostname | `localhost` | -|`keycloak_quarkus_http_port`| HTTP port | `8080` | -|`keycloak_quarkus_https_port`| TLS HTTP port | `8443` | -|`keycloak_quarkus_ajp_port`| AJP port | `8009` | -|`keycloak_quarkus_jgroups_port`| jgroups cluster tcp port | `7600` | -|`keycloak_quarkus_service_user`| Posix account username | `keycloak` | -|`keycloak_quarkus_service_group`| Posix account group | `keycloak` | -|`keycloak_quarkus_service_pidfile`| Pid file path for service | `/run/keycloak.pid` | -|`keycloak_quarkus_jvm_package`| RHEL java package runtime | `java-11-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_opts`| Additional JVM options | `-Xms1024m -Xmx2048m` | -|`keycloak_quarkus_frontend_url`| Service public URL | `http://localhost:8080/auth` | -|`keycloak_quarkus_http_relative_path` | Service context path | `auth` | -|`keycloak_quarkus_http_enabled`| Enable listener on HTTP port | `True` | -|`keycloak_quarkus_https_enabled`| Enable listener on HTTPS port | `False` | -|`keycloak_quarkus_key_file`| The file path to a private key in PEM format | `{{ keycloak.home }}/conf/server.key.pem` | -|`keycloak_quarkus_cert_file`| The file path to a server certificate or certificate chain in PEM format | `{{ keycloak.home }}/conf/server.crt.pem` | +|`keycloak_quarkus_jgroups_ip`| Host jgroups IP. If changing this variable you must make sure it is always set for all hosts in your cluster. | `{{ ansible_default_ipv4.address }}` | +|`keycloak_quarkus_jgroups_port`| jgroups cluster tcp port | `7800` | +|`keycloak_quarkus_systemd_wait_for_port` | Whether systemd unit should wait for keycloak port before returning | `{{ keycloak_quarkus_ha_enabled }}` | +|`keycloak_quarkus_systemd_wait_for_port_number`| Which port the systemd unit should wait for | `{{ keycloak_quarkus_https_port }}` | +|`keycloak_quarkus_systemd_wait_for_log` | Whether systemd unit should wait for service to be up in logs | `false` | +|`keycloak_quarkus_systemd_wait_for_timeout`| How long to wait for service to be alive (seconds) | `60` | +|`keycloak_quarkus_systemd_wait_for_delay`| Activation delay for service systemd unit (seconds) | `10` | +|`keycloak_quarkus_restart_strategy`| Strategy task file for restarting in HA (one of provided restart/['serial.yml','none.yml','serial_then_parallel.yml']) or path to file when providing custom strategy | `restart/serial.yml` | +|`keycloak_quarkus_restart_health_check`| Whether to wait for successful health check after restart | `true` | +|`keycloak_quarkus_restart_health_check_delay`| Seconds to let pass before starting healch checks | `10` | +|`keycloak_quarkus_restart_health_check_retries`| Number of attempts for successful health check before failing | `25` | +|`keycloak_quarkus_restart_pause`| Seconds to wait between restarts in HA strategy | `15` | -* Database configuration +#### Hostname configuration | Variable | Description | Default | |:---------|:------------|:--------| -|`keycloak_quarkus_jdbc_engine` | Database engine [mariadb,postres] | `postgres` | +|`keycloak_quarkus_hostname`| Address at which is the server exposed. Can be a full URL, or just a hostname. When only hostname is provided, scheme, port and context path are resolved from the request. | | +|`keycloak_quarkus_hostname_admin`| Set the base URL for accessing the administration console, including scheme, host, port and path | | +|`keycloak_quarkus_hostname_strict`| Disables dynamically resolving the hostname from request headers | `true` | +|`keycloak_quarkus_hostname_backchannel_dynamic`| Enables dynamic resolving of backchannel URLs, including hostname, scheme, port and context path. Set to true if your application accesses Keycloak via a private network. If set to true, hostname option needs to be specified as a full URL. | `false` | +|`keycloak_quarkus_hostname_strict_backchannel`| Deprecated, use (the inverted!)`keycloak_quarkus_hostname_backchannel_dynamic` instead. | | + + +#### HTTP(S) configuration +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_http_relative_path`| Set the path relative to / for serving resources. The path must start with a / | `/` | +|`keycloak_quarkus_http_host`| The http host, ie. the address used to bind the service | `0.0.0.0` | +|`keycloak_quarkus_http_port`| HTTP listening port | `8080` | +|`keycloak_quarkus_https_port`| TLS HTTP listening port | `8443` | +|`keycloak_quarkus_http_management_port`| Port of the management interface. Relevant only when something is exposed on the management interface - see the guide for details. | `9000` | +|`keycloak_quarkus_https_key_store_file`| The file path to the key store | `{{ keycloak.home }}/conf/key_store.p12` | +|`keycloak_quarkus_https_key_store_password`| Password for the key store | `""` | +|`keycloak_quarkus_https_trust_store_enabled`| Enable configuration of the https trust store | `False` | +|`keycloak_quarkus_https_trust_store_file`| The file path to the trust store | `{{ keycloak.home }}/conf/trust_store.p12` | +|`keycloak_quarkus_https_trust_store_password`| Password for the trust store | `""` | +|`keycloak_quarkus_https_key_file_enabled`| Enable listener on HTTPS port | `False` | +|`keycloak_quarkus_key_file_copy_enabled`| Enable copy of key file to target host | `False` | +|`keycloak_quarkus_key_content`| Content of the TLS private key. Use `"{{ lookup('file', 'server.key.pem') }}"` to lookup a file. | `""` | +|`keycloak_quarkus_key_file`| The file path to a private key in PEM format | `/etc/pki/tls/private/server.key.pem` | +|`keycloak_quarkus_cert_file_copy_enabled`| Enable copy of cert file to target host | `False`| +|`keycloak_quarkus_cert_file_src`| Set the source file path | `""` | +|`keycloak_quarkus_cert_file`| The file path to a server certificate or certificate chain in PEM format | `/etc/pki/tls/certs/server.crt.pem` | +|`keycloak_quarkus_https_key_store_enabled`| Enable configuration of HTTPS via a key store | `False` | +|`keycloak_quarkus_key_store_file`| Deprecated, use `keycloak_quarkus_https_key_store_file` instead. || +|`keycloak_quarkus_key_store_password`| Deprecated, use `keycloak_quarkus_https_key_store_password` instead.|| +|`keycloak_quarkus_http_relative_path` | Set the path relative to / for serving resources. The path must start with a / | `/` | +|`keycloak_quarkus_http_management_relative_path` | Set the path relative to / for serving resources from management interface. The path must start with a /. If not given, the value is inherited from HTTP options. Relevant only when something is exposed on the management interface - see the guide for details. | `/` | +|`keycloak_quarkus_http_enabled`| Enable listener on HTTP port | `True` | + + +#### Database configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_db_engine` | Database engine [mariadb,postres,mssql] | `postgres` | |`keycloak_quarkus_db_user` | User for database connection | `keycloak-user` | |`keycloak_quarkus_db_pass` | Password for database connection | `keycloak-pass` | -|`keycloak_quarkus_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_url` | URL for connecting to infinispan | `localhost` | -|`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` | Whether to connect to remote cache infinispan server | `false` | +|`keycloak_quarkus_cache_remote_username` | Username for connecting to infinispan | `supervisor` | +|`keycloak_quarkus_cache_remote_password` | Password for connecting to infinispan | `supervisor` | +|`keycloak_quarkus_cache_remote_host` | Hostname for connecting to infinispan | `localhost` | +|`keycloak_quarkus_cache_remote_port`| Port for connecting to infinispan | `11222` | +|`keycloak_quarkus_cache_remote_sasl_mechanism` | Infinispan auth mechanism | `SCRAM-SHA-512` | +|`keycloak_quarkus_cache_remote_tls_enabled` | Whether infinispan uses TLS connection | `false` | -* Install options +#### Logging configuration | Variable | Description | Default | -|:---------|:------------|:---------| -|`keycloak_quarkus_offline_install` | Perform an offline install | `False`| -|`keycloak_quarkus_download_url`| Download URL for keycloak | `https://github.com/keycloak/keycloak/releases/download//`| -|`keycloak_quarkus_version`| keycloak.org package version | `17.0.1` | -|`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_configure_firewalld` | Ensure firewalld is running and configure keycloak ports | `False` | +|:---------|:------------|:--------| +|`keycloak_quarkus_log`| Enable one or more log handlers in a comma-separated list | `file` | +|`keycloak_quarkus_log_level`| The log level of the root category or a comma-separated list of individual categories and their levels | `info` | +|`keycloak_quarkus_log_file`| Set the log file path and filename relative to keycloak home | `data/log/keycloak.log` | +|`keycloak_quarkus_log_format`| Set a format specific to file log entries | `%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n` | +|`keycloak_quarkus_log_target`| Set the destination of the keycloak log folder link | `/var/log/keycloak` | +|`keycloak_quarkus_log_max_file_size`| Set the maximum log file size before a log rotation happens; A size configuration option recognises string in this format (shown as a regular expression): `[0-9]+[KkMmGgTtPpEeZzYy]?`. If no suffix is given, assume bytes. | `10M` | +|`keycloak_quarkus_log_max_backup_index`| Set the maximum number of archived log files to keep" | `10` | +|`keycloak_quarkus_log_file_suffix`| Set the log file handler rotation file suffix. When used, the file will be rotated based on its suffix; Note: If the suffix ends with `.zip` or `.gz`, the rotation file will also be compressed. | `.yyyy-MM-dd.zip` | -* Miscellaneous configuration +#### 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 | `True` | +|`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 }}` | @@ -90,13 +183,81 @@ Role Defaults |`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_management_url` | URL for management console rest calls | `http://{{ keycloak_quarkus_host }}:{{ keycloak_management_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` | -|`keycloak_quarkus_log_format`| Set a format specific to file log entries | `%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n` | |`keycloak_quarkus_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` | +|`keycloak_quarkus_spi_sticky_session_encoder_infinispan_should_attach_route`| If the route should be attached to cookies to reflect the node that owns a particular session. If false, route is not attached to cookies and we rely on the session affinity capabilities from reverse proxy | `True` | +|`keycloak_quarkus_show_deprecation_warnings`| Whether deprecation warnings should be shown | `True` | + + +#### Vault configuration + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_ks_vault_enabled`| Whether to enable the vault SPI | `false` | +|`keycloak_quarkus_ks_vault_file`| The keystore path for the vault SPI | `{{ keycloak_quarkus_config_dir }}/keystore.p12` | +|`keycloak_quarkus_ks_vault_type`| Type of the keystore used for the vault SPI | `PKCS12` | + + +#### Configuring providers + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_providers`| List of provider definitions; see below | `[]` | + +Providers support different sources: + +* `url`: http download for providers not requiring authentication +* `maven`: maven download for providers hosted publicly on Apache Maven Central or private Maven repositories like Github Maven requiring authentication +* `local_path`: static providers to be uploaded + +Provider definition: + +```yaml +keycloak_quarkus_providers: + - id: http-client # required; "{{ id }}.jar" identifies the file name on RHBK + spi: connections # required if neither url, local_path nor maven are specified; required for setting properties + default: true # optional, whether to set default for spi, default false + restart: true # optional, whether to rebuild config and restart the service after deploying, default true + url: https://.../.../custom_spi.jar # optional, url for download via http + local_path: my_theme_spi.jar # optional, path on local controller for SPI to be uploaded + maven: # optional, for download using maven + repository_url: https://maven.pkg.github.com/OWNER/REPOSITORY # optional, maven repo url + group_id: my.group # optional, maven group id + artifact_id: artifact # optional, maven artifact id + version: 24.0.5 # optional, defaults to latest + username: user # optional, cf. https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-apache-maven-registry#authenticating-to-github-packages + password: pat # optional, provide a PAT for accessing Github's Apache Maven registry + properties: # optional, list of key-values + - key: default-connection-pool-size + value: 10 + checksum: sha256:D98291AC[...]B6DC7B97 # optional, checksum used to verify integrity: + # for `url` SPIs, use format: :, cf. ; + # for `local_path` SPIs, use SHA1 format + # for `maven` SPIs, this field is ignored since maven has integrity verification methods enabled by default +``` + +the definition above will generate the following build command: + +``` +bin/kc.sh build --spi-connections-provider=http-client --spi-connections-http-client-default-connection-pool-size=10 +``` + + +#### Configuring policies + +| Variable | Description | Default | +|:---------|:------------|:--------| +|`keycloak_quarkus_policies`| List of policy definitions; see below | `[]` | + +Provider definition: + +```yaml +keycloak_quarkus_policies: + - name: john-the-ripper.txt # required, resulting file name + url: https://github.com/danielmiessler/SecLists/raw/master/Passwords/Software/john-the-ripper.txt # required, url for download + type: password-blacklists # optional, defaults to `password-blacklists`; supported values: [`password-blacklists`] +``` Role Variables @@ -104,8 +265,28 @@ Role Variables | Variable | Description | Required | |:---------|:------------|----------| -|`keycloak_quarkus_admin_pass`| Password of console admin account | `yes` | +|`keycloak_quarkus_bootstrap_admin_password`| Password of console admin account | `yes` | +|`keycloak_quarkus_admin_pass`| Deprecated, use `keycloak_quarkus_bootstrap_admin_password` instead. | | +|`keycloak_quarkus_ks_vault_pass`| The password for accessing the keystore vault SPI | `no` | +|`keycloak_quarkus_alternate_download_url`| Alternate location with optional authentication for downloading RHBK | `no` | +|`keycloak_quarkus_download_user`| Optional username for http authentication | `no*` | +|`keycloak_quarkus_download_pass`| Optional password for http authentication | `no*` | +|`keycloak_quarkus_download_validate_certs`| Whether to validate certs for URL `keycloak_quarkus_alternate_download_url` | `no` | +|`keycloak_quarkus_jdbc_download_user`| Optional username for http authentication | `no*` | +|`keycloak_quarkus_jdbc_download_pass`| Optional password for http authentication | `no*` | +|`keycloak_quarkus_jdbc_download_validate_certs`| Whether to validate certs for URL `keycloak_quarkus_download_validate_certs` | `no` | +`*` username/password authentication credentials must be both declared or both undefined + + +Role custom facts +----------------- + +The role uses the following [custom facts](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_vars_facts.html#adding-custom-facts) found in `/etc/ansible/facts.d/keycloak.fact` (and thus identified by the `ansible_local.keycloak.` prefix): + +| Variable | Description | +|:---------|:------------| +|`general.bootstrapped` | A custom fact indicating whether this role has been used for bootstrapping keycloak on the respective host before; set to `false` (e.g., when starting off with a new, empty database) ensures that the initial admin user as defined by `keycloak_quarkus_bootstrap_admin_user[_password]` gets created | License ------- diff --git a/roles/keycloak_quarkus/defaults/main.yml b/roles/keycloak_quarkus/defaults/main.yml index a54a8ec..d53c790 100644 --- a/roles/keycloak_quarkus/defaults/main.yml +++ b/roles/keycloak_quarkus/defaults/main.yml @@ -1,87 +1,196 @@ --- ### Configuration specific to keycloak -keycloak_quarkus_version: 18.0.0 +keycloak_quarkus_version: 26.2.4 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_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 }}" # whether to install from local archive -keycloak_quarkus_offline_install: False +keycloak_quarkus_offline_install: false + +keycloak_quarkus_show_deprecation_warnings: true ### Install location and service settings -keycloak_quarkus_jvm_package: java-11-openjdk-headless keycloak_quarkus_java_home: keycloak_quarkus_dest: /opt/keycloak keycloak_quarkus_home: "{{ keycloak_quarkus_installdir }}" keycloak_quarkus_config_dir: "{{ keycloak_quarkus_home }}/conf" +keycloak_quarkus_download_path: "{{ lookup('env', 'PWD') }}" +keycloak_quarkus_start_dev: false keycloak_quarkus_service_user: keycloak keycloak_quarkus_service_group: keycloak -keycloak_quarkus_service_pidfile: "/run/keycloak.pid" -keycloak_quarkus_configure_firewalld: False +keycloak_quarkus_service_restart_always: false +keycloak_quarkus_service_restart_on_failure: false +keycloak_quarkus_service_restartsec: "10s" + +keycloak_quarkus_configure_firewalld: false +keycloak_quarkus_configure_iptables: false ### administrator console password -keycloak_quarkus_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_http_enabled: True +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_jgroups_port: 7600 -keycloak_quarkus_java_opts: "-Xms1024m -Xmx2048m" +keycloak_quarkus_http_management_port: 9000 +keycloak_quarkus_jgroups_port: 7800 +keycloak_quarkus_jgroups_bind_address: "{{ ansible_default_ipv4.address }}" +keycloak_quarkus_jgroups_external_addr: "{{ keycloak_quarkus_jgroups_bind_address }}" +keycloak_quarkus_jgroups_external_port: "{{ keycloak_quarkus_jgroups_port }}" +keycloak_quarkus_java_heap_opts: "-Xms1024m -Xmx2048m" +keycloak_quarkus_java_jvm_opts: > + -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 + -Dsun.err.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -XX:+ExitOnOutOfMemoryError + -Djava.security.egd=file:/dev/urandom -XX:+UseParallelGC -XX:GCTimeRatio=4 + -XX:AdaptiveSizePolicyWeight=90 -XX:FlightRecorderOptions=stackdepth=512 +keycloak_quarkus_jgroups_opts: > + -Djgroups.bind.address={{ keycloak_quarkus_jgroups_bind_address }} + -Djgroups.external_port={{ keycloak_quarkus_jgroups_external_port }} + -Djgroups.external_addr={{ keycloak_quarkus_jgroups_external_addr }} +keycloak_quarkus_java_opts: "{{ ' '.join((keycloak_quarkus_jgroups_opts, keycloak_quarkus_java_heap_opts, keycloak_quarkus_java_jvm_opts)) }}" +keycloak_quarkus_additional_env_vars: [] ### TLS/HTTPS configuration -keycloak_quarkus_https_enabled: False -keycloak_quarkus_key_file: "{{ keycloak.home }}/conf/server.key.pem" -keycloak_quarkus_cert_file: "{{ keycloak.home }}/conf/server.crt.pem" +keycloak_quarkus_https_key_file_enabled: false +keycloak_quarkus_key_file_copy_enabled: false +keycloak_quarkus_key_content: "" +keycloak_quarkus_key_file: "/etc/pki/tls/private/server.key.pem" +keycloak_quarkus_cert_file_copy_enabled: false +keycloak_quarkus_cert_file_src: "" +keycloak_quarkus_cert_file: "/etc/pki/tls/certs/server.crt.pem" +#### key store configuration +keycloak_quarkus_https_key_store_enabled: false +keycloak_quarkus_https_key_store_file: "{{ keycloak.home }}/conf/key_store.p12" +keycloak_quarkus_https_key_store_password: '' +##### trust store configuration +keycloak_quarkus_https_trust_store_enabled: false +keycloak_quarkus_https_trust_store_file: "{{ keycloak.home }}/conf/trust_store.p12" +keycloak_quarkus_https_trust_store_password: '' +### configuration key store configuration +keycloak_quarkus_config_key_store_file: "{{ keycloak.home }}/conf/conf_store.p12" +keycloak_quarkus_config_key_store_password: '' ### Enable configuration for database backend, clustering and remote caches on infinispan -keycloak_quarkus_ha_enabled: False +keycloak_quarkus_ha_enabled: false +keycloak_quarkus_ha_discovery: "JDBCPING" ### Enable database configuration, must be enabled when HA is configured -keycloak_quarkus_db_enabled: "{{ True if keycloak_quarkus_ha_enabled else False }}" +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_http_relative_path: auth -keycloak_quarkus_frontend_url: http://localhost:8080/auth +keycloak_quarkus_hostname: +keycloak_quarkus_hostname_admin: -# proxy address forwarding mode if the server is behind a reverse proxy. [edge, reencrypt, passthrough] +### Set the path relative to / for serving resources. The path must start with a / +### (set to `/auth` for retrocompatibility with pre-quarkus releases) +keycloak_quarkus_http_relative_path: / + +# Disables dynamically resolving the hostname from request headers. +# Should always be set to true in production, unless proxy verifies the Host header. +keycloak_quarkus_hostname_strict: true +# Enables dynamic resolving of backchannel URLs, including hostname, scheme, port and context path. +# Set to true if your application accesses Keycloak via a private network. If set to true, keycloak_quarkus_hostname option needs to be specified as a full URL. +keycloak_quarkus_hostname_backchannel_dynamic: false + +# The proxy headers that should be accepted by the server. ['', 'forwarded', 'xforwarded'] +keycloak_quarkus_proxy_headers: "" + +# deprecated: proxy address forwarding mode if the server is behind a reverse proxy. [none, edge, reencrypt, passthrough] keycloak_quarkus_proxy_mode: edge -keycloak_quarkus_metrics_enabled: False -keycloak_quarkus_health_enabled: True +# disable xa transactions +keycloak_quarkus_transaction_xa_enabled: true + +# If the route should be attached to cookies to reflect the node that owns a particular session. +# If false, route is not attached to cookies and we rely on the session affinity capabilities from reverse proxy +keycloak_quarkus_spi_sticky_session_encoder_infinispan_should_attach_route: true + +keycloak_quarkus_metrics_enabled: false +keycloak_quarkus_health_enabled: true + +### caches; must read: https://www.keycloak.org/2024/12/storing-sessions-in-kc26 +### embedded caches +# https://www.keycloak.org/server/caching +keycloak_quarkus_cache_metrics_enabled: false +keycloak_quarkus_cache_embedded_authorization_max_count: +keycloak_quarkus_cache_embedded_client_sessions_max_count: +keycloak_quarkus_cache_embedded_crl_max_count: +keycloak_quarkus_cache_embedded_keys_max_count: +keycloak_quarkus_cache_embedded_offline_client_sessions_max_count: +keycloak_quarkus_cache_embedded_offline_sessions_max_count: +keycloak_quarkus_cache_embedded_realms_max_count: +keycloak_quarkus_cache_embedded_sessions_max_count: +keycloak_quarkus_cache_embedded_users_max_count: +keycloak_quarkus_cache_embedded_mtls_enabled: true +keycloak_quarkus_cache_embedded_mtls_key_store_file: "{{ keycloak.home }}/conf/cache_key_store.p12" +keycloak_quarkus_cache_embedded_mtls_key_store_password: '' +keycloak_quarkus_cache_embedded_mtls_rotation_interval_days: 30 +keycloak_quarkus_cache_embedded_mtls_trust_store_file: "{{ keycloak.home }}/conf/cache_trust_store.p12" +keycloak_quarkus_cache_embedded_mtls_trust_store_password: '' ### infinispan remote caches access (hotrod) -keycloak_quarkus_ispn_user: supervisor -keycloak_quarkus_ispn_pass: supervisor -keycloak_quarkus_ispn_url: localhost -keycloak_quarkus_ispn_sasl_mechanism: SCRAM-SHA-512 -keycloak_quarkus_ispn_use_ssl: False -# if ssl is enabled, import ispn server certificate here -keycloak_quarkus_ispn_trust_store_path: /etc/pki/java/cacerts -keycloak_quarkus_ispn_trust_store_password: changeit +# https://www.keycloak.org/server/caching#_remote_cache +keycloak_quarkus_cache_remote: false +keycloak_quarkus_cache_remote_username: supervisor +keycloak_quarkus_cache_remote_password: supervisor +keycloak_quarkus_cache_remote_host: localhost +keycloak_quarkus_cache_remote_port: 11222 +keycloak_quarkus_cache_remote_tls_enabled: false +keycloak_quarkus_cache_remote_sasl_mechanism: SCRAM-SHA-512 + ### database backend engine: values [ 'postgres', 'mariadb' ] -keycloak_quarkus_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 }}" -# override the variables above, following defaults show minimum supported versions +keycloak_quarkus_db_url: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].url }}" +keycloak_quarkus_db_driver_version: "{{ keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].version }}" +# override the variables above, following defaults show recommended version as per +# https://access.redhat.com/articles/7033107 keycloak_quarkus_default_jdbc: postgres: url: 'jdbc:postgresql://localhost:5432/keycloak' - version: 9.4.1212 + version: 42.7.5 mariadb: url: 'jdbc:mariadb://localhost:3306/keycloak' - version: 2.7.4 - + version: 3.5.2 + mssql: + url: 'jdbc:sqlserver://localhost:1433;databaseName=keycloak;' + 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" ### logging configuration keycloak_quarkus_log: file keycloak_quarkus_log_level: info keycloak_quarkus_log_file: data/log/keycloak.log keycloak_quarkus_log_format: '%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n' +keycloak_quarkus_log_target: /var/log/keycloak +keycloak_quarkus_log_max_file_size: 10M +keycloak_quarkus_log_max_backup_index: 10 +keycloak_quarkus_log_file_suffix: '.yyyy-MM-dd.zip' + +# keystore-based vault +keycloak_quarkus_ks_vault_enabled: false +keycloak_quarkus_ks_vault_file: "{{ keycloak_quarkus_config_dir }}/keystore.p12" +keycloak_quarkus_ks_vault_type: PKCS12 +keycloak_quarkus_ks_vault_pass: + +keycloak_quarkus_providers: [] +keycloak_quarkus_policies: [] +keycloak_quarkus_supported_policy_types: ['password-blacklists'] + +# files in restart directory (one of [ 'serial', 'none', 'serial_then_parallel' ]), or path to file when providing custom strategy +keycloak_quarkus_restart_strategy: restart/serial.yml +keycloak_quarkus_restart_health_check: true +keycloak_quarkus_restart_health_check_delay: 10 +keycloak_quarkus_restart_health_check_retries: 25 +keycloak_quarkus_restart_pause: 15 diff --git a/roles/keycloak_quarkus/handlers/main.yml b/roles/keycloak_quarkus/handlers/main.yml index 00cab00..eec7789 100644 --- a/roles/keycloak_quarkus/handlers/main.yml +++ b/roles/keycloak_quarkus/handlers/main.yml @@ -1,4 +1,21 @@ --- +- 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 + listen: "rebuild keycloak config" +- name: "Bootstrapped" + ansible.builtin.include_tasks: bootstrapped.yml + listen: bootstrapped - name: "Restart {{ keycloak.service_name }}" - ansible.builtin.include_tasks: restart.yml - listen: "restart keycloak" \ No newline at end of file + ansible.builtin.include_tasks: + file: "{{ keycloak_quarkus_restart_strategy if keycloak_quarkus_ha_enabled else 'restart.yml' }}" + listen: "restart keycloak" +- name: "Display deprecation warning" + ansible.builtin.fail: + msg: "Deprecation warning: you are using the deprecated variable '{{ deprecated_variable | d('NotSet') }}', check docs on how to upgrade." + failed_when: false + changed_when: true + listen: "print deprecation warning" diff --git a/roles/keycloak_quarkus/meta/argument_specs.yml b/roles/keycloak_quarkus/meta/argument_specs.yml index a0214a5..1683321 100644 --- a/roles/keycloak_quarkus/meta/argument_specs.yml +++ b/roles/keycloak_quarkus/meta/argument_specs.yml @@ -2,226 +2,305 @@ argument_specs: main: options: keycloak_quarkus_version: - # line 3 of defaults/main.yml - default: "17.0.1" + default: "26.2.4" description: "keycloak.org package version" type: "str" keycloak_quarkus_archive: - # line 4 of defaults/main.yml default: "keycloak-{{ keycloak_quarkus_version }}.zip" description: "keycloak install archive filename" type: "str" keycloak_quarkus_download_url: - # line 5 of defaults/main.yml default: "https://github.com/keycloak/keycloak/releases/download/{{ keycloak_quarkus_version }}/{{ keycloak_quarkus_archive }}" description: "Download URL for keycloak" type: "str" keycloak_quarkus_installdir: - # line 6 of defaults/main.yml default: "{{ keycloak_quarkus_dest }}/keycloak-{{ keycloak_quarkus_version }}" description: "Installation path" type: "str" keycloak_quarkus_offline_install: - # line 9 of defaults/main.yml default: false description: "Perform an offline install" type: "bool" keycloak_quarkus_jvm_package: - # line 12 of defaults/main.yml - default: "java-11-openjdk-headless" + default: "java-21-openjdk-headless" description: "RHEL java package runtime" type: "str" keycloak_quarkus_java_home: description: "JAVA_HOME of installed JRE, leave empty for using specified keycloak_jvm_package RPM path" type: "str" keycloak_quarkus_dest: - # line 13 of defaults/main.yml default: "/opt/keycloak" description: "Installation root path" type: "str" keycloak_quarkus_home: - # line 14 of defaults/main.yml default: "{{ keycloak_quarkus_installdir }}" description: "Installation work directory" type: "str" keycloak_quarkus_config_dir: - # line 15 of defaults/main.yml default: "{{ keycloak_quarkus_home }}/conf" description: "Path for configuration" type: "str" keycloak_quarkus_service_user: - # line 16 of defaults/main.yml default: "keycloak" description: "Posix account username" type: "str" keycloak_quarkus_service_group: - # line 17 of defaults/main.yml default: "keycloak" description: "Posix account group" type: "str" - keycloak_quarkus_service_pidfile: - # line 18 of defaults/main.yml - default: "/run/keycloak.pid" - description: "Pid file path for service" - type: "str" keycloak_quarkus_configure_firewalld: - # line 19 of defaults/main.yml default: false description: "Ensure firewalld is running and configure keycloak ports" type: "bool" - keycloak_quarkus_admin_user: - # line 22 of defaults/main.yml - default: "admin" - description: "Administration console user account" + keycloak_quarkus_configure_iptables: + default: false + description: "Ensure firewalld is running and configure keycloak ports" + type: "bool" + keycloak_quarkus_service_restart_always: + default: false + description: "systemd restart always behavior of service; takes precedence over keycloak_service_restart_on_failure if true" + type: "bool" + keycloak_quarkus_service_restart_on_failure: + default: false + description: "systemd restart on-failure behavior of service" + type: "bool" + keycloak_quarkus_service_restartsec: + default: "10s" + description: "systemd RestartSec for service" type: "str" - keycloak_quarkus_admin_pass: - # line 23 of defaults/main.yml - default: "" - description: "Password of console admin account" + keycloak_quarkus_bootstrap_admin_user: + default: "admin" + description: "Administration user account, only for bootstrapping" + type: "str" + keycloak_quarkus_bootstrap_admin_password: + required: true + description: "Password of admin account, only for bootstrapping" type: "str" keycloak_quarkus_master_realm: - # line 24 of defaults/main.yml default: "master" description: "Name for rest authentication realm" type: "str" keycloak_quarkus_bind_address: - # line 27 of defaults/main.yml 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: - # line 28 of defaults/main.yml - default: "localhost" - description: "hostname" + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." + type: "str" + keycloak_quarkus_port: + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." + type: "int" + keycloak_quarkus_path: + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." type: "str" keycloak_quarkus_http_enabled: default: true description: "Enable listener on HTTP port" - type: "bool" + 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: - # line 29 of defaults/main.yml default: 8080 description: "HTTP port" type: "int" - keycloak_quarkus_https_enabled: + 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; keycloak_quarkus_hostname will be prepended automatically; Note that keycloak_quarkus_health_check_url takes precedence over this property" + type: "str" + keycloak_quarkus_https_key_file_enabled: default: false - description: "Enable listener on HTTPS port" - type: "bool" + description: "Enable configuration of HTTPS via files in PEM format" + type: "bool" + keycloak_quarkus_key_file_copy_enabled: + default: false + description: "Enable copy of key file to target host" + type: "bool" + keycloak_quarkus_key_content: + default: "" + description: "Content of the TLS private key" + type: "str" keycloak_quarkus_key_file: - default: "{{ keycloak.home }}/conf/server.key.pem" + default: "/etc/pki/tls/private/server.key.pem" description: "The file path to a private key in PEM format" type: "str" + keycloak_quarkus_cert_file_copy_enabled: + default: false + description: "Enable copy of cert file to target host" + type: "bool" + keycloak_quarkus_cert_file_src: + default: "" + description: "Set the source file path" + type: "str" keycloak_quarkus_cert_file: - default: "{{ keycloak.home }}/conf/server.crt.pem" + default: "/etc/pki/tls/certs/server.crt.pem" description: "The file path to a server certificate or certificate chain in PEM format" type: "str" + keycloak_quarkus_https_key_store_enabled: + default: false + description: "Enable configuration of HTTPS via a key store" + type: "bool" + keycloak_quarkus_key_store_file: + default: "" + description: "Deprecated, use `keycloak_quarkus_https_key_store_file` instead." + type: "str" + keycloak_quarkus_key_store_password: + default: "" + description: "Deprecated, use `keycloak_quarkus_https_key_store_password` instead." + type: "str" + keycloak_quarkus_https_key_store_file: + default: "{{ keycloak.home }}/conf/key_store.p12" + description: "The file path to the key store" + type: "str" + keycloak_quarkus_https_key_store_password: + default: "" + description: "Password for the key store" + type: "str" + keycloak_quarkus_https_trust_store_enabled: + default: false + description: "Enable configuration of the https trust store" + type: "bool" + keycloak_quarkus_https_trust_store_file: + default: "{{ keycloak.home }}/conf/trust_store.p12" + description: "The file path to the trust store" + type: "str" + keycloak_quarkus_https_trust_store_password: + default: "" + description: "Password for the trust store" + type: "str" + keycloak_quarkus_config_key_store_file: + default: "{{ keycloak.home }}/conf/conf_store.p12" + description: "Path to the configuration key store; only used if `keycloak_quarkus_keystore_password` is not empty" + type: "str" + keycloak_quarkus_config_key_store_password: + default: "" + description: > + Password of the configuration key store; if non-empty, `keycloak_quarkus_db_pass` will be saved to the key store + at `keycloak_quarkus_config_key_store_file` (instead of being written to the configuration file in clear text) + type: "str" keycloak_quarkus_https_port: - # line 30 of defaults/main.yml default: 8443 description: "HTTPS port" type: "int" - keycloak_quarkus_ajp_port: - # line 31 of defaults/main.yml - 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: - # line 32 of defaults/main.yml - default: 7600 - description: "jgroups cluster tcp port" - type: "int" - keycloak_quarkus_java_opts: - # line 33 of defaults/main.yml + keycloak_quarkus_java_heap_opts: default: "-Xms1024m -Xmx2048m" - description: "Additional JVM options" + description: "Heap memory JVM setting" type: "str" + keycloak_quarkus_java_jvm_opts: + default: > + -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.err.encoding=UTF-8 + -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -XX:+ExitOnOutOfMemoryError -Djava.security.egd=file:/dev/urandom -XX:+UseParallelGC + -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -XX:FlightRecorderOptions=stackdepth=512 + description: "Other JVM settings" + type: "str" + keycloak_quarkus_java_opts: + default: "{{ ' '.join((keycloak_quarkus_jgroups_opts, keycloak_quarkus_java_heap_opts, keycloak_quarkus_java_jvm_opts)) }}" + description: "JVM arguments, by default heap_opts + jvm_opts, if overriden it takes precedence over them" + type: "str" + keycloak_quarkus_additional_env_vars: + default: "[]" + description: "List of additional env variables of { key: str, value: str} to be put in sysconfig file" + type: "list" keycloak_quarkus_ha_enabled: - # line 36 of defaults/main.yml default: false description: "Enable auto configuration for database backend, clustering and remote caches on infinispan" type: "bool" + keycloak_quarkus_ha_discovery: + default: "TCPPING" + description: "Discovery protocol for HA cluster members" + type: "str" keycloak_quarkus_db_enabled: - # line 38 of defaults/main.yml default: "{{ True if keycloak_quarkus_ha_enabled else False }}" description: "Enable auto configuration for database backend" type: "str" keycloak_quarkus_http_relative_path: - # line 41 of defaults/main.yml - default: "auth" - description: "Service context path" + required: false + default: / + description: "Set the path relative to / for serving resources. The path must start with a /" + type: "str" + keycloak_quarkus_http_management_relative_path: + required: false + description: "Set the path relative to / for serving resources from management interface. The path must start with a /. If not given, the value is inherited from HTTP options. Relevant only when something is exposed on the management interface - see the guide for details." type: "str" keycloak_quarkus_frontend_url: - # line 41 of defaults/main.yml - default: "http://localhost:8080/auth" - description: "Service public URL" + required: false + description: "Deprecated in v26, use keycloak_quarkus_hostname instead." + type: "str" + keycloak_quarkus_hostname_admin: + required: false + description: "Service URL for the admin console" + type: "str" + keycloak_quarkus_admin_url: + required: false + description: "Deprecated in v26, use keycloak_quarkus_hostname_admin instead." type: "str" keycloak_quarkus_metrics_enabled: - # line 43 of defaults/main.yml default: false description: "Whether to enable metrics" 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: - # line 46 of defaults/main.yml + keycloak_quarkus_cache_remote: + description: "Whether to connect to remote cache infinispan server" + default: false + type: 'bool' + keycloak_quarkus_cache_remote_username: default: "supervisor" description: "Username for connecting to infinispan" type: "str" - keycloak_quarkus_ispn_pass: - # line 47 of defaults/main.yml + keycloak_quarkus_cache_remote_password: default: "supervisor" description: "Password for connecting to infinispan" type: "str" - keycloak_quarkus_ispn_url: - # line 48 of defaults/main.yml + keycloak_quarkus_cache_remote_host: default: "localhost" - description: "URL for connecting to infinispan" + description: "Hostname for connecting to infinispan" type: "str" - keycloak_quarkus_ispn_sasl_mechanism: - # line 49 of defaults/main.yml + keycloak_quarkus_cache_remote_port: + default: "11222" + description: "Port for connecting to infinispan" + type: "str" + keycloak_quarkus_cache_remote_sasl_mechanism: default: "SCRAM-SHA-512" description: "Infinispan auth mechanism" type: "str" - keycloak_quarkus_ispn_use_ssl: - # line 50 of defaults/main.yml + keycloak_quarkus_cache_remote_tls_enabled: default: false description: "Whether infinispan uses TLS connection" type: "bool" - keycloak_quarkus_ispn_trust_store_path: - # line 52 of defaults/main.yml - default: "/etc/pki/java/cacerts" - description: "Path to infinispan server trust certificate" - type: "str" - keycloak_quarkus_ispn_trust_store_password: - # line 53 of defaults/main.yml - default: "changeit" - description: "Password for infinispan certificate keystore" - type: "str" - keycloak_quarkus_jdbc_engine: - # line 56 of defaults/main.yml + keycloak_quarkus_db_engine: default: "postgres" - description: "Database engine [mariadb,postres]" + description: "Database engine [mariadb,postres,mssql]" type: "str" keycloak_quarkus_db_user: - # line 58 of defaults/main.yml default: "keycloak-user" description: "User for database connection" type: "str" keycloak_quarkus_db_pass: - # line 59 of defaults/main.yml default: "keycloak-pass" description: "Password for database connection" type: "str" - keycloak_quarkus_jdbc_url: - # line 60 of defaults/main.yml - 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: - # line 61 of defaults/main.yml - 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: @@ -240,7 +319,290 @@ argument_specs: default: '%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n' type: "str" description: "Set a format specific to file log entries" + keycloak_quarkus_log_target: + default: '/var/log/keycloak' + type: "str" + description: "Set the destination of the keycloak log folder link" + keycloak_quarkus_log_max_file_size: + default: 10M + type: "str" + description: > + Set the maximum log file size before a log rotation happens; A size configuration option recognises string in this format (shown as a regular + expression): [0-9]+[KkMmGgTtPpEeZzYy]?. If no suffix is given, assume bytes. + keycloak_quarkus_log_max_backup_index: + default: 10 + type: "str" + description: "Set the maximum number of archived log files to keep" + keycloak_quarkus_log_file_suffix: + default: '.yyyy-MM-dd.zip' + type: "str" + description: > + Set the log file handler rotation file suffix. When used, the file will be rotated based on its suffix. Note: If the suffix ends + with .zip or .gz, the rotation file will also be compressed. keycloak_quarkus_proxy_mode: default: 'edge' type: "str" - description: "The proxy address forwarding mode if the server is behind a reverse proxy" + description: "The proxy address forwarding mode if the server is behind a reverse proxy. Set to 'none' if not using a proxy" + keycloak_quarkus_proxy_headers: + default: "" + type: "str" + description: "Parse reverse proxy headers (`forwarded` or `xforwarded`), overrides the deprecated keycloak_quarkus_proxy_mode argument" + keycloak_quarkus_start_dev: + default: false + type: "bool" + description: "Whether to start the service in development mode (start-dev)" + keycloak_quarkus_transaction_xa_enabled: + default: true + type: "bool" + description: "Enable or disable XA transactions which may not be supported by some DBMS" + keycloak_quarkus_hostname_strict: + default: true + type: "bool" + description: > + Disables dynamically resolving the hostname from request headers. Should always be set to true in production, unless + proxy verifies the Host header. + keycloak_quarkus_hostname_backchannel_dynamic: + default: false + type: "bool" + description: > + Enables dynamic resolving of backchannel URLs, including hostname, scheme, port and context path. + Set to true if your application accesses Keycloak via a private network. If set to true, hostname option needs to be specified as a full URL. + keycloak_quarkus_spi_sticky_session_encoder_infinispan_should_attach_route: + default: true + type: "bool" + description: > + If the route should be attached to cookies to reflect the node that owns a particular session. If false, route is not attached to cookies + and we rely on the session affinity capabilities from reverse proxy + keycloak_quarkus_ks_vault_enabled: + default: false + type: "bool" + description: "Whether to enable vault SPI" + keycloak_quarkus_ks_vault_file: + default: "{{ keycloak_quarkus_config_dir }}/keystore.p12" + type: "str" + description: "The keystore path for the vault SPI" + keycloak_quarkus_ks_vault_type: + default: "PKCS12" + type: "str" + description: "Type of the keystore used for the vault SPI" + keycloak_quarkus_ks_vault_pass: + required: false + type: "str" + description: "The password for accessing the keystore vault SPI" + keycloak_quarkus_systemd_wait_for_port: + description: 'Whether systemd unit should wait for keycloak port before returning' + default: "{{ keycloak_quarkus_ha_enabled }}" + type: "bool" + keycloak_quarkus_systemd_wait_for_port_number: + default: "{{ keycloak_quarkus_https_port }}" + description: "The port the systemd unit should wait for, by default the https port" + type: "int" + keycloak_quarkus_systemd_wait_for_log: + description: 'Whether systemd unit should wait for service to be up in logs' + default: false + type: "bool" + keycloak_quarkus_systemd_wait_for_timeout: + description: "How long to wait for service to be alive (seconds)" + default: 60 + type: 'int' + keycloak_quarkus_systemd_wait_for_delay: + description: "Activation delay for service systemd unit (seconds)" + default: 10 + type: 'int' + keycloak_quarkus_providers: + description: > + List of provider definition dicts: { 'id': str, 'spi': str, 'url': str, 'local_path': str, + 'maven': { + 'repository_url': str, 'group_id': str, 'artifact_id': str, 'version': str, 'username': str, optional, 'password': str, optional + }, + 'default': bool, + 'properties': list of key/value } + default: [] + type: "list" + keycloak_quarkus_supported_policy_types: + description: "List of str of supported policy types" + default: ['password-blacklists'] + type: "list" + keycloak_quarkus_policies: + description: "List of policy definition dicts: { 'name': str, 'url': str, 'type': str }" + default: [] + type: "list" + keycloak_quarkus_jdbc_download_url: + description: "Override the default Maven Central download URL for the JDBC driver" + type: "str" + keycloak_quarkus_jdbc_download_user: + description: "Set a username with which to authenticate when downloading JDBC drivers from an alternative location" + type: "str" + keycloak_quarkus_jdbc_download_pass: + description: > + Set a password with which to authenticate when downloading JDBC drivers from an alternative location + (requires `keycloak_quarkus_jdbc_download_user``) + type: "str" + keycloak_quarkus_jdbc_download_validate_certs: + default: true + description: "Allow the option to ignore invalid certificates when downloading JDBC drivers from a custom URL" + type: "bool" + keycloak_quarkus_restart_health_check: + default: true + description: "Whether to wait for successful health check after restart" + type: "bool" + keycloak_quarkus_restart_strategy: + description: > + Strategy task file for restarting in HA, one of restart/[ 'serial', 'none', 'serial_then_parallel' ].yml, or path to + file when providing custom strategy; when keycloak_quarkus_ha_enabled and keycloak_quarkus_restart_health_check == true + default: "restart/serial.yml" + type: "str" + keycloak_quarkus_restart_pause: + description: "Seconds to wait between restarts in HA strategy" + default: 15 + type: int + keycloak_quarkus_restart_health_check_delay: + description: "Seconds to let pass before starting healch checks" + default: 10 + type: 'int' + keycloak_quarkus_restart_health_check_retries: + description: "Number of attempts for successful health check before failing" + default: 25 + type: 'int' + keycloak_quarkus_show_deprecation_warnings: + default: true + description: "Whether or not deprecation warnings should be shown" + type: "bool" + keycloak_quarkus_download_path: + description: "Path local to controller for offline/download of install archives" + default: "{{ lookup('env', 'PWD') }}" + type: "str" + keycloak_quarkus_cache_metrics_enabled: + description: 'Enable histograms for metrics for the embedded caches' + default: false + type: 'bool' + keycloak_quarkus_cache_embedded_authorization_max_count: + description: 'The maximum number of entries that can be stored in-memory by the authorization cache' + required: false + type: "int" + keycloak_quarkus_cache_embedded_client_sessions_max_count: + description: 'The maximum number of entries that can be stored in-memory by the clientSessions cache' + required: false + type: "int" + keycloak_quarkus_cache_embedded_crl_max_count: + description: 'The maximum number of entries that can be stored in-memory by the crl cache' + required: false + type: "int" + keycloak_quarkus_cache_embedded_keys_max_count: + description: 'The maximum number of entries that can be stored in-memory by the keys cache' + required: false + type: "int" + keycloak_quarkus_cache_embedded_offline_client_sessions_max_count: + description: 'The maximum number of entries that can be stored in-memory by the offlineClientSessions cache' + required: false + type: "int" + keycloak_quarkus_cache_embedded_offline_sessions_max_count: + description: 'The maximum number of entries that can be stored in-memory by the offlineSessions cache' + required: false + type: "int" + keycloak_quarkus_cache_embedded_realms_max_count: + description: 'The maximum number of entries that can be stored in-memory by the realms cache' + required: false + type: "int" + keycloak_quarkus_cache_embedded_sessions_max_count: + description: 'The maximum number of entries that can be stored in-memory by the sessions cache' + required: false + type: "int" + keycloak_quarkus_cache_embedded_users_max_count: + description: 'The maximum number of entries that can be stored in-memory by the users cache' + required: false + type: 'int' + keycloak_quarkus_cache_embedded_mtls_enabled: + description: 'Encrypts the network communication between Keycloak servers' + default: true + type: 'bool' + keycloak_quarkus_cache_embedded_mtls_key_store_file: + description: 'The Keystore file path' + default: "{{ keycloak.home }}/conf/cache_key_store.p12" + type: "str" + keycloak_quarkus_cache_embedded_mtls_key_store_password: + description: 'The password to access the Keystore' + default: '' + type: "str" + keycloak_quarkus_cache_embedded_mtls_rotation_interval_days: + description: 'Rotation period in days of automatic JGroups MTLS certificates' + default: 30 + type: 'int' + keycloak_quarkus_cache_embedded_mtls_trust_store_file: + description: 'The Truststore file path' + default: "{{ keycloak.home }}/conf/cache_trust_store.p12" + type: "str" + keycloak_quarkus_cache_embedded_mtls_trust_store_password: + description: 'The password to access the Truststore.' + default: '' + type: "str" + keycloak_quarkus_jgroups_port: + description: 'jgroups bind port' + default: 7800 + type: "int" + keycloak_quarkus_jgroups_bind_address: + description: 'jgroups bind address' + default: "{{ ansible_default_ipv4.address }}" + type: "str" + keycloak_quarkus_jgroups_external_addr: + description: 'IP address that other instances in the Keycloak should use to contact this node' + default: "{{ keycloak_quarkus_jgroups_bind_address }}" + type: "str" + keycloak_quarkus_jgroups_external_port: + description: 'Port that other instances in the Keycloak cluster should use to contact this node' + default: "{{ keycloak_quarkus_jgroups_port }}" + type: "int" + keycloak_quarkus_jgroups_opts: + description: "JVM arguments for jgroups configuration" + default: "-Djgroups.bind.address={{ keycloak_quarkus_jgroups_bind_address }} -Djgroups.external_port={{ keycloak_quarkus_jgroups_external_port }} -Djgroups.external_addr={{ keycloak_quarkus_jgroups_external_addr }}" + type: "str" + downstream: + options: + rhbk_version: + default: "26.2.4" + description: "Red Hat Build of Keycloak version" + type: "str" + rhbk_archive: + default: "rhbk-{{ rhbk_version }}.zip" + description: "Red Hat Build of Keycloak install archive filename" + type: "str" + rhbk_dest: + default: "/opt/rhbk" + description: "Root installation directory" + type: "str" + rhbk_installdir: + default: "{{ rhbk_dest }}/rhbk-{{ rhbk_version }}" + description: "Installation path for Red Hat Build of Keycloak" + type: "str" + rhbk_apply_patches: + default: false + description: "Install Red Hat Build of Keycloak most recent cumulative patch" + type: "bool" + rhbk_enable: + default: true + description: "Enable Red Hat Build of Keycloak installation" + type: "bool" + rhbk_offline_install: + default: false + description: "Perform an offline install" + type: "bool" + rhbk_service_name: + default: "rhbk" + description: "systemd service name for Red Hat Build of Keycloak" + type: "str" + rhbk_service_desc: + default: "Red Hat Build of Keycloak" + description: "systemd description for Red Hat Build of Keycloak" + type: "str" + rhbk_patch_version: + required: false + description: "Red Hat Build of Keycloak latest cumulative patch version to apply; defaults to latest version when rhbk_apply_patches is True" + type: "str" + rhbk_patch_bundle: + default: "rhbk-{{ rhbk_patch_version | default('[0-9]+[.][0-9]+[.][0-9]+') }}-patch.zip" + description: "Red Hat Build of Keycloak patch archive filename" + type: "str" + rhbk_product_category: + default: "rhbk" + description: "JBossNetwork API category for Red Hat Build of Keycloak" + type: "str" diff --git a/roles/keycloak_quarkus/meta/main.yml b/roles/keycloak_quarkus/meta/main.yml index fd6a110..65b5e50 100644 --- a/roles/keycloak_quarkus/meta/main.yml +++ b/roles/keycloak_quarkus/meta/main.yml @@ -1,6 +1,4 @@ --- -collections: - galaxy_info: role_name: keycloak_quarkus namespace: middleware_automation @@ -10,12 +8,17 @@ galaxy_info: license: Apache License 2.0 - min_ansible_version: "2.9" + min_ansible_version: "2.16" platforms: - - name: EL - versions: - - 8 + - name: EL + versions: + - "8" + - "9" + - name: Fedora + - name: Debian + - name: Ubuntu + galaxy_tags: - keycloak @@ -26,3 +29,5 @@ galaxy_info: - authentication - identity - security + - rhbk + - debian diff --git a/roles/keycloak_quarkus/tasks/bootstrapped.yml b/roles/keycloak_quarkus/tasks/bootstrapped.yml new file mode 100644 index 0000000..3cbc5c4 --- /dev/null +++ b/roles/keycloak_quarkus/tasks/bootstrapped.yml @@ -0,0 +1,16 @@ +--- +- name: Save ansible custom facts + become: true + ansible.builtin.template: + src: keycloak.fact.j2 + dest: /etc/ansible/facts.d/keycloak.fact + mode: '0644' + vars: + bootstrapped: true + +- name: Refresh custom facts + ansible.builtin.setup: + filter: ansible_local + +- name: Ensure that `KEYCLOAK_ADMIN[_PASSWORD]` get purged + ansible.builtin.include_tasks: systemd.yml diff --git a/roles/keycloak_quarkus/tasks/config_store.yml b/roles/keycloak_quarkus/tasks/config_store.yml new file mode 100644 index 0000000..2d8b39e --- /dev/null +++ b/roles/keycloak_quarkus/tasks/config_store.yml @@ -0,0 +1,52 @@ +--- +- name: "Initialize configuration key store variables to be written" + ansible.builtin.set_fact: + store_items: + - key: "kc.db-password" + value: "{{ keycloak_quarkus_db_pass }}" + +- 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: | # noqa blocked_modules shell is necessary here + set -o nounset # abort on unbound variable + set -o pipefail # do not hide errors within pipes + set -o errexit # abort on nonzero exit status + + echo dummy | keytool -noprompt -importpass -alias dummy -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} -storetype PKCS12 + keytool -delete -alias dummy -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} + args: + creates: "{{ keycloak_quarkus_config_key_store_file }}" + +- name: "Set configuration key store using keytool" + ansible.builtin.shell: | # noqa blocked_modules shell is necessary here + set -o nounset # abort on unbound variable + set -o pipefail # do not hide errors within pipes + + keytool -list -alias {{ item.key | quote }} -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} + retVal=$? + + set -o errexit # abort on nonzero exit status + + if [ $retVal -eq 0 ]; then + # value is already in keystore, but keytool has no replace function: delete and re-create instead + # note that we can not read whether the value has changed either[^1], so we need to override it + # [^1]: https://stackoverflow.com/a/37491400 + keytool -delete -alias {{ item.key | quote }} -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} + fi + + echo {{ item.value | quote }} | keytool -noprompt -importpass -alias {{ item.key | quote }} -keystore {{ keycloak_quarkus_config_key_store_file | quote }} -storepass {{ keycloak_quarkus_config_key_store_password | quote }} -storetype PKCS12 + loop: "{{ store_items }}" + no_log: true + become: true + changed_when: true + notify: + - restart keycloak + +- name: "Set owner of configuration key store {{ keycloak_quarkus_config_key_store_file }}" + ansible.builtin.file: + path: "{{ keycloak_quarkus_config_key_store_file }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0400' + become: true diff --git a/roles/keycloak_quarkus/tasks/debian.yml b/roles/keycloak_quarkus/tasks/debian.yml new file mode 100644 index 0000000..7e59204 --- /dev/null +++ b/roles/keycloak_quarkus/tasks/debian.yml @@ -0,0 +1,10 @@ +--- +- name: Include firewall config tasks + ansible.builtin.include_tasks: + file: iptables.yml + apply: + tags: + - firewall + when: keycloak_quarkus_configure_iptables + tags: + - firewall diff --git a/roles/keycloak_quarkus/tasks/deprecations.yml b/roles/keycloak_quarkus/tasks/deprecations.yml new file mode 100644 index 0000000..0d370d5 --- /dev/null +++ b/roles/keycloak_quarkus/tasks/deprecations.yml @@ -0,0 +1,162 @@ +--- +- name: Check deprecation keycloak_quarkus_key_store -> keycloak_quarkus_http_key_store + delegate_to: localhost + run_once: true + when: + - keycloak_quarkus_https_key_store_enabled + block: + - name: Ensure backward compatibility for `keycloak_quarkus_key_store_file`, superseded by `keycloak_quarkus_https_key_store_file` + when: + - keycloak_quarkus_key_store_file is defined + - keycloak_quarkus_key_store_file != '' + - keycloak_quarkus_https_key_store_file == keycloak.home + "/conf/key_store.p12" # default value + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_https_key_store_file: "{{ keycloak_quarkus_key_store_file }}" + deprecated_variable: "keycloak_quarkus_key_store_file" # read in deprecation handler + notify: + - print deprecation warning + + - name: Flush handlers + ansible.builtin.meta: flush_handlers + + - name: Ensure backward compatibility for `keycloak_quarkus_key_store_password`, superseded by `keycloak_quarkus_https_key_store_password` + when: + - keycloak_quarkus_key_store_password is defined + - keycloak_quarkus_key_store_password != '' + - keycloak_quarkus_https_key_store_password == "" # default value + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_https_key_store_password: "{{ keycloak_quarkus_key_store_password }}" + deprecated_variable: "keycloak_quarkus_key_store_password" # read in deprecation handler + notify: + - print deprecation warning + + - name: Flush handlers + ansible.builtin.meta: flush_handlers + +# https://access.redhat.com/documentation/en-us/red_hat_build_of_keycloak/24.0/html-single/upgrading_guide/index#deprecated_literal_proxy_literal_option +- name: Check deprecation of keycloak_quarkus_proxy_mode + when: + - keycloak_quarkus_proxy_mode is defined + - keycloak_quarkus_proxy_headers is defined and keycloak_quarkus_proxy_headers | length == 0 + - keycloak_quarkus_version.split('.') | first | int >= 24 + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + deprecated_variable: "keycloak_quarkus_proxy_mode" # read in deprecation handler + notify: + - print deprecation warning + +# https://docs.redhat.com/en/documentation/red_hat_build_of_keycloak/26.0/html-single/upgrading_guide/index#new_hostname_options +- name: Check deprecation of keycloak_quarkus_frontend_url -> keycloak_quarkus_hostname + when: + - keycloak_quarkus_hostname is not defined + - keycloak_quarkus_frontend_url is defined + - keycloak_quarkus_frontend_url != '' + delegate_to: localhost + run_once: true + changed_when: keycloak_quarkus_show_deprecation_warnings + ansible.builtin.set_fact: + keycloak_quarkus_hostname: "{{ keycloak_quarkus_frontend_url }}" + 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/fastpackages.yml b/roles/keycloak_quarkus/tasks/fastpackages.yml index 3dd28e1..9dc1621 100644 --- a/roles/keycloak_quarkus/tasks/fastpackages.yml +++ b/roles/keycloak_quarkus/tasks/fastpackages.yml @@ -1,22 +1,31 @@ --- -- name: Check packages to be installed - block: - - name: "Check if packages are already installed" - ansible.builtin.command: "rpm -q {{ packages_list | join(' ') }}" - args: - warn: no - register: rpm_info - changed_when: rpm_info.failed +- name: "Check if packages are already installed" # noqa command-instead-of-module this runs faster + ansible.builtin.command: "rpm -q {{ packages_list | join(' ') }}" + register: rpm_info + changed_when: false + failed_when: false + when: ansible_facts.os_family == "RedHat" - rescue: - - name: "Add missing packages to the yum install list" - ansible.builtin.set_fact: - packages_to_install: "{{ packages_to_install | default([]) + rpm_info.stdout_lines | map('regex_findall', 'package (.+) is not installed$') | flatten }}" - when: rpm_info.failed +- name: "Add missing packages to the yum install list" + ansible.builtin.set_fact: + packages_to_install: "{{ packages_to_install | default([]) + rpm_info.stdout_lines | \ + map('regex_findall', 'package (.+) is not installed$') | default([]) | flatten }}" + when: ansible_facts.os_family == "RedHat" -- name: "Install packages: {{ packages_to_install | join(',') }}" - become: yes - ansible.builtin.yum: +- name: "Install packages: {{ packages_to_install }}" + become: true + ansible.builtin.dnf: name: "{{ packages_to_install }}" state: present - when: packages_to_install | default([]) | length > 0 \ No newline at end of file + when: + - packages_to_install | default([]) | length > 0 + - ansible_facts.os_family == "RedHat" + +- name: "Install packages: {{ packages_list }}" + become: true + ansible.builtin.package: + name: "{{ packages_list }}" + state: present + when: + - packages_list | default([]) | length > 0 + - ansible_facts.os_family == "Debian" diff --git a/roles/keycloak_quarkus/tasks/firewalld.yml b/roles/keycloak_quarkus/tasks/firewalld.yml index 772ba3c..2d48124 100644 --- a/roles/keycloak_quarkus/tasks/firewalld.yml +++ b/roles/keycloak_quarkus/tasks/firewalld.yml @@ -6,20 +6,31 @@ - firewalld - name: Enable and start the firewalld service - become: yes + become: true ansible.builtin.systemd: name: firewalld - enabled: yes + enabled: true state: started -- name: "Configure firewall for {{ keycloak.service_name }} ports" - become: yes - firewalld: +- name: "Configure firewall for {{ keycloak.service_name }} http port" + become: true + ansible.posix.firewalld: port: "{{ item }}" permanent: true state: enabled - immediate: yes + 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 b1ea1ee..b188e6c 100644 --- a/roles/keycloak_quarkus/tasks/install.yml +++ b/roles/keycloak_quarkus/tasks/install.yml @@ -8,30 +8,38 @@ - 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 - become: yes + become: true ansible.builtin.stat: path: "{{ keycloak.home }}" register: existing_deploy - name: "Create {{ keycloak.service_name }} service user/group" - become: yes + become: true ansible.builtin.user: name: "{{ keycloak.service_user }}" home: /opt/keycloak - system: yes - create_home: no + system: true + create_home: false - name: "Create {{ keycloak.service_name }} install location" - become: yes + become: true ansible.builtin.file: dest: "{{ keycloak_quarkus_dest }}" state: directory owner: "{{ keycloak.service_user }}" group: "{{ keycloak.service_group }}" - mode: 0750 + mode: '0750' + +- name: Create directory for ansible custom facts + become: true + ansible.builtin.file: + state: directory + recurse: true + path: /etc/ansible/facts.d ## check remote archive - name: Set download archive path @@ -39,35 +47,93 @@ archive: "{{ keycloak_quarkus_dest }}/{{ keycloak.bundle }}" - name: Check download archive path - become: yes + become: true ansible.builtin.stat: path: "{{ archive }}" 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 - - name: Download keycloak archive ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user url: "{{ keycloak_quarkus_download_url }}" dest: "{{ local_path.stat.path }}/{{ keycloak.bundle }}" - mode: 0640 + mode: '0640' delegate_to: localhost + become: false + run_once: true when: - archive_path is defined - archive_path.stat is defined - not archive_path.stat.exists - not keycloak.offline_install + - not rhbk_enable is defined or not rhbk_enable + +- name: Perform download from RHN using JBoss Network API + delegate_to: localhost + run_once: true + when: + - archive_path is defined + - archive_path.stat is defined + - not archive_path.stat.exists + - rhbk_enable is defined and rhbk_enable + - not keycloak.offline_install + block: + - name: Retrieve product download using JBoss Network API + middleware_automation.common.product_search: + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_type: DISTRIBUTION + product_version: "{{ rhbk_version }}" + product_category: "{{ rhbk_product_category }}" + register: rhn_products + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + + - name: Determine install zipfile from search results + ansible.builtin.set_fact: + rhn_filtered_products: "{{ rhn_products.results | selectattr('file_path', 'match', '[^/]*/' + rhbk_archive + '$') }}" + delegate_to: localhost + run_once: true + + - name: Download Red Hat Build of Keycloak + middleware_automation.common.product_download: # noqa risky-file-permissions delegated, uses controller host user + client_id: "{{ rhn_username }}" + client_secret: "{{ rhn_password }}" + product_id: "{{ (rhn_filtered_products | first).id }}" + dest: "{{ local_path.stat.path }}/{{ keycloak.bundle }}" + mode: '0640' + no_log: "{{ omit_rhn_output | default(true) }}" + delegate_to: localhost + run_once: true + become: false + +- name: Perform download of RHBK from alternate download location + delegate_to: localhost + run_once: true + become: false + when: + - archive_path is defined + - archive_path.stat is defined + - not archive_path.stat.exists + - rhbk_enable is defined and rhbk_enable + - not keycloak.offline_install + - keycloak_quarkus_alternate_download_url is defined + ansible.builtin.get_url: # noqa risky-file-permissions delegated, uses controller host user + url: "{{ keycloak_quarkus_alternate_download_url }}" + dest: "{{ local_path.stat.path }}/{{ keycloak.bundle }}" + mode: '0640' + url_username: "{{ keycloak_quarkus_download_user | default(omit) }}" + url_password: "{{ keycloak_quarkus_download_pass | default(omit) }}" + validate_certs: "{{ keycloak_quarkus_download_validate_certs | default(omit) }}" - name: Check downloaded archive ansible.builtin.stat: path: "{{ local_path.stat.path }}/{{ keycloak.bundle }}" register: local_archive_path delegate_to: localhost + become: false + run_once: true ## copy and unpack - name: Copy archive to target nodes @@ -76,29 +142,29 @@ dest: "{{ archive }}" owner: "{{ keycloak.service_user }}" group: "{{ keycloak.service_group }}" - mode: 0750 + mode: '0640' register: new_version_downloaded when: - not archive_path.stat.exists - local_archive_path.stat is defined - local_archive_path.stat.exists - become: yes + become: true - name: "Check target directory: {{ keycloak.home }}/bin/" ansible.builtin.stat: path: "{{ keycloak.home }}/bin/" register: path_to_workdir - become: yes + become: true -- name: "Extract Keycloak archive on target" +- name: "Extract Keycloak archive on target" # noqa no-handler need to run this here ansible.builtin.unarchive: - remote_src: yes + remote_src: true src: "{{ archive }}" dest: "{{ keycloak_quarkus_dest }}" creates: "{{ keycloak.home }}/bin/" owner: "{{ keycloak.service_user }}" group: "{{ keycloak.service_group }}" - become: yes + become: true when: - (not path_to_workdir.stat.exists) or new_version_downloaded.changed notify: @@ -109,3 +175,112 @@ msg: "{{ keycloak.home }} already exists and version unchanged, skipping decompression" when: - (not new_version_downloaded.changed) and path_to_workdir.stat.exists + +- name: "Copy private key to target" + ansible.builtin.copy: + content: "{{ keycloak_quarkus_key_content }}" + dest: "{{ keycloak_quarkus_key_file }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + become: true + when: + - keycloak_quarkus_https_key_file_enabled is defined and keycloak_quarkus_https_key_file_enabled + - keycloak_quarkus_key_file_copy_enabled is defined and keycloak_quarkus_key_file_copy_enabled + - keycloak_quarkus_key_content | length > 0 + +- name: "Copy certificate to target" + ansible.builtin.copy: + src: "{{ keycloak_quarkus_cert_file_src }}" + dest: "{{ keycloak_quarkus_cert_file }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0644' + become: true + when: + - keycloak_quarkus_https_key_file_enabled is defined and keycloak_quarkus_https_key_file_enabled + - keycloak_quarkus_cert_file_copy_enabled is defined and keycloak_quarkus_cert_file_copy_enabled + - keycloak_quarkus_cert_file_src | length > 0 + +- name: "Install {{ 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_db_engine].driver_jar_url is defined + +- name: "Download custom providers via http" + ansible.builtin.get_url: + url: "{{ item.url }}" + dest: "{{ keycloak.home }}/providers/{{ item.id }}.jar" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + checksum: "{{ item.checksum | default(omit) }}" + become: true + loop: "{{ keycloak_quarkus_providers }}" + when: item.url is defined and item.url | length > 0 + notify: "{{ ['invalidate keycloak theme cache', 'rebuild keycloak config', 'restart keycloak'] if not item.restart is defined or item.restart else [] }}" + +# this requires the `lxml` package to be installed; we redirect this step to localhost such that we do need to install it on the remote hosts +- name: "Download custom providers to localhost using maven" + middleware_automation.common.maven_artifact: + repository_url: "{{ item.maven.repository_url }}" + group_id: "{{ item.maven.group_id }}" + artifact_id: "{{ item.maven.artifact_id }}" + version: "{{ item.maven.version | default(omit) }}" + username: "{{ item.maven.username | default(omit) }}" + password: "{{ item.maven.password | default(omit) }}" + dest: "{{ local_path.stat.path }}/{{ item.id }}.jar" + delegate_to: "localhost" + run_once: true + loop: "{{ keycloak_quarkus_providers }}" + when: item.maven is defined + no_log: "{{ item.maven.password is defined and item.maven.password | length > 0 | default(false) }}" + +- name: "Copy maven providers" + ansible.builtin.copy: + src: "{{ local_path.stat.path }}/{{ item.id }}.jar" + dest: "{{ keycloak.home }}/providers/{{ item.id }}.jar" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + checksum: "{{ item.checksum | default(omit) }}" + become: true + loop: "{{ keycloak_quarkus_providers }}" + when: item.maven is defined + no_log: "{{ item.maven.password is defined and item.maven.password | length > 0 | default(false) }}" + notify: "{{ ['invalidate keycloak theme cache', 'rebuild keycloak config', 'restart keycloak'] if not item.restart is defined or item.restart else [] }}" + +- name: "Copy local providers" + ansible.builtin.copy: + src: "{{ item.local_path }}" + dest: "{{ keycloak.home }}/providers/{{ item.id }}.jar" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + 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: + path: "{{ keycloak.home }}/data/{{ item | lower }}" + state: directory + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0750' + become: true + loop: "{{ keycloak_quarkus_supported_policy_types }}" + +- name: "Install custom policies" + ansible.builtin.get_url: + url: "{{ item.url }}" + dest: "{{ keycloak.home }}/data/{{ item.type | default(keycloak_quarkus_supported_policy_types | first) | lower }}/{{ item.name }}" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + mode: '0640' + become: true + loop: "{{ keycloak_quarkus_policies }}" + when: item.url is defined and item.url | length > 0 + notify: "restart keycloak" 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/iptables.yml b/roles/keycloak_quarkus/tasks/iptables.yml new file mode 100644 index 0000000..b487b89 --- /dev/null +++ b/roles/keycloak_quarkus/tasks/iptables.yml @@ -0,0 +1,20 @@ +--- +- name: Ensure required package iptables are installed + ansible.builtin.include_tasks: fastpackages.yml + vars: + packages_list: + - iptables + +- name: "Configure firewall ports for {{ keycloak.service_name }}" + become: true + ansible.builtin.iptables: + destination_port: "{{ item }}" + action: "insert" + rule_num: 6 # magic number I forget why + chain: "INPUT" + policy: "ACCEPT" + protocol: tcp + loop: + - "{{ keycloak_quarkus_http_port }}" + - "{{ keycloak_quarkus_https_port }}" + - "{{ keycloak_quarkus_jgroups_port }}" diff --git a/roles/keycloak_quarkus/tasks/jdbc_driver.yml b/roles/keycloak_quarkus/tasks/jdbc_driver.yml new file mode 100644 index 0000000..ba3f4b8 --- /dev/null +++ b/roles/keycloak_quarkus/tasks/jdbc_driver.yml @@ -0,0 +1,22 @@ +--- +- name: "Verify valid parameters for download credentials when specified" + ansible.builtin.fail: + msg: >- + When JDBC driver download credentials are set, both the username and the password MUST be set + when: > + (keycloak_quarkus_jdbc_download_user is undefined and keycloak_quarkus_jdbc_download_pass is not undefined) or + (keycloak_quarkus_jdbc_download_pass is undefined and keycloak_quarkus_jdbc_download_user is not undefined) + +- name: "Retrieve JDBC Driver from {{ keycloak_jdbc_download_url | default(keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].driver_jar_url) }}" + ansible.builtin.get_url: + url: "{{ keycloak_quarkus_jdbc_download_url | default(keycloak_quarkus_default_jdbc[keycloak_quarkus_db_engine].driver_jar_url) }}" + dest: "{{ keycloak.home }}/providers" + owner: "{{ keycloak.service_user }}" + group: "{{ keycloak.service_group }}" + url_username: "{{ keycloak_quarkus_jdbc_download_user | default(omit) }}" + url_password: "{{ keycloak_quarkus_jdbc_download_pass | default(omit) }}" + validate_certs: "{{ keycloak_quarkus_jdbc_download_validate_certs | default(omit) }}" + mode: '0640' + become: true + notify: + - restart keycloak diff --git a/roles/keycloak_quarkus/tasks/main.yml b/roles/keycloak_quarkus/tasks/main.yml index 738b55e..6a7a4b0 100644 --- a/roles/keycloak_quarkus/tasks/main.yml +++ b/roles/keycloak_quarkus/tasks/main.yml @@ -1,69 +1,128 @@ --- # tasks file for keycloak - name: Check prerequisites - ansible.builtin.include_tasks: prereqs.yml + ansible.builtin.include_tasks: + file: prereqs.yml + apply: + tags: + - prereqs tags: - prereqs + - always -- name: Include firewall config tasks - ansible.builtin.include_tasks: firewalld.yml - when: keycloak_quarkus_configure_firewalld +- name: Check for deprecations + ansible.builtin.include_tasks: + file: deprecations.yml + apply: + tags: + - always tags: - - firewall + - always + +- name: Distro specific tasks + ansible.builtin.include_tasks: + file: "{{ ansible_os_family | lower }}.yml" + apply: + tags: + - unbound + tags: + - unbound - name: Include install tasks - ansible.builtin.include_tasks: install.yml + ansible.builtin.include_tasks: + file: install.yml + apply: + tags: + - install tags: - install - name: Include systemd tasks - ansible.builtin.include_tasks: systemd.yml + ansible.builtin.include_tasks: + file: systemd.yml + apply: + tags: + - systemd tags: - systemd -- name: "Configure config for keycloak service" - ansible.builtin.template: - src: keycloak.conf.j2 - dest: "{{ keycloak.home }}/conf/keycloak.conf" - owner: "{{ keycloak.service_user }}" - group: "{{ keycloak.service_group }}" - mode: 0644 - become: yes - notify: - - restart keycloak +- name: Include configuration key store tasks + ansible.builtin.include_tasks: + file: config_store.yml + apply: + tags: + - install + when: keycloak.config_key_store_enabled + tags: + - install -- name: "Configure quarkus config for keycloak service" +- name: Create tcpping cluster node list + ansible.builtin.set_fact: + keycloak_quarkus_cluster_nodes: > + {{ keycloak_quarkus_cluster_nodes | default([]) + [ + { + "name": item, + "address": 'jgroups-' + item, + "inventory_host": hostvars[item].keycloak_quarkus_jgroups_ip | default(item) + '[' + (keycloak_quarkus_jgroups_port | string) + ']', + "value": hostvars[item].keycloak_quarkus_jgroups_ip | default(item) + } + ] }} + loop: "{{ ansible_play_batch }}" + when: keycloak_quarkus_ha_enabled and keycloak_quarkus_ha_discovery == 'TCPPING' + +- name: "Configure config files for keycloak service" ansible.builtin.template: - src: quarkus.properties.j2 - dest: "{{ keycloak.home }}/conf/quarkus.properties" + src: "{{ item }}.j2" + dest: "{{ keycloak.home }}/conf/{{ item }}" owner: "{{ keycloak.service_user }}" group: "{{ keycloak.service_group }}" - mode: 0644 - become: yes + mode: '0640' + become: true + loop: + - keycloak.conf + - quarkus.properties + - cache-ispn.xml notify: - - restart keycloak + - rebuild keycloak config + - restart keycloak - name: Ensure logdirectory exists ansible.builtin.file: state: directory - path: "{{ keycloak.home }}/{{ keycloak.log.file | dirname }}" + path: "{{ keycloak.log.file | dirname }}" owner: "{{ keycloak.service_user }}" group: "{{ keycloak.service_group }}" - mode: 0775 - become: yes + mode: '0775' + become: true + +- name: Flush pending handlers + ansible.builtin.meta: flush_handlers - name: "Start and wait for keycloak service" ansible.builtin.include_tasks: start.yml -- name: Check service status - ansible.builtin.command: "systemctl status keycloak" - register: keycloak_service_status - changed_when: False - - name: Link default logs directory ansible.builtin.file: state: link - src: "{{ keycloak.home }}/{{ keycloak.log.file | dirname }}" - dest: /var/log/keycloak - force: yes - become: yes + src: "{{ keycloak.log.file | dirname }}" + dest: "{{ keycloak_quarkus_log_target }}" + force: true + become: true + +- name: Check service status + ansible.builtin.systemd_service: + name: "{{ keycloak.service_name }}" + register: keycloak_service_status + changed_when: false + +- name: "Notify to remove `keycloak_quarkus_bootstrap_admin_user[_password]` env vars" + when: + - not ansible_local.keycloak.general.bootstrapped | default(false) | bool # it was not bootstrapped prior to the current role's execution + - keycloak_service_status.status.ActiveState == "active" # but it is now + ansible.builtin.assert: { that: true, quiet: true } + changed_when: true + notify: + - bootstrapped + +- name: Flush pending handlers + ansible.builtin.meta: flush_handlers diff --git a/roles/keycloak_quarkus/tasks/prereqs.yml b/roles/keycloak_quarkus/tasks/prereqs.yml index ea2b8f4..9b633f3 100644 --- a/roles/keycloak_quarkus/tasks/prereqs.yml +++ b/roles/keycloak_quarkus/tasks/prereqs.yml @@ -2,33 +2,150 @@ - name: Validate admin console password ansible.builtin.assert: that: - - keycloak_quarkus_admin_pass | length > 12 - quiet: True - fail_msg: "The console administrator password is empty or invalid. Please set the keycloak_quarkus_admin_pass variable to a 12+ char long string" + - keycloak_quarkus_bootstrap_admin_password | length > 12 + quiet: true + fail_msg: "The console administrator password is empty or invalid. Please set the keycloak_quarkus_bootstrap_admin_password to a 12+ char long string" success_msg: "{{ 'Console administrator password OK' }}" +- name: Validate http_relative_path + ansible.builtin.assert: + that: + - keycloak_quarkus_http_relative_path is regex('^/.*') + quiet: true + fail_msg: "The relative path for keycloak_quarkus_http_relative_path must begin with /" + success_msg: "{{ 'Relative path OK' }}" + +- name: Validate http_management_relative_path + ansible.builtin.assert: + that: + - keycloak_quarkus_http_management_relative_path is regex('^/.*') + quiet: true + fail_msg: "The relative path for keycloak_quarkus_http_management_relative_path must begin with /" + success_msg: "{{ 'Relative mgmt path OK' }}" + when: keycloak_quarkus_http_management_relative_path is defined + - name: Validate configuration ansible.builtin.assert: that: - - (keycloak_quarkus_ha_enabled and keycloak_quarkus_db_enabled) or (not keycloak_quarkus_ha_enabled and keycloak_quarkus_db_enabled) or (not keycloak_quarkus_ha_enabled and not keycloak_quarkus_db_enabled) - quiet: True - fail_msg: "Cannot install HA setup without a backend database service. Check keycloak_quarkus_ha_enabled and keycloak_quarkus_db_enabled" + - (keycloak_quarkus_ha_enabled and keycloak_quarkus_db_enabled) or + (not keycloak_quarkus_ha_enabled and keycloak_quarkus_db_enabled) or + (not keycloak_quarkus_ha_enabled and not keycloak_quarkus_db_enabled) + quiet: true + fail_msg: "HA setup requires a backend database service. Check keycloak_quarkus_ha_enabled and keycloak_quarkus_db_enabled" success_msg: "{{ 'Configuring HA' if keycloak_quarkus_ha_enabled else 'Configuring standalone' }}" -# - name: Validate credentials -# ansible.builtin.assert: -# that: -# - (rhn_username is defined and keycloak_rhsso_enable) or not keycloak_rhsso_enable or keycloak_offline_install -# - (rhn_password is defined and keycloak_rhsso_enable) or not keycloak_rhsso_enable or keycloak_offline_install -# quiet: True -# fail_msg: "Cannot install Red Hat SSO without RHN credentials. Check rhn_username and rhn_password are defined" -# success_msg: "{{ 'Installing Red Hat Single Sign-On' if keycloak_rhsso_enable else 'Installing keycloak.org' }}" +- name: Validate OS family + ansible.builtin.assert: + that: + - ansible_os_family in ["RedHat", "Debian"] + quiet: true + fail_msg: "Can only install on RedHat or Debian OS families; found {{ ansible_os_family }}" + success_msg: "Installing on {{ ansible_os_family }}" + +- name: Load OS specific variables + ansible.builtin.include_vars: "vars/{{ ansible_os_family | lower }}.yml" + tags: + - always - name: Ensure required packages are installed ansible.builtin.include_tasks: fastpackages.yml vars: - packages_list: - - "{{ keycloak_quarkus_jvm_package }}" - - unzip - - procps-ng - - initscripts \ No newline at end of file + 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: "Check run keytool" + changed_when: false + ansible.builtin.command: keytool -help + register: keytool_check + ignore_errors: true + + - name: "Fail when no keytool found" + when: keytool_check.rc != 0 + ansible.builtin.fail: + msg: "keytool NOT found in the PATH, but is required for setting up the configuration key store" + +- name: "Validate providers" + ansible.builtin.assert: + that: > + item.id is defined and item.id | length > 0 and + ( (item.spi is defined and item.spi | length > 0) or + (item.url is defined and item.url | length > 0) or + ( item.maven is defined and item.maven.repository_url is defined and item.maven.repository_url | length > 0 and + item.maven.group_id is defined and item.maven.group_id | length > 0 and + item.maven.artifact_id is defined and item.maven.artifact_id | length > 0) or + (item.local_path is defined and item.local_path | length > 0) + ) + quiet: true + fail_msg: > + Providers definition incorrect; `id` and one of `spi`, `url`, `local_path`, or `maven` are mandatory. `key` and `value` are mandatory for each property + loop: "{{ keycloak_quarkus_providers }}" + +- name: "Validate policies" + ansible.builtin.assert: + that: + - item.name is defined and item.name | length > 0 + - item.url is defined and item.url | length > 0 + - item.type is not defined or item.type | lower in keycloak_quarkus_supported_policy_types + quiet: true + fail_msg: > + Policy definition is incorrect: `name` and one of `url` are mandatory, `type` needs to be left empty or one of {{ keycloak_quarkus_supported_policy_types }}. + loop: "{{ keycloak_quarkus_policies }}" + +- name: "Validate additional env variables" + ansible.builtin.assert: + that: + - item.key is defined and item.key | length > 0 + - item.value is defined and item.value | length > 0 + quiet: true + fail_msg: "Additional env variable definition is incorrect: `key` and `value` are mandatory." + no_log: true + loop: "{{ keycloak_quarkus_additional_env_vars }}" + +- name: "Validate proxy-headers" + ansible.builtin.assert: + that: + - keycloak_quarkus_proxy_headers | lower in ['', 'forwarded', 'xforwarded'] + quiet: true + fail_msg: "keycloak_quarkus_proxy_headers must be either '', 'forwarded' or 'xforwarded'" diff --git a/roles/keycloak_quarkus/tasks/rebuild_config.yml b/roles/keycloak_quarkus/tasks/rebuild_config.yml new file mode 100644 index 0000000..1d43127 --- /dev/null +++ b/roles/keycloak_quarkus/tasks/rebuild_config.yml @@ -0,0 +1,7 @@ +--- +# cf. https://www.keycloak.org/server/configuration#_optimize_the_keycloak_startup +- name: "Rebuild {{ keycloak.service_name }} config" + ansible.builtin.shell: | # noqa blocked_modules shell is necessary here + env -i bash -c "set -a ; source {{ keycloak_quarkus_sysconf_file }} ; {{ keycloak.home }}/bin/kc.sh build " + become: true + changed_when: true diff --git a/roles/keycloak_quarkus/tasks/redhat.yml b/roles/keycloak_quarkus/tasks/redhat.yml new file mode 100644 index 0000000..26d552b --- /dev/null +++ b/roles/keycloak_quarkus/tasks/redhat.yml @@ -0,0 +1,10 @@ +--- +- name: Include firewall config tasks + ansible.builtin.include_tasks: + file: firewalld.yml + apply: + tags: + - firewall + when: keycloak_quarkus_configure_firewalld + tags: + - firewall diff --git a/roles/keycloak_quarkus/tasks/restart.yml b/roles/keycloak_quarkus/tasks/restart.yml index eff9ddf..3aa97f6 100644 --- a/roles/keycloak_quarkus/tasks/restart.yml +++ b/roles/keycloak_quarkus/tasks/restart.yml @@ -1,7 +1,23 @@ --- - name: "Restart and enable {{ keycloak.service_name }} service" ansible.builtin.systemd: - name: keycloak - enabled: yes + name: "{{ keycloak.service_name }}" + enabled: true state: restarted - become: yes + daemon_reload: true + become: true + +- name: "Wait until {{ keycloak.service_name }} service becomes active {{ keycloak.health_url }}" + ansible.builtin.uri: + url: "{{ keycloak.health_url }}" + register: keycloak_status + until: keycloak_status.status == 200 + retries: "{{ keycloak_quarkus_restart_health_check_retries }}" + delay: "{{ keycloak_quarkus_restart_health_check_delay }}" + when: internal_force_health_check | default(keycloak_quarkus_restart_health_check) + +- name: Wait to give distributed ispn caches time to (re-)replicate back onto first host + ansible.builtin.pause: + seconds: "{{ keycloak_quarkus_restart_pause }}" + when: + - keycloak_quarkus_ha_enabled diff --git a/roles/keycloak_quarkus/tasks/restart/none.yml b/roles/keycloak_quarkus/tasks/restart/none.yml new file mode 100644 index 0000000..d048959 --- /dev/null +++ b/roles/keycloak_quarkus/tasks/restart/none.yml @@ -0,0 +1,4 @@ +--- +- name: "Display message" + ansible.builtin.debug: + msg: "keycloak_quarkus_restart_strategy is none, skipping restart" diff --git a/roles/keycloak_quarkus/tasks/restart/serial.yml b/roles/keycloak_quarkus/tasks/restart/serial.yml new file mode 100644 index 0000000..26397d3 --- /dev/null +++ b/roles/keycloak_quarkus/tasks/restart/serial.yml @@ -0,0 +1,11 @@ +--- +- name: "Restart services in serial, with optional healtch check (keycloak_quarkus_restart_health_check)" + throttle: 1 + block: + - name: "Restart and enable {{ keycloak.service_name }} service on {{ item }}" + ansible.builtin.include_tasks: + file: restart.yml + apply: + delegate_to: "{{ item }}" + run_once: true + loop: "{{ ansible_play_hosts }}" diff --git a/roles/keycloak_quarkus/tasks/restart/serial_then_parallel.yml b/roles/keycloak_quarkus/tasks/restart/serial_then_parallel.yml new file mode 100644 index 0000000..d883ff1 --- /dev/null +++ b/roles/keycloak_quarkus/tasks/restart/serial_then_parallel.yml @@ -0,0 +1,20 @@ +--- +- name: Verify first restarted service with health URL, then rest restart in parallel + block: + - name: "Restart and enable {{ keycloak.service_name }} service on initial host" + ansible.builtin.include_tasks: + file: restart.yml + apply: + delegate_to: "{{ ansible_play_hosts | first }}" + run_once: true + vars: + internal_force_health_check: true + + - name: "Restart and enable {{ keycloak.service_name }} service on other hosts" + ansible.builtin.systemd: + name: "{{ keycloak.service_name }}" + enabled: true + state: restarted + daemon_reload: true + become: true + when: inventory_hostname != ansible_play_hosts | first diff --git a/roles/keycloak_quarkus/tasks/start.yml b/roles/keycloak_quarkus/tasks/start.yml index bdf42f9..5a3ad5f 100644 --- a/roles/keycloak_quarkus/tasks/start.yml +++ b/roles/keycloak_quarkus/tasks/start.yml @@ -2,9 +2,10 @@ - name: "Start {{ keycloak.service_name }} service" ansible.builtin.systemd: name: keycloak - enabled: yes + enabled: true state: started - become: yes + daemon_reload: true + become: true - name: "Wait until {{ keycloak.service_name }} becomes active {{ keycloak.health_url }}" ansible.builtin.uri: @@ -12,4 +13,5 @@ register: keycloak_status until: keycloak_status.status == 200 retries: 25 - delay: 10 \ No newline at end of file + delay: 10 + when: internal_force_health_check | default(keycloak_quarkus_restart_health_check) diff --git a/roles/keycloak_quarkus/tasks/systemd.yml b/roles/keycloak_quarkus/tasks/systemd.yml index ee8a1cc..fda37f5 100644 --- a/roles/keycloak_quarkus/tasks/systemd.yml +++ b/roles/keycloak_quarkus/tasks/systemd.yml @@ -1,24 +1,16 @@ --- -- name: Determine JAVA_HOME for selected JVM RPM # noqa blocked_modules - ansible.builtin.shell: | - set -o pipefail - rpm -ql {{ keycloak_quarkus_jvm_package }} | grep -Po '/usr/lib/jvm/.*(?=/bin/java$)' - args: - executable: /bin/bash - changed_when: False - register: rpm_java_home - -- name: "Configure sysconfig file for keycloak service" - become: yes +- name: "Configure sysconfig file for {{ keycloak.service_name }} service" + become: true ansible.builtin.template: src: keycloak-sysconfig.j2 - dest: /etc/sysconfig/keycloak + dest: "{{ keycloak_quarkus_sysconf_file }}" owner: root group: root - mode: 0644 + mode: '0640' vars: - keycloak_rpm_java_home: "{{ rpm_java_home.stdout }}" + keycloak_sys_pkg_java_home: "{{ keycloak_quarkus_pkg_java_home }}" notify: + - rebuild keycloak config - restart keycloak - name: "Configure systemd unit file for keycloak service" @@ -27,14 +19,9 @@ dest: /etc/systemd/system/keycloak.service owner: root group: root - mode: 0644 - become: yes + mode: '0644' + become: true register: systemdunit notify: + - rebuild keycloak config - restart keycloak - -- name: Reload systemd - become: yes - ansible.builtin.systemd: - daemon_reload: yes - when: systemdunit.changed diff --git a/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 b/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 new file mode 100644 index 0000000..2d745d5 --- /dev/null +++ b/roles/keycloak_quarkus/templates/cache-ispn.xml.j2 @@ -0,0 +1,117 @@ +{{ ansible_managed | comment('xml') }} + + + + +{% set stack_expression='' %} +{% if keycloak_quarkus_ha_enabled %} +{% if keycloak_quarkus_ha_discovery == 'TCPPING' %} +{% set stack_expression='stack="tcpping"' %} + + + + + + +{% elif keycloak_quarkus_ha_discovery == 'JDBCPING' %} +{% set stack_expression='stack="JDBC_PING2"' %} +{% endif %} +{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 b/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 index a3fdf57..9efd068 100644 --- a/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 +++ b/roles/keycloak_quarkus/templates/keycloak-sysconfig.j2 @@ -1,5 +1,15 @@ -# {{ ansible_managed }} -KEYCLOAK_ADMIN={{ keycloak_quarkus_admin_user }} -KEYCLOAK_ADMIN_PASSWORD='{{ keycloak_quarkus_admin_pass }}' -PATH={{ keycloak_java_home | default(keycloak_rpm_java_home, true) }}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -JAVA_HOME={{ keycloak_java_home | default(keycloak_rpm_java_home, true) }} +{{ ansible_managed | comment }} +{% if not ansible_local.keycloak.general.bootstrapped | default(false) | bool %} +KC_BOOTSTRAP_ADMIN_USERNAME={{ keycloak_quarkus_bootstrap_admin_user }} +KC_BOOTSTRAP_ADMIN_PASSWORD='{{ keycloak_quarkus_bootstrap_admin_password }}' +{% else %} +{{ keycloak.bootstrap_mnemonic }} +{% endif %} +PATH="{{ keycloak_quarkus_java_home | default(keycloak_sys_pkg_java_home, true) }}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +JAVA_HOME="{{ keycloak_quarkus_java_home | default(keycloak_sys_pkg_java_home, true) }}" +JAVA_OPTS="{{ keycloak_quarkus_java_opts }}" + +# Custom ENV variables +{% for env in keycloak_quarkus_additional_env_vars %} +{{ env.key }}={{ env.value }} +{% endfor %} diff --git a/roles/keycloak_quarkus/templates/keycloak.conf.j2 b/roles/keycloak_quarkus/templates/keycloak.conf.j2 index c02dbae..7642715 100644 --- a/roles/keycloak_quarkus/templates/keycloak.conf.j2 +++ b/roles/keycloak_quarkus/templates/keycloak.conf.j2 @@ -1,43 +1,84 @@ -# {{ ansible_managed }} +{{ ansible_managed | comment }} {% 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.config_key_store_enabled %} +# Config store +config-keystore={{ keycloak_quarkus_config_key_store_file }} +config-keystore-password={{ keycloak_quarkus_config_key_store_password }} +{% endif %} # Observability -metrics-enabled={{ keycloak_quarkus_metrics_enabled }} -health-enabled={{ keycloak_quarkus_health_enabled }} +metrics-enabled={{ keycloak_quarkus_metrics_enabled | lower }} +health-enabled={{ keycloak_quarkus_health_enabled | lower }} # HTTP -http-enabled={{ keycloak_quarkus_http_enabled }} +http-enabled={{ keycloak_quarkus_http_enabled | lower }} +{% if keycloak_quarkus_http_enabled %} http-port={{ keycloak_quarkus_http_port }} +{% endif %} +http-relative-path={{ keycloak_quarkus_http_relative_path }} +http-host={{ keycloak_quarkus_http_host }} + +# Management +http-management-port={{ keycloak_quarkus_http_management_port }} +{% if keycloak_quarkus_http_management_relative_path is defined and keycloak_quarkus_http_management_relative_path | length > 0 %} +http-management-relative-path={{ keycloak_quarkus_http_management_relative_path }} +{% endif %} # HTTPS https-port={{ keycloak_quarkus_https_port }} -{% if keycloak_quarkus_https_enabled %} +{% if keycloak_quarkus_https_key_file_enabled %} https-certificate-file={{ keycloak_quarkus_cert_file}} https-certificate-key-file={{ keycloak_quarkus_key_file }} {% endif %} +{% if keycloak_quarkus_https_key_store_enabled %} +https-key-store-file={{ keycloak_quarkus_https_key_store_file }} +https-key-store-password={{ keycloak_quarkus_https_key_store_password }} +{% endif %} +{% if keycloak_quarkus_https_trust_store_enabled %} +https-trust-store-file={{ keycloak_quarkus_https_trust_store_file }} +https-trust-store-password={{ keycloak_quarkus_https_trust_store_password }} +{% endif %} -# Hostname for the Keycloak server. -hostname={{ keycloak_quarkus_host }} -hostname-path={{ keycloak_quarkus_http_relative_path }} +# Client URL configuration +hostname={{ keycloak_quarkus_hostname }} +hostname-admin={{ keycloak_quarkus_hostname_admin }} +hostname-strict={{ keycloak_quarkus_hostname_strict | lower }} +hostname-backchannel-dynamic={{ keycloak_quarkus_hostname_backchannel_dynamic | lower }} # Cluster {% if keycloak_quarkus_ha_enabled %} cache=ispn -cache-config-file=conf/cache-ispn.xml -cache-stack=tcp +cache-config-file=cache-ispn.xml +{% if keycloak_quarkus_cache_remote %} +cache-remote-username={{ keycloak_quarkus_cache_remote_username }} +cache-remote-password={{ keycloak_quarkus_cache_remote_password }} +cache-remote-host={{ keycloak_quarkus_cache_remote_host }} +cache-remote-port={{ keycloak_quarkus_cache_remote_port }} +cache-remote-tls-enabled={{ keycloak_quarkus_cache_remote_tls_enabled | lower }} +{% endif %} {% endif %} -# Proxy +{% if keycloak_quarkus_proxy_headers | length > 0 %} +proxy-headers={{ keycloak_quarkus_proxy_headers | lower }} +{% elif keycloak_quarkus_proxy_mode is defined and keycloak_quarkus_proxy_mode != "none" %} +# Deprecated Proxy configuration proxy={{ keycloak_quarkus_proxy_mode }} -# Do not attach route to cookies and rely on the session affinity capabilities from reverse proxy -#spi-sticky-session-encoder-infinispan-should-attach-route=false +{% endif %} + +spi-sticky-session-encoder-infinispan-should-attach-route={{ keycloak_quarkus_spi_sticky_session_encoder_infinispan_should_attach_route | d(true) | lower }} + +# Transaction +transaction-xa-enabled={{ keycloak_quarkus_transaction_xa_enabled | lower }} # Logging #log-format=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c] (%t) %s%e%n @@ -45,3 +86,22 @@ log={{ keycloak_quarkus_log }} log-level={{ keycloak.log.level }} log-file={{ keycloak.log.file }} log-file-format={{ keycloak.log.format }} + +# Vault +{% if keycloak_quarkus_ks_vault_enabled %} +vault=keystore +vault-file={{ keycloak_quarkus_ks_vault_file }} +vault-type={{ keycloak_quarkus_ks_vault_type }} +vault-pass={{ keycloak_quarkus_ks_vault_pass }} +{% endif %} + + +# Providers +{% for provider in keycloak_quarkus_providers %} +{% if provider.default is defined and provider.default %} +spi-{{ provider.spi }}-provider={{ provider.id }} +{% endif %} +{% if provider.properties is defined %}{% for property in provider.properties %} +spi-{{ provider.spi }}-{{ provider.id }}-{{ property.key }}={{ property.value }} +{% endfor %}{% endif %} +{% endfor %} diff --git a/roles/keycloak_quarkus/templates/keycloak.fact.j2 b/roles/keycloak_quarkus/templates/keycloak.fact.j2 new file mode 100644 index 0000000..e035110 --- /dev/null +++ b/roles/keycloak_quarkus/templates/keycloak.fact.j2 @@ -0,0 +1,2 @@ +[general] +bootstrapped={{ bootstrapped | lower }} diff --git a/roles/keycloak_quarkus/templates/keycloak.service.j2 b/roles/keycloak_quarkus/templates/keycloak.service.j2 index 90242cf..96207ed 100644 --- a/roles/keycloak_quarkus/templates/keycloak.service.j2 +++ b/roles/keycloak_quarkus/templates/keycloak.service.j2 @@ -1,14 +1,33 @@ -# {{ ansible_managed }} +{{ ansible_managed | comment }} [Unit] Description=Keycloak Server After=network.target [Service] -Type=simple -EnvironmentFile=-/etc/sysconfig/keycloak -PIDFile={{ keycloak_quarkus_service_pidfile }} -ExecStart={{ keycloak.home }}/bin/kc.sh start --auto-build --log={{ keycloak_quarkus_log }} +EnvironmentFile=-{{ keycloak_quarkus_sysconf_file }} +{% if keycloak_quarkus_start_dev %} +ExecStart={{ keycloak.home }}/bin/kc.sh start-dev +{% else %} +ExecStart={{ keycloak.home }}/bin/kc.sh start --optimized +{% endif %} User={{ keycloak.service_user }} +Group={{ keycloak.service_group }} +SuccessExitStatus=0 143 +{% if keycloak_quarkus_service_restart_always %} +Restart=always +{% elif keycloak_quarkus_service_restart_on_failure %} +Restart=on-failure +{% endif %} +RestartSec={{ keycloak_quarkus_service_restartsec }} +{% if keycloak_quarkus_http_port | int < 1024 or keycloak_quarkus_https_port | int < 1024 %} +AmbientCapabilities=CAP_NET_BIND_SERVICE +{% endif %} +{% if keycloak_quarkus_systemd_wait_for_port %} +ExecStartPost=/usr/bin/timeout {{ keycloak_quarkus_systemd_wait_for_timeout }} sh -c 'while ! ss -H -t -l -n sport = :{{ keycloak_quarkus_systemd_wait_for_port_number }} | grep -q "^LISTEN.*:{{ keycloak_quarkus_systemd_wait_for_port_number }}"; do sleep 1; done && /bin/sleep {{ keycloak_quarkus_systemd_wait_for_delay }}' +{% endif %} +{% if keycloak_quarkus_systemd_wait_for_log %} +ExecStartPost=/usr/bin/timeout {{ keycloak_quarkus_systemd_wait_for_timeout }} sh -c 'cat {{ keycloak.log.file }} | sed "/Profile.*activated/ q" && /bin/sleep {{ keycloak_quarkus_systemd_wait_for_delay }}' +{% endif %} [Install] WantedBy=multi-user.target diff --git a/roles/keycloak_quarkus/templates/quarkus.properties.j2 b/roles/keycloak_quarkus/templates/quarkus.properties.j2 index cf133a3..06d9077 100644 --- a/roles/keycloak_quarkus/templates/quarkus.properties.j2 +++ b/roles/keycloak_quarkus/templates/quarkus.properties.j2 @@ -1,19 +1,29 @@ -# {{ ansible_managed }} +{{ ansible_managed | comment }} {% if keycloak_quarkus_ha_enabled %} -quarkus.infinispan-client.server-list={{ keycloak_quarkus_ispn_url }} +{% if keycloak_quarkus_version.split('.')[0] | int < 22 %} +quarkus.infinispan-client.server-list={{ keycloak_quarkus_cache_remote_host }} +quarkus.infinispan-client.auth-username={{ keycloak_quarkus_cache_remote_username }} +quarkus.infinispan-client.auth-password={{ keycloak_quarkus_cache_remote_password }} +{% else %} +quarkus.infinispan-client.hosts={{ keycloak_quarkus_cache_remote_host }} +quarkus.infinispan-client.username={{ keycloak_quarkus_cache_remote_username }} +quarkus.infinispan-client.password={{ keycloak_quarkus_cache_remote_password }} +{% endif %} quarkus.infinispan-client.client-intelligence=HASH_DISTRIBUTION_AWARE quarkus.infinispan-client.use-auth=true -quarkus.infinispan-client.auth-username={{ keycloak_quarkus_ispn_user }} -quarkus.infinispan-client.auth-password={{ keycloak_quarkus_ispn_pass }} quarkus.infinispan-client.auth-realm=default quarkus.infinispan-client.auth-server-name=infinispan -quarkus.infinispan-client.sasl-mechanism={{ keycloak_quarkus_ispn_sasl_mechanism }} -{% if keycloak_quarkus_ispn_use_ssl %} -quarkus.infinispan-client.trust-store={{ keycloak_quarkus_ispn_trust_store_path }} -quarkus.infinispan-client.trust-store-password={{ keycloak_quarkus_ispn_trust_store_password }} +quarkus.infinispan-client.sasl-mechanism={{ keycloak_quarkus_cache_remote_sasl_mechanism }} +{% if keycloak_quarkus_cache_remote_tls_enabled %} +quarkus.infinispan-client.trust-store={{ keycloak_quarkus_https_trust_store_file }} +quarkus.infinispan-client.trust-store-password={{ keycloak_quarkus_https_trust_store_password }} quarkus.infinispan-client.trust-store-type=jks {% endif %} #quarkus.infinispan-client.use-schema-registration=true -#quarkus.infinispan-client.auth-client-subject -#quarkus.infinispan-client.auth-callback-handler -{% endif %} \ No newline at end of file +{% endif %} +quarkus.log.file.rotation.max-file-size={{ keycloak_quarkus_log_max_file_size }} +quarkus.log.file.rotation.max-backup-index={{ keycloak_quarkus_log_max_backup_index }} +quarkus.log.file.rotation.file-suffix={{ keycloak_quarkus_log_file_suffix }} +{% if keycloak_quarkus_db_enabled %} +quarkus.transaction-manager.enable-recovery=true +{% endif %} diff --git a/roles/keycloak_quarkus/vars/debian.yml b/roles/keycloak_quarkus/vars/debian.yml new file mode 100644 index 0000000..5391dda --- /dev/null +++ b/roles/keycloak_quarkus/vars/debian.yml @@ -0,0 +1,12 @@ +--- +keycloak_quarkus_varjvm_package: "{{ keycloak_quarkus_jvm_package | default('openjdk-21-jdk-headless') }}" +keycloak_quarkus_prereq_package_list: + - "{{ keycloak_quarkus_varjvm_package }}" + - bash + - unzip + - procps + - apt + - tzdata +keycloak_quarkus_sysconf_file: /etc/default/keycloak +keycloak_quarkus_pkg_java_home: "/usr/lib/jvm/java-{{ keycloak_quarkus_varjvm_package | \ + regex_search('(?!:openjdk-)[0-9.]+') }}-openjdk-{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}" diff --git a/roles/keycloak_quarkus/vars/main.yml b/roles/keycloak_quarkus/vars/main.yml index abd6413..997d7dc 100644 --- a/roles/keycloak_quarkus/vars/main.yml +++ b/roles/keycloak_quarkus/vars/main.yml @@ -1,15 +1,17 @@ --- -keycloak: +keycloak: # noqa var-naming this is an internal dict of interpolated values home: "{{ keycloak_quarkus_home }}" config_dir: "{{ keycloak_quarkus_config_dir }}" bundle: "{{ keycloak_quarkus_archive }}" service_name: "keycloak" - health_url: "http://localhost:8080/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 }}" offline_install: "{{ keycloak_quarkus_offline_install }}" + config_key_store_enabled: "{{ keycloak_quarkus_config_key_store_password != '' }}" log: file: "{{ keycloak_quarkus_home }}/{{ keycloak_quarkus_log_file }}" level: "{{ keycloak_quarkus_log_level }}" - format: "{{ keycloak_quarkus_log_format }}" \ No newline at end of file + format: "{{ keycloak_quarkus_log_format }}" + bootstrap_mnemonic: "# ansible-middleware/keycloak: bootstrapped" diff --git a/roles/keycloak_quarkus/vars/redhat.yml b/roles/keycloak_quarkus/vars/redhat.yml new file mode 100644 index 0000000..458c841 --- /dev/null +++ b/roles/keycloak_quarkus/vars/redhat.yml @@ -0,0 +1,11 @@ +--- +keycloak_quarkus_varjvm_package: "{{ keycloak_quarkus_jvm_package | default('java-21-openjdk-headless') }}" +keycloak_quarkus_prereq_package_list: + - "{{ keycloak_quarkus_varjvm_package }}" + - bash + - unzip + - procps-ng + - initscripts + - tzdata-java +keycloak_quarkus_sysconf_file: /etc/sysconfig/keycloak +keycloak_quarkus_pkg_java_home: "/etc/alternatives/jre_{{ keycloak_quarkus_varjvm_package | regex_search('(?<=java-)[0-9.]+') }}" diff --git a/roles/keycloak_realm/README.md b/roles/keycloak_realm/README.md index 91e6b8f..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 ------------- @@ -15,11 +16,10 @@ Role Defaults |`keycloak_http_port`| HTTP port | `8080` | |`keycloak_https_port`| TLS HTTP port | `8443` | |`keycloak_auth_realm`| Name of the main authentication realm | `master` | -|`keycloak_rhsso_enable`| Define service is an upstream(Keycloak) or RHSSO | `master` | |`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 }}` | @@ -72,6 +72,8 @@ Refer to [docs](https://docs.ansible.com/ansible/latest/collections/community/ge ```yaml - name: + id: + client_id: roles: realm: public_client: @@ -79,6 +81,9 @@ Refer to [docs](https://docs.ansible.com/ansible/latest/collections/community/ge users: ``` +`name` and either `id` or `client_id` are required. + + * `keycloak_client_users`, a list of: ```yaml @@ -132,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 4975380..a294cbe 100644 --- a/roles/keycloak_realm/defaults/main.yml +++ b/roles/keycloak_realm/defaults/main.yml @@ -4,7 +4,6 @@ keycloak_host: localhost keycloak_http_port: 8080 keycloak_https_port: 8443 keycloak_management_http_port: 9990 -keycloak_rhsso_enable: False ### Keycloak administration console user keycloak_admin_user: admin @@ -27,23 +26,24 @@ keycloak_admin_password: '' # and users is a list of account, see below for the format definition # an empty name will skip the creation of the client # -#keycloak_clients: -# - name: '' -# roles: "{{ keycloak_client_default_roles }}" -# realm: "{{ keycloak_realm }}" -# public_client: "{{ keycloak_client_public }}" -# web_origins: "{{ keycloak_client_web_origins }}" -# users: "{{ keycloak_client_users }}" +# keycloak_clients: +# - name: '' +# roles: "{{ keycloak_client_default_roles }}" +# realm: "{{ keycloak_realm }}" +# public_client: "{{ keycloak_client_public }}" +# web_origins: "{{ keycloak_client_web_origins }}" +# redirect_uris: "{{ keycloak_client_redirect_uris }}" +# users: "{{ keycloak_client_users }}" keycloak_clients: [] # list of roles to create in the client keycloak_client_default_roles: [] # if True, create a public client; otherwise, a confidetial client -keycloak_client_public: True +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 45b5998..7c24a7c 100644 --- a/roles/keycloak_realm/meta/argument_specs.yml +++ b/roles/keycloak_realm/meta/argument_specs.yml @@ -10,7 +10,7 @@ argument_specs: # line 5 of keycloak_realm/defaults/main.yml default: "/auth" description: "Context path for rest calls" - type: "str" + type: "str" keycloak_http_port: # line 4 of keycloak_realm/defaults/main.yml default: 8080 @@ -26,11 +26,6 @@ argument_specs: default: 9990 description: "Management port" type: "int" - keycloak_rhsso_enable: - # line 7 of keycloak_realm/defaults/main.yml - default: false - description: "Enable Red Hat Single Sign-on" - type: "bool" keycloak_admin_user: # line 10 of keycloak_realm/defaults/main.yml default: "admin" @@ -58,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: @@ -88,11 +83,53 @@ argument_specs: type: "list" keycloak_url: # line 14 of keycloak_realm/vars/main.yml - default: "http://{{ keycloak_host }}:{{ keycloak_http_port }}" + default: "http://{{ keycloak_host }}:{{ keycloak_http_port + ( keycloak_jboss_port_offset | default(0) ) }}" description: "URL for configuration rest calls" type: "str" keycloak_management_url: # line 15 of keycloak_realm/vars/main.yml - default: "http://{{ keycloak_host }}:{{ keycloak_management_http_port }}" + default: "http://{{ keycloak_host }}:{{ keycloak_management_http_port + ( keycloak_jboss_port_offset | default(0) ) }}" description: "URL for management console rest calls" type: "str" + downstream: + options: + sso_version: + default: "7.6.0" + description: "Red Hat Single Sign-On version" + type: "str" + sso_dest: + default: "/opt/sso" + description: "Root installation directory" + type: "str" + sso_installdir: + default: "{{ sso_dest }}/rh-sso-{{ sso_version.split('.')[0] }}.{{ sso_version.split('.')[1] }}" + description: "Installation path for Red Hat SSO" + type: "str" + sso_apply_patches: + default: false + description: "Install Red Hat SSO most recent cumulative patch" + type: "bool" + sso_enable: + default: true + description: "Enable Red Hat Single Sign-on installation" + type: "bool" + rhbk_version: + default: "22.0.6" + description: "Red Hat Build of Keycloak version" + type: "str" + rhbk_archive: + default: "rhbk-{{ rhbk_version }}.zip" + description: "Red Hat Build of Keycloak install archive filename" + type: "str" + rhbk_dest: + default: "/opt/rhbk" + description: "Root installation directory" + type: "str" + rhbk_installdir: + default: "{{ rhbk_dest }}/rhbk-{{ rhbk_version.split('.')[0] }}.{{ rhbk_version.split('.')[1] }}" + description: "Installation path for Red Hat Build of Keycloak" + type: "str" + rhbk_enable: + default: true + description: "Enable Red Hat Build of Keycloak installation" + type: "bool" diff --git a/roles/keycloak_realm/meta/main.yml b/roles/keycloak_realm/meta/main.yml index 4ce1b73..97c69d2 100644 --- a/roles/keycloak_realm/meta/main.yml +++ b/roles/keycloak_realm/meta/main.yml @@ -8,16 +8,15 @@ galaxy_info: license: Apache License 2.0 - min_ansible_version: "2.9" + min_ansible_version: "2.16" platforms: - - name: EL - versions: - - 8 + - name: EL + versions: + - "8" galaxy_tags: - keycloak - redhat - rhel - - rhn - sso diff --git a/roles/keycloak_realm/tasks/main.yml b/roles/keycloak_realm/tasks/main.yml index 2554958..7595ba3 100644 --- a/roles/keycloak_realm/tasks/main.yml +++ b/roles/keycloak_realm/tasks/main.yml @@ -4,8 +4,8 @@ url: "{{ keycloak_url }}{{ keycloak_context }}/realms/master/protocol/openid-connect/token" method: POST body: "client_id={{ keycloak_auth_client }}&username={{ keycloak_admin_user }}&password={{ keycloak_admin_password }}&grant_type=password" - validate_certs: no - no_log: True + validate_certs: false + no_log: "{{ keycloak_no_log | default('True') }}" register: keycloak_auth_response until: keycloak_auth_response.status == 200 retries: 5 @@ -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 @@ -27,8 +28,8 @@ ansible.builtin.uri: url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms" method: POST - body: "{{ lookup('template','realm.json.j2') }}" - validate_certs: no + body: "{{ lookup('template', 'realm.json.j2') }}" + validate_certs: false body_format: json headers: Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" @@ -36,31 +37,42 @@ when: keycloak_realm_exists.status == 404 - name: Create user federation - community.general.keycloak_user_federation: + middleware_automation.keycloak.keycloak_user_federation: auth_keycloak_url: "{{ keycloak_url }}{{ keycloak_context }}" auth_realm: "{{ keycloak_auth_realm }}" auth_username: "{{ keycloak_admin_user }}" auth_password: "{{ keycloak_admin_password }}" - realm: "{{ item.realm }}" + realm: "{{ item.realm | default(keycloak_realm) }}" name: "{{ item.name }}" state: present provider_id: "{{ item.provider_id }}" - provider_type: "{{ item.provider_type | default(org.keycloak.storage.UserStorageProvider) }}" + provider_type: "{{ item.provider_type | default(org.keycloak.storage.UserStorageProvider) }}" config: "{{ item.config }}" mappers: "{{ item.mappers | default(omit) }}" - no_log: True + no_log: "{{ keycloak_no_log | default('True') }}" register: create_user_federation_result loop: "{{ keycloak_user_federation | flatten }}" when: keycloak_user_federation is defined +- name: Validate Keycloak clients + ansible.builtin.assert: + that: + - item.name is defined and item.name | length > 0 + - (item.client_id is defined and item.client_id | length > 0) or (item.id is defined and item.id | length > 0) + fail_msg: "For each keycloak client, attributes `name` and either `id` or `client_id` is required" + quiet: true + loop: "{{ keycloak_clients | flatten }}" + loop_control: + label: "{{ item.name | default('unnamed client') }}" + - name: Create or update a Keycloak client - community.general.keycloak_client: + middleware_automation.keycloak.keycloak_client: auth_client_id: "{{ keycloak_auth_client }}" auth_keycloak_url: "{{ keycloak_url }}{{ keycloak_context }}" auth_realm: "{{ keycloak_auth_realm }}" auth_username: "{{ keycloak_admin_user }}" auth_password: "{{ keycloak_admin_password }}" - realm: "{{ item.realm }}" + realm: "{{ item.realm | default(keycloak_realm) }}" default_roles: "{{ item.roles | default(omit) }}" client_id: "{{ item.client_id | default(omit) }}" id: "{{ item.id | default(omit) }}" @@ -71,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) }}" @@ -79,8 +91,9 @@ service_accounts_enabled: "{{ item.service_accounts_enabled | default(omit) }}" public_client: "{{ item.public_client | default(False) }}" protocol: "{{ item.protocol | default(omit) }}" + attributes: "{{ item.attributes | default(omit) }}" state: present - no_log: 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) @@ -97,4 +110,7 @@ loop: "{{ keycloak_clients | flatten }}" loop_control: loop_var: client - when: "'users' in client" \ No newline at end of file + 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_client_roles.yml b/roles/keycloak_realm/tasks/manage_client_roles.yml index 04cf2fa..fbb25ac 100644 --- a/roles/keycloak_realm/tasks/manage_client_roles.yml +++ b/roles/keycloak_realm/tasks/manage_client_roles.yml @@ -1,7 +1,7 @@ - name: Create client roles - community.general.keycloak_role: + middleware_automation.keycloak.keycloak_role: name: "{{ item }}" - realm: "{{ client.realm }}" + realm: "{{ client.realm | default(keycloak_realm) }}" client_id: "{{ client.name }}" auth_client_id: "{{ keycloak_auth_client }}" auth_keycloak_url: "{{ keycloak_url }}{{ keycloak_context }}" @@ -10,4 +10,4 @@ auth_password: "{{ keycloak_admin_password }}" state: present loop: "{{ client.roles | flatten }}" - no_log: True + no_log: "{{ keycloak_no_log | default('True') }}" diff --git a/roles/keycloak_realm/tasks/manage_client_users.yml b/roles/keycloak_realm/tasks/manage_client_users.yml index ed9fb03..5234cb1 100644 --- a/roles/keycloak_realm/tasks/manage_client_users.yml +++ b/roles/keycloak_realm/tasks/manage_client_users.yml @@ -10,4 +10,4 @@ loop: "{{ client.users | flatten }}" loop_control: loop_var: user - when: "'client_roles' in user" \ No newline at end of file + when: "'client_roles' in user" 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.yml b/roles/keycloak_realm/tasks/manage_user.yml index 840c738..1f9f7bd 100644 --- a/roles/keycloak_realm/tasks/manage_user.yml +++ b/roles/keycloak_realm/tasks/manage_user.yml @@ -2,7 +2,7 @@ - name: "Check if User Already Exists" ansible.builtin.uri: url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ keycloak_realm }}/users?username={{ user.username }}" - validate_certs: no + validate_certs: false headers: Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" register: keycloak_user_search_result @@ -18,7 +18,7 @@ email: "{{ user.email | default(omit) }}" firstName: "{{ user.firstName | default(omit) }}" lastName: "{{ user.lastName | default(omit) }}" - validate_certs: no + validate_certs: false body_format: json headers: Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" @@ -28,7 +28,7 @@ - name: "Get User" ansible.builtin.uri: url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ keycloak_realm }}/users?username={{ user.username }}" - validate_certs: no + validate_certs: false headers: Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" register: keycloak_user @@ -41,7 +41,7 @@ type: password temporary: false value: "{{ user.password }}" - validate_certs: no + validate_certs: false body_format: json status_code: - 200 diff --git a/roles/keycloak_realm/tasks/manage_user_client_roles.yml b/roles/keycloak_realm/tasks/manage_user_client_roles.yml index 5369094..f9e0329 100644 --- a/roles/keycloak_realm/tasks/manage_user_client_roles.yml +++ b/roles/keycloak_realm/tasks/manage_user_client_roles.yml @@ -1,8 +1,9 @@ --- - name: "Get Realm for role" ansible.builtin.uri: - url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ client_role.realm }}" + url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ client_role.realm | default(keycloak_realm) }}" method: GET + validate_certs: false status_code: - 200 headers: @@ -12,8 +13,11 @@ - name: Check if Mapping is available ansible.builtin.uri: - url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ client_role.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" + url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ client_role.realm | \ + 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: @@ -23,7 +27,9 @@ - name: "Create Role Mapping" ansible.builtin.uri: - url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ client_role.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 }}" + url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ client_role.realm | \ + 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 }}" method: POST body: - id: "{{ item.id }}" @@ -31,7 +37,7 @@ containerId: "{{ item.containerId }}" name: "{{ item.name }}" composite: "{{ item.composite }}" - validate_certs: False + validate_certs: false body_format: json headers: Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" diff --git a/roles/keycloak_realm/tasks/manage_user_roles.yml b/roles/keycloak_realm/tasks/manage_user_roles.yml index e9d18b7..dc74477 100644 --- a/roles/keycloak_realm/tasks/manage_user_roles.yml +++ b/roles/keycloak_realm/tasks/manage_user_roles.yml @@ -3,7 +3,7 @@ ansible.builtin.uri: url: "{{ keycloak_url }}{{ keycloak_context }}/admin/realms/{{ keycloak_realm }}/users?username={{ user.username }}" headers: - validate_certs: no + validate_certs: false Authorization: "Bearer {{ keycloak_auth_response.json.access_token }}" register: keycloak_user @@ -12,9 +12,9 @@ url: "{{ keycloak_url }}{{ keycloak_context }}/realms/master/protocol/openid-connect/token" method: POST body: "client_id={{ keycloak_auth_client }}&username={{ keycloak_admin_user }}&password={{ keycloak_admin_password }}&grant_type=password" - validate_certs: no + validate_certs: false register: keycloak_auth_response - no_log: True + no_log: "{{ keycloak_no_log | default('True') }}" until: keycloak_auth_response.status == 200 retries: 5 delay: 2 diff --git a/roles/keycloak_realm/vars/main.yml b/roles/keycloak_realm/vars/main.yml index 076a8a9..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_management_url: "http://{{ keycloak_host }}:{{ keycloak_management_http_port }}"