diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 504bfbc969..6f4abeaea4 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -678,6 +678,8 @@ files: maintainers: $team_keycloak $modules/keycloak_authentication.py: maintainers: elfelip Gaetan2907 + $modules/keycloak_authz_authorization_scope.py: + maintainers: mattock $modules/keycloak_client_rolemapping.py: maintainers: Gaetan2907 $modules/keycloak_clientscope.py: @@ -1390,7 +1392,7 @@ macros: team_huawei: QijunPan TommyLike edisonxiang freesky-edward hwDCN niuzhenguo xuxiaowei0512 yanzhangi zengchen1024 zhongjun2 team_ipa: Akasurde Nosmoht fxfitz justchris1 team_jboss: Wolfant jairojunior wbrefvem - team_keycloak: eikef ndclt + team_keycloak: eikef ndclt mattock team_linode: InTheCloudDan decentral1se displague rmcintosh Charliekenney23 LBGarber team_macos: Akasurde kyleabenson martinm82 danieljaouen indrajitr team_manageiq: abellotti cben gtanzillo yaacov zgalor dkorn evertmulder diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 8be36333e6..5443811d0b 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -90,6 +90,9 @@ URL_IDENTITY_PROVIDER_MAPPER = "{url}/admin/realms/{realm}/identity-provider/ins URL_COMPONENTS = "{url}/admin/realms/{realm}/components" URL_COMPONENT = "{url}/admin/realms/{realm}/components/{id}" +URL_AUTHZ_AUTHORIZATION_SCOPE = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/scope/{id}" +URL_AUTHZ_AUTHORIZATION_SCOPES = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/scope" + def keycloak_argument_spec(): """ @@ -2331,3 +2334,44 @@ class KeycloakAPI(object): except Exception as e: self.module.fail_json(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)) + + 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.module.fail_json(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.module.fail_json(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.module.fail_json(msg='Could not delete scope %s for client %s in realm %s: %s' % (id, client_id, realm, str(e))) diff --git a/plugins/modules/keycloak_authz_authorization_scope.py b/plugins/modules/keycloak_authz_authorization_scope.py new file mode 100644 index 0000000000..c451d3751e --- /dev/null +++ b/plugins/modules/keycloak_authz_authorization_scope.py @@ -0,0 +1,280 @@ +#!/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_authz_authorization_scope + +short_description: Allows administration of Keycloak client authorization scopes via Keycloak API + +version_added: 6.6.0 + +description: + - This module allows the administration of Keycloak client Authorization Scopes via the Keycloak REST + API. Authorization Scopes are only available if a client has Authorization enabled. + + - This module 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 options used by Keycloak. + The Authorization Services paths and payloads have not officially been documented by the Keycloak project. + U(https://www.puppeteers.net/blog/keycloak-authorization-services-rest-api-paths-and-payload/) + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the authorization scope. + - On C(present), the authorization scope will be created (or updated if it exists already). + - On C(absent), the authorization scope will be removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + name: + description: + - Name of the authorization scope to create. + type: str + required: true + display_name: + description: + - The display name of the authorization scope. + type: str + required: false + icon_uri: + description: + - The icon URI for the authorization scope. + type: str + required: false + client_id: + description: + - The C(clientId) of the Keycloak client that should have the authorization scope. + - This is usually a human-readable name of the Keycloak client. + type: str + required: true + realm: + description: + - The name of the Keycloak realm the Keycloak client is in. + type: str + required: true + +extends_documentation_fragment: + - community.general.keycloak + - community.general.attributes + +author: + - Samuli Seppänen (@mattock) +''' + +EXAMPLES = ''' +- name: Manage Keycloak file:delete authorization scope + keycloak_authz_authorization_scope: + name: file:delete + state: present + display_name: File delete + client_id: myclient + realm: myrealm + auth_keycloak_url: http://localhost:8080/auth + auth_username: keycloak + auth_password: keycloak + auth_realm: master +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + +end_state: + description: Representation of the authorization scope after module execution. + returned: on success + type: complex + contains: + id: + description: ID of the authorization scope. + type: str + returned: when I(state=present) + sample: a6ab1cf2-1001-40ec-9f39-48f23b6a0a41 + name: + description: Name of the authorization scope. + type: str + returned: when I(state=present) + sample: file:delete + display_name: + description: Display name of the authorization scope. + type: str + returned: when I(state=present) + sample: File delete + icon_uri: + description: Icon URI for the authorization scope. + type: str + returned: when I(state=present) + sample: http://localhost/icon.png + +''' + +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(type='str', default='present', + choices=['present', 'absent']), + name=dict(type='str', required=True), + display_name=dict(type='str', required=False), + icon_uri=dict(type='str', required=False), + client_id=dict(type='str', required=True), + realm=dict(type='str', required=True) + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={})) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Convenience variables + state = module.params.get('state') + name = module.params.get('name') + display_name = module.params.get('display_name') + icon_uri = module.params.get('icon_uri') + client_id = module.params.get('client_id') + realm = module.params.get('realm') + + # Get the "id" of the client based on the usually more human-readable + # "clientId" + cid = kc.get_client_id(client_id, realm=realm) + if not cid: + module.fail_json(msg='Invalid client %s for realm %s' % + (client_id, realm)) + + # Get current state of the Authorization Scope using its name as the search + # filter. This returns False if it is not found. + before_authz_scope = kc.get_authz_authorization_scope_by_name( + name=name, client_id=cid, realm=realm) + + # Generate a JSON payload for Keycloak Admin API. This is needed for + # "create" and "update" operations. + desired_authz_scope = {} + desired_authz_scope['name'] = name + desired_authz_scope['displayName'] = display_name + desired_authz_scope['iconUri'] = icon_uri + + # Add "id" to payload for modify operations + if before_authz_scope: + desired_authz_scope['id'] = before_authz_scope['id'] + + # Ensure that undefined (null) optional parameters are presented as empty + # strings in the desired state. This makes comparisons with current state + # much easier. + for k, v in desired_authz_scope.items(): + if not v: + desired_authz_scope[k] = '' + + # Do the above for the current state + if before_authz_scope: + for k in ['displayName', 'iconUri']: + if k not in before_authz_scope: + before_authz_scope[k] = '' + + if before_authz_scope and state == 'present': + changes = False + for k, v in desired_authz_scope.items(): + if before_authz_scope[k] != v: + changes = True + # At this point we know we have to update the object anyways, + # so there's no need to do more work. + break + + if changes: + if module._diff: + result['diff'] = dict(before=before_authz_scope, after=desired_authz_scope) + + if module.check_mode: + result['changed'] = True + result['msg'] = 'Authorization scope would be updated' + module.exit_json(**result) + else: + kc.update_authz_authorization_scope( + payload=desired_authz_scope, id=before_authz_scope['id'], client_id=cid, realm=realm) + result['changed'] = True + result['msg'] = 'Authorization scope updated' + else: + result['changed'] = False + result['msg'] = 'Authorization scope not updated' + + result['end_state'] = desired_authz_scope + elif not before_authz_scope and state == 'present': + if module._diff: + result['diff'] = dict(before={}, after=desired_authz_scope) + + if module.check_mode: + result['changed'] = True + result['msg'] = 'Authorization scope would be created' + module.exit_json(**result) + else: + kc.create_authz_authorization_scope( + payload=desired_authz_scope, client_id=cid, realm=realm) + result['changed'] = True + result['msg'] = 'Authorization scope created' + result['end_state'] = desired_authz_scope + elif before_authz_scope and state == 'absent': + if module._diff: + result['diff'] = dict(before=before_authz_scope, after={}) + + if module.check_mode: + result['changed'] = True + result['msg'] = 'Authorization scope would be removed' + module.exit_json(**result) + else: + kc.remove_authz_authorization_scope( + id=before_authz_scope['id'], client_id=cid, realm=realm) + result['changed'] = True + result['msg'] = 'Authorization scope removed' + elif not before_authz_scope and state == 'absent': + result['changed'] = False + else: + module.fail_json(msg='Unable to determine what to do with authorization scope %s of client %s in realm %s' % ( + name, client_id, realm)) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/keycloak_authz_authorization_scope/aliases b/tests/integration/targets/keycloak_authz_authorization_scope/aliases new file mode 100644 index 0000000000..bd1f024441 --- /dev/null +++ b/tests/integration/targets/keycloak_authz_authorization_scope/aliases @@ -0,0 +1,5 @@ +# 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 + +unsupported diff --git a/tests/integration/targets/keycloak_authz_authorization_scope/readme.adoc b/tests/integration/targets/keycloak_authz_authorization_scope/readme.adoc new file mode 100644 index 0000000000..1941e54efd --- /dev/null +++ b/tests/integration/targets/keycloak_authz_authorization_scope/readme.adoc @@ -0,0 +1,27 @@ +// 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 + +To be able to run these integration tests a keycloak server must be +reachable under a specific url with a specific admin user and password. +The exact values expected for these parameters can be found in +'vars/main.yml' file. A simple way to do this is to use the official +keycloak docker images like this: + +---- +docker run --name mykeycloak -p 8080:8080 -e KC_HTTP_RELATIVE_PATH= -e KEYCLOAK_ADMIN= -e KEYCLOAK_ADMIN_PASSWORD= quay.io/keycloak/keycloak:20.0.2 start-dev +---- + +Example with concrete values inserted: + +---- +docker run --name mykeycloak -p 8080:8080 -e KC_HTTP_RELATIVE_PATH=/auth -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=password quay.io/keycloak/keycloak:20.0.2 start-dev +---- + +This test suite can run against a fresh unconfigured server instance +(no preconfiguration required) and cleans up after itself (undoes all +its config changes) as long as it runs through completly. While its active +it changes the server configuration in the following ways: + + * creating, modifying and deleting some keycloak groups + diff --git a/tests/integration/targets/keycloak_authz_authorization_scope/tasks/main.yml b/tests/integration/targets/keycloak_authz_authorization_scope/tasks/main.yml new file mode 100644 index 0000000000..504a24a010 --- /dev/null +++ b/tests/integration/targets/keycloak_authz_authorization_scope/tasks/main.yml @@ -0,0 +1,234 @@ +--- +# 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 +- name: Remove keycloak client to avoid failures from previous failed runs + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + state: absent + +- name: Create keycloak client with authorization services enabled + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + state: present + enabled: true + public_client: false + service_accounts_enabled: true + authorization_services_enabled: true + +- name: Create an authorization scope (check mode) + community.general.keycloak_authz_authorization_scope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "file:delete" + display_name: "File delete" + icon_uri: "http://localhost/icon.png" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + check_mode: true + diff: true + register: result + +- name: Assert that authorization scope was not created in check mode + assert: + that: + - result is changed + - result.end_state == {} + - result.msg == 'Authorization scope would be created' + - result.diff.before == {} + - result.diff.after.name == 'file:delete' + - result.diff.after.displayName == 'File delete' + - result.diff.after.iconUri == 'http://localhost/icon.png' + +- name: Create authorization scope + community.general.keycloak_authz_authorization_scope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "file:delete" + display_name: "File delete" + icon_uri: "http://localhost/icon.png" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that authorization scope was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "file:delete" + - result.end_state.iconUri == "http://localhost/icon.png" + - result.end_state.displayName == "File delete" + +- name: Create authorization scope (test for idempotency) + community.general.keycloak_authz_authorization_scope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "file:delete" + display_name: "File delete" + icon_uri: "http://localhost/icon.png" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that nothing has changed + assert: + that: + - result is not changed + - result.end_state != {} + - result.end_state.name == "file:delete" + - result.end_state.iconUri == "http://localhost/icon.png" + - result.end_state.displayName == "File delete" + +- name: Authorization scope update (check mode) + community.general.keycloak_authz_authorization_scope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "file:delete" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + diff: true + check_mode: true + register: result + +- name: Assert that authorization scope was not updated in check mode + assert: + that: + - result is changed + - result.msg == 'Authorization scope would be updated' + - result.diff.before.displayName == 'File delete' + - result.diff.before.iconUri == 'http://localhost/icon.png' + - result.diff.after.displayName == '' + - result.diff.after.iconUri == '' + +- name: Authorization scope update (remove optional parameters) + community.general.keycloak_authz_authorization_scope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "file:delete" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that optional parameters have been removed + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "file:delete" + - result.end_state.iconUri == "" + - result.end_state.displayName == "" + +- name: Authorization scope update (test for idempotency) + community.general.keycloak_authz_authorization_scope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "file:delete" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that nothing has changed + assert: + that: + - result is not changed + - result.end_state != {} + - result.end_state.name == "file:delete" + - result.end_state.iconUri == "" + - result.end_state.displayName == "" + +- name: Authorization scope remove (check mode) + community.general.keycloak_authz_authorization_scope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: absent + name: "file:delete" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + diff: true + check_mode: true + register: result + +- name: Assert that authorization scope has not been removed in check mode + assert: + that: + - result is changed + - result.msg == 'Authorization scope would be removed' + - result.diff.before.name == 'file:delete' + - result.diff.after == {} + +- name: Authorization scope remove + community.general.keycloak_authz_authorization_scope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: absent + name: "file:delete" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that authorization scope has been removed + assert: + that: + - result is changed + - result.end_state == {} + +- name: Authorization scope remove (test for idempotency) + community.general.keycloak_authz_authorization_scope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: absent + name: "file:delete" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that nothing has changed + assert: + that: + - result is not changed + - result.end_state == {} + +- name: Remove keycloak client + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + state: absent diff --git a/tests/integration/targets/keycloak_authz_authorization_scope/vars/main.yml b/tests/integration/targets/keycloak_authz_authorization_scope/vars/main.yml new file mode 100644 index 0000000000..c1d5fc9839 --- /dev/null +++ b/tests/integration/targets/keycloak_authz_authorization_scope/vars/main.yml @@ -0,0 +1,11 @@ +--- +# 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 + +url: http://localhost:8080/auth +admin_realm: master +admin_user: admin +admin_password: password +realm: master +client_id: authz