1#!/usr/bin/python
2# -*- coding: utf-8 -*-
3
4# Copyright (c) 2019, Adam Goossens <adam.goossens@gmail.com>
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7from __future__ import absolute_import, division, print_function
8__metaclass__ = type
9
10ANSIBLE_METADATA = {
11    'metadata_version': '1.1',
12    'status': ['preview'],
13    'supported_by': 'community'
14}
15
16DOCUMENTATION = '''
17---
18module: keycloak_group
19
20short_description: Allows administration of Keycloak groups via Keycloak API
21
22description:
23    - This module allows you to add, remove or modify Keycloak groups via the Keycloak REST API.
24      It requires access to the REST API via OpenID Connect; the user connecting and the client being
25      used must have the requisite access rights. In a default Keycloak installation, admin-cli
26      and an admin user would work, as would a separate client definition with the scope tailored
27      to your needs and a user having the expected roles.
28
29    - The names of module options are snake_cased versions of the camelCase ones found in the
30      Keycloak API and its documentation at U(http://www.keycloak.org/docs-api/3.3/rest-api/).
31
32    - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will
33      be returned that way by this module. You may pass single values for attributes when calling the module,
34      and this will be translated into a list suitable for the API.
35
36    - When updating a group, where possible provide the group ID to the module. This removes a lookup
37      to the API to translate the name into the group ID.
38
39version_added: "2.8"
40
41options:
42    state:
43        description:
44            - State of the group.
45            - On C(present), the group will be created if it does not yet exist, or updated with the parameters you provide.
46            - On C(absent), the group will be removed if it exists.
47        required: true
48        default: 'present'
49        type: str
50        choices:
51            - present
52            - absent
53
54    name:
55        type: str
56        description:
57            - Name of the group.
58            - This parameter is required only when creating or updating the group.
59
60    realm:
61        type: str
62        description:
63            - They Keycloak realm under which this group resides.
64        default: 'master'
65
66    id:
67        type: str
68        description:
69            - The unique identifier for this group.
70            - This parameter is not required for updating or deleting a group but
71              providing it will reduce the number of API calls required.
72
73    attributes:
74        type: dict
75        description:
76            - A dict of key/value pairs to set as custom attributes for the group.
77            - Values may be single values (e.g. a string) or a list of strings.
78
79notes:
80    - Presently, the I(realmRoles), I(clientRoles) and I(access) attributes returned by the Keycloak API
81      are read-only for groups. This limitation will be removed in a later version of this module.
82
83extends_documentation_fragment:
84    - keycloak
85
86author:
87    - Adam Goossens (@adamgoossens)
88'''
89
90EXAMPLES = '''
91- name: Create a Keycloak group
92  keycloak_group:
93    name: my-new-kc-group
94    realm: MyCustomRealm
95    state: present
96    auth_client_id: admin-cli
97    auth_keycloak_url: https://auth.example.com/auth
98    auth_realm: master
99    auth_username: USERNAME
100    auth_password: PASSWORD
101  delegate_to: localhost
102
103- name: Delete a keycloak group
104  keycloak_group:
105    id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd'
106    state: absent
107    realm: MyCustomRealm
108    auth_client_id: admin-cli
109    auth_keycloak_url: https://auth.example.com/auth
110    auth_realm: master
111    auth_username: USERNAME
112    auth_password: PASSWORD
113  delegate_to: localhost
114
115- name: Delete a Keycloak group based on name
116  keycloak_group:
117    name: my-group-for-deletion
118    state: absent
119    auth_client_id: admin-cli
120    auth_keycloak_url: https://auth.example.com/auth
121    auth_realm: master
122    auth_username: USERNAME
123    auth_password: PASSWORD
124  delegate_to: localhost
125
126- name: Update the name of a Keycloak group
127  keycloak_group:
128    id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd'
129    name: an-updated-kc-group-name
130    state: present
131    auth_client_id: admin-cli
132    auth_keycloak_url: https://auth.example.com/auth
133    auth_realm: master
134    auth_username: USERNAME
135    auth_password: PASSWORD
136  delegate_to: localhost
137
138- name: Create a keycloak group with some custom attributes
139  keycloak_group:
140    auth_client_id: admin-cli
141    auth_keycloak_url: https://auth.example.com/auth
142    auth_realm: master
143    auth_username: USERNAME
144    auth_password: PASSWORD
145    name: my-new_group
146    attributes:
147        attrib1: value1
148        attrib2: value2
149        attrib3:
150            - with
151            - numerous
152            - individual
153            - list
154            - items
155  delegate_to: localhost
156'''
157
158RETURN = '''
159group:
160  description: Group representation of the group after module execution (sample is truncated).
161  returned: always
162  type: complex
163  contains:
164    id:
165      description: GUID that identifies the group
166      type: str
167      returned: always
168      sample: 23f38145-3195-462c-97e7-97041ccea73e
169    name:
170      description: Name of the group
171      type: str
172      returned: always
173      sample: grp-test-123
174    attributes:
175      description: Attributes applied to this group
176      type: dict
177      returned: always
178      sample:
179        attr1: ["val1", "val2", "val3"]
180    path:
181      description: URI path to the group
182      type: str
183      returned: always
184      sample: /grp-test-123
185    realmRoles:
186      description: An array of the realm-level roles granted to this group
187      type: list
188      returned: always
189      sample: []
190    subGroups:
191      description: A list of groups that are children of this group. These groups will have the same parameters as
192                   documented here.
193      type: list
194      returned: always
195    clientRoles:
196      description: A list of client-level roles granted to this group
197      type: list
198      returned: always
199      sample: []
200    access:
201      description: A dict describing the accesses you have to this group based on the credentials used.
202      type: dict
203      returned: always
204      sample:
205        manage: true
206        manageMembership: true
207        view: true
208'''
209
210from ansible.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \
211    keycloak_argument_spec, get_token, KeycloakError
212from ansible.module_utils.basic import AnsibleModule
213
214
215def main():
216    """
217    Module execution
218
219    :return:
220    """
221    argument_spec = keycloak_argument_spec()
222    meta_args = dict(
223        state=dict(default='present', choices=['present', 'absent']),
224        realm=dict(default='master'),
225        id=dict(type='str'),
226        name=dict(type='str'),
227        attributes=dict(type='dict')
228    )
229
230    argument_spec.update(meta_args)
231
232    module = AnsibleModule(argument_spec=argument_spec,
233                           supports_check_mode=True,
234                           required_one_of=([['id', 'name']]))
235
236    result = dict(changed=False, msg='', diff={}, group='')
237
238    # Obtain access token, initialize API
239    try:
240        connection_header = get_token(
241            base_url=module.params.get('auth_keycloak_url'),
242            validate_certs=module.params.get('validate_certs'),
243            auth_realm=module.params.get('auth_realm'),
244            client_id=module.params.get('auth_client_id'),
245            auth_username=module.params.get('auth_username'),
246            auth_password=module.params.get('auth_password'),
247            client_secret=module.params.get('auth_client_secret'),
248        )
249    except KeycloakError as e:
250        module.fail_json(msg=str(e))
251    kc = KeycloakAPI(module, connection_header)
252
253    realm = module.params.get('realm')
254    state = module.params.get('state')
255    gid = module.params.get('id')
256    name = module.params.get('name')
257    attributes = module.params.get('attributes')
258
259    before_group = None         # current state of the group, for merging.
260
261    # does the group already exist?
262    if gid is None:
263        before_group = kc.get_group_by_name(name, realm=realm)
264    else:
265        before_group = kc.get_group_by_groupid(gid, realm=realm)
266
267    before_group = {} if before_group is None else before_group
268
269    # attributes in Keycloak have their values returned as lists
270    # via the API. attributes is a dict, so we'll transparently convert
271    # the values to lists.
272    if attributes is not None:
273        for key, val in module.params['attributes'].items():
274            module.params['attributes'][key] = [val] if not isinstance(val, list) else val
275
276    group_params = [x for x in module.params
277                    if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and
278                    module.params.get(x) is not None]
279
280    # build a changeset
281    changeset = {}
282    for param in group_params:
283        new_param_value = module.params.get(param)
284        old_value = before_group[param] if param in before_group else None
285        if new_param_value != old_value:
286            changeset[camel(param)] = new_param_value
287
288    # prepare the new group
289    updated_group = before_group.copy()
290    updated_group.update(changeset)
291
292    # if before_group is none, the group doesn't exist.
293    if before_group == {}:
294        if state == 'absent':
295            # nothing to do.
296            if module._diff:
297                result['diff'] = dict(before='', after='')
298            result['msg'] = 'Group does not exist; doing nothing.'
299            result['group'] = dict()
300            module.exit_json(**result)
301
302        # for 'present', create a new group.
303        result['changed'] = True
304        if name is None:
305            module.fail_json(msg='name must be specified when creating a new group')
306
307        if module._diff:
308            result['diff'] = dict(before='', after=updated_group)
309
310        if module.check_mode:
311            module.exit_json(**result)
312
313        # do it for real!
314        kc.create_group(updated_group, realm=realm)
315        after_group = kc.get_group_by_name(name, realm)
316
317        result['group'] = after_group
318        result['msg'] = 'Group {name} has been created with ID {id}'.format(name=after_group['name'],
319                                                                            id=after_group['id'])
320
321    else:
322        if state == 'present':
323            # no changes
324            if updated_group == before_group:
325                result['changed'] = False
326                result['group'] = updated_group
327                result['msg'] = "No changes required to group {name}.".format(name=before_group['name'])
328                module.exit_json(**result)
329
330            # update the existing group
331            result['changed'] = True
332
333            if module._diff:
334                result['diff'] = dict(before=before_group, after=updated_group)
335
336            if module.check_mode:
337                module.exit_json(**result)
338
339            # do the update
340            kc.update_group(updated_group, realm=realm)
341
342            after_group = kc.get_group_by_groupid(updated_group['id'], realm=realm)
343
344            result['group'] = after_group
345            result['msg'] = "Group {id} has been updated".format(id=after_group['id'])
346
347            module.exit_json(**result)
348
349        elif state == 'absent':
350            result['group'] = dict()
351
352            if module._diff:
353                result['diff'] = dict(before=before_group, after='')
354
355            if module.check_mode:
356                module.exit_json(**result)
357
358            # delete for real
359            gid = before_group['id']
360            kc.delete_group(groupid=gid, realm=realm)
361
362            result['changed'] = True
363            result['msg'] = "Group {name} has been deleted".format(name=before_group['name'])
364
365            module.exit_json(**result)
366
367    module.exit_json(**result)
368
369
370if __name__ == '__main__':
371    main()
372