summaryrefslogtreecommitdiffstats
path: root/ansible_collections/community/general/plugins/modules/keycloak_authz_permission.py
blob: ef81fb8c31728f675296113dfa8cab6053e4b55b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright (c) 2017, Eike Frost <ei@kefro.st>
# Copyright (c) 2021, Christophe Gilles <christophe.gilles54@gmail.com>
# 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 V(present), the authorization permission will be created (or updated if it exists already).
            - On V(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 V(scope) create a scope-based permission.
            - On V(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 O(state=present)
            sample: 9da05cd2-b273-4354-bbd8-0c133918a454
        name:
            description: Name of the authorization permission.
            type: str
            returned: when O(state=present)
            sample: ResourcePermission
        description:
            description: Description of the authorization permission.
            type: str
            returned: when O(state=present)
            sample: Resource Permission
        type:
            description: Type of the authorization permission.
            type: str
            returned: when O(state=present)
            sample: resource
        decisionStrategy:
            description: The decision strategy to use.
            type: str
            returned: when O(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 O(state=present)
            sample: POSITIVE
        resources:
            description: IDs of resources attached to this permission.
            type: list
            returned: when O(state=present)
            sample:
                - 49e052ff-100d-4b79-a9dd-52669ed3c11d
        scopes:
            description: IDs of scopes attached to this permission.
            type: list
            returned: when O(state=present)
            sample:
                - 9da05cd2-b273-4354-bbd8-0c133918a454
        policies:
            description: IDs of policies attached to this permission.
            type: list
            returned: when O(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'].append(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()