diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 9e1c3f4d93c..69bcc42b4b6 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -116,6 +116,7 @@ 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_RESOURCE = "{url}/admin/realms/{realm}/clients/{client_id}/authz/resource-server/resource/{id}" 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}" @@ -3025,6 +3026,16 @@ def update_authz_permission(self, payload, permission_type, id, client_id, realm 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 create_authz_resource(self, payload, client_id, realm): + """Create an authorization resource for a Keycloak client""" + url = URL_AUTHZ_RESOURCES.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 resource %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) @@ -3037,6 +3048,26 @@ def get_authz_resource_by_name(self, name, client_id, realm): except Exception: return False + def remove_authz_permission(self, id, client_id, realm): + """Remove an authorization resource for a Keycloak client""" + url = URL_AUTHZ_RESOURCE.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_resource(self, payload, id, client_id, realm): + """Update a resource for a Keycloak client""" + url = URL_AUTHZ_RESOURCE.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 permission %s for client %s in realm %s: %s' % (payload['name'], client_id, realm, str(e))) + 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) diff --git a/plugins/modules/keycloak_authz_resource.py b/plugins/modules/keycloak_authz_resource.py new file mode 100644 index 00000000000..23a28741e28 --- /dev/null +++ b/plugins/modules/keycloak_authz_resource.py @@ -0,0 +1,274 @@ +#!/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_resource + +version_added: 8.6.0 + +short_description: Allows administration of Keycloak client authorization resources via Keycloak API + +description: + - This module allows the administration of Keycloak client authorization resources via the Keycloak REST + API. Authorization resources 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 resource. + - On V(present), the authorization resource will be created (or updated if it exists already). + - On V(absent), the authorization resource will be removed if it exists. + choices: ['present', 'absent'] + default: 'present' + type: str + name: + description: + - Name of the authorization resource to create. + type: str + required: true + displayName: + description: + - The displayName of the authorization resource. + type: str + 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 + icon_uri: + description: + - A URI pointing to an icon. + type: str + required: false + uris: + description: + - Set of URIs which are protected by resource. + type: list + required: false + type: + description: + - OpenID Connect allows Clients to verify the identity of the End-User based on the + authentication performed by an Authorization Server. + - SAML enables web-based authentication and authorization scenarios including cross-domain s + ingle sign-on (SSO) and uses security tokens containing assertions to pass information. + type: str + required: false + attributes: + description: + - The attributes associated wth the resource. + type: dict + required: false + +extends_documentation_fragment: + - community.general.keycloak + - community.general.attributes + +author: + - Maximilian Pohle (@mattock) +''' + +EXAMPLES = ''' +- name: Manage scope-based Keycloak authorization resource + community.general.keycloak_authz_resource: + name: test-resource + state: present + displayName: test-resource + 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 resource after module execution. + returned: on success + type: complex + +diff: + description: Representation of the authorization resource changes. + returned: on success + type: complex + +''' + +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), + attributes=dict(type='raw', required=False, default={}), + client_id=dict(type='str', required=True), + displayName=dict(type='str', required=False), + icon_uri=dict(type='str', required=False), + ownerManagedAccess=dict(type='bool', required=False, default=False), + realm=dict(type='str', required=False), + type=dict(type='str', required=False), + uris=dict(type='list', elements='str', default=[], required=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']])) + + attributes = module.params.get('attributes') + client_id = module.params.get('client_id') + displayName = module.params.get('displayName') + icon_uri = module.params.get('icon_uri') + name = module.params.get('client_id') + ownerManagedAccess = module.params.get('ownerManagedAccess') + realm = module.params.get('realm') + state = module.params.get('state') + type = module.params.get('type') + uris = module.params.get('uris') + + 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. + resource = kc.get_authz_resource_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['attributes'] = attributes + + payload['name'] = name + if type: + payload['type'] = type + if displayName: + payload['displayName'] = displayName + if icon_uri: + payload['icon_uri'] = icon_uri + payload['ownerManagedAccess'] = ownerManagedAccess + payload['uris'] = uris + + payload['owner'] = { + 'id': cid, + 'name': client_id + } + + # Add "id" to payload for update operations + if resource: + payload['_id'] = resource['_id'] + + if resource and state == 'present': + result['msg'] = 'Update present' + result['diff'] = { + 'after': payload, + 'before': resource + } + + if result['diff']['before'] != result['diff']['after']: + if not module.check_mode: + kc.update_authz_resource(payload=payload, id=resource['_id'], client_id=cid, realm=realm) + result['msg'] = 'Would update resource' + result['changed'] = True + result['end_state'] = payload + + elif not resource and state == 'present': + result['diff'] = { + 'after': payload, + 'before': {} + } + + if not module.check_mode: + kc.create_authz_resource(payload=payload, client_id=cid, realm=realm) + result['msg'] = 'Resource created' + + result['changed'] = True + result['end_state'] = payload + elif resource and state == 'absent': + result['diff'] = { + 'after': {}, + 'before': resource + } + + if module.check_mode: + result['msg'] = 'Would remove permission' + else: + kc.remove_authz_permission(id=resource['_id'], client_id=cid, realm=realm) + result['msg'] = 'Resource removed' + + result['changed'] = True + + elif not resource and state == 'absent': + result['changed'] = False + else: + module.fail_json(msg='Unable to determine what to do with resource %s of client %s in realm %s' % ( + name, client_id, realm)) + + module.exit_json(**result) + + +if __name__ == '__main__': + main()