diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index b06bc0aaa3..e2d9d2f849 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -693,6 +693,10 @@ files: maintainers: Skrekulko $modules/keycloak_authz_authorization_scope.py: maintainers: mattock + $modules/keycloak_authz_permission.py: + maintainers: mattock + $modules/keycloak_authz_permission_info.py: + maintainers: mattock $modules/keycloak_client_rolemapping.py: maintainers: Gaetan2907 $modules/keycloak_clientscope.py: diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 5616f787dd..f7120c7e24 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -105,6 +105,17 @@ 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" + def keycloak_argument_spec(): """ @@ -2892,3 +2903,69 @@ class KeycloakAPI(object): group_dict['name'] = group list_of_groups.append(group_dict) return list_of_groups + + 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.module.fail_json(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.module.fail_json(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.module.fail_json(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 diff --git a/plugins/modules/keycloak_authz_permission.py b/plugins/modules/keycloak_authz_permission.py new file mode 100644 index 0000000000..4da837e526 --- /dev/null +++ b/plugins/modules/keycloak_authz_permission.py @@ -0,0 +1,433 @@ +#!/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_permission + +version_added: 7.2.0 + +short_description: Allows administration of Keycloak client authorization permissions via Keycloak API + +description: + - This module allows the administration of Keycloak client authorization permissions via the Keycloak REST + API. Authorization permissions are only available if a client has Authorization enabled. + + - There are some peculiarities in JSON paths and payloads for authorization permissions. In particular + POST and PUT operations are targeted at permission endpoints, whereas GET requests go to policies + endpoint. To make matters more interesting the JSON responses from GET requests return data in a + different format than what is expected for POST and PUT. The end result is that it is not possible to + detect changes to things like policies, scopes or resources - at least not without a large number of + additional API calls. Therefore this module always updates authorization permissions instead of + attempting to determine if changes are truly needed. + + - This module requires access to the REST API 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: none + +options: + state: + description: + - State of the authorization permission. + - On C(present), the authorization permission will be created (or updated if it exists already). + - On C(absent), the authorization permission will be removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + name: + description: + - Name of the authorization permission to create. + type: str + required: true + description: + description: + - The description of the authorization permission. + type: str + required: false + permission_type: + description: + - The type of authorization permission. + - On C(scope) create a scope-based permission. + - On C(resource) create a resource-based permission. + type: str + required: true + choices: + - resource + - scope + decision_strategy: + description: + - The decision strategy to use with this permission. + type: str + default: UNANIMOUS + required: false + choices: + - UNANIMOUS + - AFFIRMATIVE + - CONSENSUS + resources: + description: + - Resource names to attach to this permission. + - Scope-based permissions can only include one resource. + - Resource-based permissions can include multiple resources. + type: list + elements: str + default: [] + required: false + scopes: + description: + - Scope names to attach to this permission. + - Resource-based permissions cannot have scopes attached to them. + type: list + elements: str + default: [] + required: false + policies: + description: + - Policy names to attach to this permission. + type: list + elements: str + default: [] + required: false + client_id: + description: + - The clientId of the keycloak client that should have the authorization scope. + - This is usually a human-readable name of the Keycloak client. + type: str + required: true + realm: + description: + - The name of the Keycloak realm the Keycloak client is in. + type: str + required: true + +extends_documentation_fragment: + - community.general.keycloak + - community.general.attributes + +author: + - Samuli Seppänen (@mattock) +''' + +EXAMPLES = ''' +- name: Manage scope-based Keycloak authorization permission + community.general.keycloak_authz_permission: + name: ScopePermission + state: present + description: Scope permission + permission_type: scope + scopes: + - file:delete + policies: + - Default Policy + client_id: myclient + realm: myrealm + auth_keycloak_url: http://localhost:8080/auth + auth_username: keycloak + auth_password: keycloak + auth_realm: master + +- name: Manage resource-based Keycloak authorization permission + community.general.keycloak_authz_permission: + name: ResourcePermission + state: present + description: Resource permission + permission_type: resource + resources: + - Default Resource + policies: + - Default Policy + client_id: myclient + realm: myrealm + auth_keycloak_url: http://localhost:8080/auth + 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 permission after module execution. + returned: on success + type: complex + contains: + id: + description: ID of the authorization permission. + type: str + returned: when I(state=present) + sample: 9da05cd2-b273-4354-bbd8-0c133918a454 + name: + description: Name of the authorization permission. + type: str + returned: when I(state=present) + sample: ResourcePermission + description: + description: Description of the authorization permission. + type: str + returned: when I(state=present) + sample: Resource Permission + type: + description: Type of the authorization permission. + type: str + returned: when I(state=present) + sample: resource + decisionStrategy: + description: The decision strategy to use. + type: str + returned: when I(state=present) + sample: UNANIMOUS + logic: + description: The logic used for the permission (part of the payload, but has a fixed value). + type: str + returned: when I(state=present) + sample: POSITIVE + resources: + description: IDs of resources attached to this permission. + type: list + returned: when I(state=present) + sample: + - 49e052ff-100d-4b79-a9dd-52669ed3c11d + scopes: + description: IDs of scopes attached to this permission. + type: list + returned: when I(state=present) + sample: + - 9da05cd2-b273-4354-bbd8-0c133918a454 + policies: + description: IDs of policies attached to this permission. + type: list + returned: when I(state=present) + sample: + - 9da05cd2-b273-4354-bbd8-0c133918a454 +''' + +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), + description=dict(type='str', required=False), + permission_type=dict(type='str', choices=['scope', 'resource'], required=True), + decision_strategy=dict(type='str', default='UNANIMOUS', + choices=['UNANIMOUS', 'AFFIRMATIVE', 'CONSENSUS']), + resources=dict(type='list', elements='str', default=[], required=False), + scopes=dict(type='list', elements='str', default=[], required=False), + policies=dict(type='list', elements='str', default=[], 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']])) + + # Convenience variables + state = module.params.get('state') + name = module.params.get('name') + description = module.params.get('description') + permission_type = module.params.get('permission_type') + decision_strategy = module.params.get('decision_strategy') + realm = module.params.get('realm') + client_id = module.params.get('client_id') + realm = module.params.get('realm') + resources = module.params.get('resources') + scopes = module.params.get('scopes') + policies = module.params.get('policies') + + if permission_type == 'scope' and state == 'present': + if scopes == []: + module.fail_json(msg='Scopes need to defined when permission type is set to scope!') + if len(resources) > 1: + module.fail_json(msg='Only one resource can be defined for a scope permission!') + + if permission_type == 'resource' and state == 'present': + if resources == []: + module.fail_json(msg='A resource need to defined when permission type is set to resource!') + if scopes != []: + module.fail_json(msg='Scopes cannot be defined when permission type is set to resource!') + + result = dict(changed=False, msg='', end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Get id of the client based on client_id + cid = kc.get_client_id(client_id, realm=realm) + if not cid: + module.fail_json(msg='Invalid client %s for realm %s' % + (client_id, realm)) + + # Get current state of the permission using its name as the search + # filter. This returns False if it is not found. + permission = kc.get_authz_permission_by_name( + name=name, client_id=cid, realm=realm) + + # Generate a JSON payload for Keycloak Admin API. This is needed for + # "create" and "update" operations. + payload = {} + payload['name'] = name + payload['description'] = description + payload['type'] = permission_type + payload['decisionStrategy'] = decision_strategy + payload['logic'] = 'POSITIVE' + payload['scopes'] = [] + payload['resources'] = [] + payload['policies'] = [] + + if permission_type == 'scope': + # Add the resource id, if any, to the payload. While the data type is a + # list, it is only possible to have one entry in it based on what Keycloak + # Admin Console does. + r = False + resource_scopes = [] + + if resources: + r = kc.get_authz_resource_by_name(resources[0], cid, realm) + if not r: + module.fail_json(msg='Unable to find authorization resource with name %s for client %s in realm %s' % (resources[0], cid, realm)) + else: + payload['resources'] = r['_id'] + + for rs in r['scopes']: + resource_scopes.append(rs['id']) + + # Generate a list of scope ids based on scope names. Fail if the + # defined resource does not include all those scopes. + for scope in scopes: + s = kc.get_authz_authorization_scope_by_name(scope, cid, realm) + if r and not s['id'] in resource_scopes: + module.fail_json(msg='Resource %s does not include scope %s for client %s in realm %s' % (resources[0], scope, client_id, realm)) + else: + payload['scopes'].append(s['id']) + + elif permission_type == 'resource': + if resources: + for resource in resources: + r = kc.get_authz_resource_by_name(resource, cid, realm) + if not r: + module.fail_json(msg='Unable to find authorization resource with name %s for client %s in realm %s' % (resource, cid, realm)) + else: + payload['resources'].append(r['_id']) + + # Add policy ids, if any, to the payload. + if policies: + for policy in policies: + p = kc.get_authz_policy_by_name(policy, cid, realm) + + if p: + payload['policies'].append(p['id']) + else: + module.fail_json(msg='Unable to find authorization policy with name %s for client %s in realm %s' % (policy, client_id, realm)) + + # Add "id" to payload for update operations + if permission: + payload['id'] = permission['id'] + + # Handle the special case where the user attempts to change an already + # existing permission's type - something that can't be done without a + # full delete -> (re)create cycle. + if permission['type'] != payload['type']: + module.fail_json(msg='Modifying the type of permission (scope/resource) is not supported: \ + permission %s of client %s in realm %s unchanged' % (permission['id'], cid, realm)) + + # Updating an authorization permission is tricky for several reasons. + # Firstly, the current permission is retrieved using a _policy_ endpoint, + # not from a permission endpoint. Also, the data that is returned is in a + # different format than what is expected by the payload. So, comparing the + # current state attribute by attribute to the payload is not possible. For + # example the data contains a JSON object "config" which may contain the + # authorization type, but which is no required in the payload. Moreover, + # information about resources, scopes and policies is _not_ present in the + # data. So, there is no way to determine if any of those fields have + # changed. Therefore the best options we have are + # + # a) Always apply the payload without checking the current state + # b) Refuse to make any changes to any settings (only support create and delete) + # + # The approach taken here is a). + # + if permission and state == 'present': + if module.check_mode: + result['msg'] = 'Notice: unable to check current resources, scopes and policies for permission. \ + Would apply desired state without checking the current state.' + else: + kc.update_authz_permission(payload=payload, permission_type=permission_type, id=permission['id'], client_id=cid, realm=realm) + result['msg'] = 'Notice: unable to check current resources, scopes and policies for permission. \ + Applying desired state without checking the current state.' + + # Assume that something changed, although we don't know if that is the case. + result['changed'] = True + result['end_state'] = payload + elif not permission and state == 'present': + if module.check_mode: + result['msg'] = 'Would create permission' + else: + kc.create_authz_permission(payload=payload, permission_type=permission_type, client_id=cid, realm=realm) + result['msg'] = 'Permission created' + + result['changed'] = True + result['end_state'] = payload + elif permission and state == 'absent': + if module.check_mode: + result['msg'] = 'Would remove permission' + else: + kc.remove_authz_permission(id=permission['id'], client_id=cid, realm=realm) + result['msg'] = 'Permission removed' + + result['changed'] = True + + elif not permission and state == 'absent': + result['changed'] = False + else: + module.fail_json(msg='Unable to determine what to do with permission %s of client %s in realm %s' % ( + name, client_id, realm)) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_authz_permission_info.py b/plugins/modules/keycloak_authz_permission_info.py new file mode 100644 index 0000000000..e41d6d4e84 --- /dev/null +++ b/plugins/modules/keycloak_authz_permission_info.py @@ -0,0 +1,180 @@ +#!/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_permission_info + +version_added: 7.2.0 + +short_description: Query Keycloak client authorization permissions information + +description: + - This module allows querying information about Keycloak client authorization permissions from the + resources endpoint via the Keycloak REST API. Authorization permissions 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/) + +options: + name: + description: + - Name of the authorization permission to create. + type: str + required: true + client_id: + description: + - The clientId of the keycloak client that should have the authorization scope. + - This is usually a human-readable name of the Keycloak client. + type: str + required: true + realm: + description: + - The name of the Keycloak realm the Keycloak client is in. + type: str + required: true + +extends_documentation_fragment: + - community.general.keycloak + - community.general.attributes + - community.general.attributes.info_module + +author: + - Samuli Seppänen (@mattock) +''' + +EXAMPLES = ''' +- name: Query Keycloak authorization permission + community.general.keycloak_authz_permission_info: + name: ScopePermission + 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 + +queried_state: + description: State of the resource (a policy) as seen by Keycloak. + returned: on success + type: complex + contains: + id: + description: ID of the authorization permission. + type: str + returned: when I(state=present) + sample: 9da05cd2-b273-4354-bbd8-0c133918a454 + name: + description: Name of the authorization permission. + type: str + returned: when I(state=present) + sample: ResourcePermission + description: + description: Description of the authorization permission. + type: str + returned: when I(state=present) + sample: Resource Permission + type: + description: Type of the authorization permission. + type: str + returned: when I(state=present) + sample: resource + decisionStrategy: + description: The decision strategy. + type: str + returned: when I(state=present) + sample: UNANIMOUS + logic: + description: The logic used for the permission (part of the payload, but has a fixed value). + type: str + returned: when I(state=present) + sample: POSITIVE + config: + description: Configuration of the permission (empty in all observed cases). + type: dict + returned: when I(state=present) + sample: {} +''' + +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( + name=dict(type='str', required=True), + client_id=dict(type='str', required=True), + realm=dict(type='str', required=True) + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + # Convenience variables + name = module.params.get('name') + client_id = module.params.get('client_id') + realm = module.params.get('realm') + + result = dict(changed=False, msg='', queried_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + # Get id of the client based on client_id + cid = kc.get_client_id(client_id, realm=realm) + if not cid: + module.fail_json(msg='Invalid client %s for realm %s' % + (client_id, realm)) + + # Get current state of the permission using its name as the search + # filter. This returns False if it is not found. + permission = kc.get_authz_permission_by_name( + name=name, client_id=cid, realm=realm) + + result['queried_state'] = permission + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/keycloak_authz_permission/aliases b/tests/integration/targets/keycloak_authz_permission/aliases new file mode 100644 index 0000000000..e1f8d6b4b1 --- /dev/null +++ b/tests/integration/targets/keycloak_authz_permission/aliases @@ -0,0 +1,6 @@ +# 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 +keycloak_authz_permission_info diff --git a/tests/integration/targets/keycloak_authz_permission/readme.adoc b/tests/integration/targets/keycloak_authz_permission/readme.adoc new file mode 100644 index 0000000000..1941e54efd --- /dev/null +++ b/tests/integration/targets/keycloak_authz_permission/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_permission/tasks/main.yml b/tests/integration/targets/keycloak_authz_permission/tasks/main.yml new file mode 100644 index 0000000000..16cb6806f2 --- /dev/null +++ b/tests/integration/targets/keycloak_authz_permission/tasks/main.yml @@ -0,0 +1,567 @@ +--- +# 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 file: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:create" + display_name: "File create" + icon_uri: "http://localhost/icon.png" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Create file:delete 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: Create permission without type (test for failure) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ScopePermission" + description: "Scope permission" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + failed_when: result.msg.find('missing required arguments') == -1 + +- name: Create scope permission without scopes (test for failure) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ScopePermission" + description: "Scope permission" + permission_type: scope + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + failed_when: result.msg.find('Scopes need to defined when permission type is set to scope!') == -1 + +- name: Create scope permission with multiple resources (test for failure) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ScopePermission" + description: "Scope permission" + resources: + - "Default Resource" + - "Other Resource" + permission_type: scope + scopes: + - "file:delete" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + failed_when: result.msg.find('Only one resource can be defined for a scope permission!') == -1 + +- name: Create scope permission with invalid policy name (test for failure) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ScopePermission" + description: "Scope permission" + permission_type: scope + scopes: + - "file:delete" + policies: + - "Missing Policy" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + failed_when: result.msg.find('Unable to find authorization policy with name') == -1 + +- name: Create scope permission + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ScopePermission" + description: "Scope permission" + permission_type: scope + scopes: + - "file:delete" + policies: + - "Default Policy" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that scope permission was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.name == "ScopePermission" + - result.end_state.description == "Scope permission" + - result.end_state.type == "scope" + - result.end_state.resources == [] + - result.end_state.policies|length == 1 + - result.end_state.scopes|length == 1 + +- name: Query state + community.general.keycloak_authz_permission_info: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "ScopePermission" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that queried state matches desired end state + assert: + that: + - result.queried_state.name == "ScopePermission" + - result.queried_state.description == "Scope permission" + +- name: Create scope permission (test for idempotency) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ScopePermission" + description: "Scope permission" + permission_type: scope + scopes: + - "file:delete" + policies: + - "Default Policy" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that nothing changed + assert: + that: + - result.end_state != {} + - result.end_state.name == "ScopePermission" + - result.end_state.description == "Scope permission" + - result.end_state.type == "scope" + - result.end_state.resources == [] + - result.end_state.policies|length == 1 + - result.end_state.scopes|length == 1 + +- name: Query state + community.general.keycloak_authz_permission_info: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "ScopePermission" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that queried state matches desired end state + assert: + that: + - result.queried_state.name == "ScopePermission" + - result.queried_state.description == "Scope permission" + +- name: Update scope permission + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ScopePermission" + description: "Scope permission changed" + permission_type: scope + decision_strategy: 'AFFIRMATIVE' + scopes: + - "file:create" + - "file:delete" + policies: [] + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that scope permission was updated correctly + assert: + that: + - result.changed == True + - result.end_state != {} + - result.end_state.scopes|length == 2 + - result.end_state.policies == [] + - result.end_state.resources == [] + - result.end_state.name == "ScopePermission" + - result.end_state.description == "Scope permission changed" + +- name: Query state + community.general.keycloak_authz_permission_info: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "ScopePermission" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that queried state matches desired end state + assert: + that: + - result.queried_state.name == "ScopePermission" + - result.queried_state.description == "Scope permission changed" + +- name: Update scope permission (test for idempotency) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ScopePermission" + description: "Scope permission changed" + permission_type: scope + decision_strategy: 'AFFIRMATIVE' + scopes: + - "file:create" + - "file:delete" + policies: [] + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that nothing changed + assert: + that: + - result.changed == True + - result.end_state != {} + - result.end_state.scopes|length == 2 + - result.end_state.policies == [] + - result.end_state.resources == [] + - result.end_state.name == "ScopePermission" + - result.end_state.description == "Scope permission changed" + +- name: Query state + community.general.keycloak_authz_permission_info: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "ScopePermission" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that queried state matches desired end state + assert: + that: + - result.queried_state.name == "ScopePermission" + - result.queried_state.description == "Scope permission changed" + +- name: Remove scope permission + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: absent + name: "ScopePermission" + permission_type: scope + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that scope permission was removed + assert: + that: + - result is changed + - result.end_state == {} + +- name: Remove scope permission (test for idempotency) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: absent + name: "ScopePermission" + permission_type: scope + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that nothing has changed + assert: + that: + - result is not changed + - result.end_state == {} + +- name: Create resource permission without resources (test for failure) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ResourcePermission" + description: "Resource permission" + permission_type: resource + policies: + - "Default Policy" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + failed_when: result.msg.find('A resource need to defined when permission type is set to resource!') == -1 + +- name: Create resource permission with scopes (test for failure) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ResourcePermission" + description: "Resource permission" + permission_type: resource + resources: + - "Default Resource" + policies: + - "Default Policy" + scopes: + - "file:delete" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + failed_when: result.msg.find('Scopes cannot be defined when permission type is set to resource!') == -1 + +- name: Create resource permission + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ResourcePermission" + description: "Resource permission" + resources: + - "Default Resource" + permission_type: resource + policies: + - "Default Policy" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that resource permission was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.policies|length == 1 + - result.end_state.resources|length == 1 + - result.end_state.name == "ResourcePermission" + - result.end_state.description == "Resource permission" + +- name: Query state + community.general.keycloak_authz_permission_info: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "ResourcePermission" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that queried state matches desired end state + assert: + that: + - result.queried_state.name == "ResourcePermission" + - result.queried_state.description == "Resource permission" + +- name: Create resource permission (test for idempotency) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ResourcePermission" + description: "Resource permission" + resources: + - "Default Resource" + permission_type: resource + policies: + - "Default Policy" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that nothing has changed + assert: + that: + - result.end_state != {} + - result.end_state.policies|length == 1 + - result.end_state.resources|length == 1 + - result.end_state.name == "ResourcePermission" + - result.end_state.description == "Resource permission" + +- name: Query state + community.general.keycloak_authz_permission_info: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "ResourcePermission" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that queried state matches desired end state + assert: + that: + - result.queried_state.name == "ResourcePermission" + - result.queried_state.description == "Resource permission" + +- name: Update resource permission + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + name: "ResourcePermission" + description: "Resource permission changed" + resources: + - "Default Resource" + permission_type: resource + policies: [] + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that resource permission was updated correctly + assert: + that: + - result.changed == True + - result.end_state != {} + - result.end_state.policies == [] + - result.end_state.resources|length == 1 + - result.end_state.name == "ResourcePermission" + - result.end_state.description == "Resource permission changed" + +- name: Query state + community.general.keycloak_authz_permission_info: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "ResourcePermission" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that queried state matches desired end state + assert: + that: + - result.queried_state.name == "ResourcePermission" + - result.queried_state.description == "Resource permission changed" + +- name: Remove resource permission + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: absent + name: "ResourcePermission" + permission_type: resource + client_id: "{{ client_id }}" + realm: "{{ realm }}" + register: result + +- name: Assert that resource permission was removed + assert: + that: + - result is changed + - result.end_state == {} + +- name: Remove resource permission (test for idempotency) + community.general.keycloak_authz_permission: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: absent + name: "ResourcePermission" + permission_type: resource + 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_permission/vars/main.yml b/tests/integration/targets/keycloak_authz_permission/vars/main.yml new file mode 100644 index 0000000000..c1d5fc9839 --- /dev/null +++ b/tests/integration/targets/keycloak_authz_permission/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