1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Copyright: (c) 2013, Chatham Financial <oss@chathamfinancial.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
10
11DOCUMENTATION = '''
12---
13module: rabbitmq_user
14short_description: Manage RabbitMQ users
15description:
16  - Add or remove users to RabbitMQ and assign permissions
17author: Chris Hoffman (@chrishoffman)
18options:
19  user:
20    description:
21      - Name of user to add
22    type: str
23    required: true
24    aliases: [username, name]
25  password:
26    description:
27      - Password of user to add.
28      - To change the password of an existing user, you must also specify
29        C(update_password=always).
30    type: str
31  tags:
32    description:
33      - User tags specified as comma delimited
34    type: str
35  permissions:
36    description:
37      - a list of dicts, each dict contains vhost, configure_priv, write_priv, and read_priv,
38        and represents a permission rule for that vhost.
39      - This option should be preferable when you care about all permissions of the user.
40      - You should use vhost, configure_priv, write_priv, and read_priv options instead
41        if you care about permissions for just some vhosts.
42    type: list
43    elements: dict
44    default: []
45  vhost:
46    description:
47      - vhost to apply access privileges.
48      - This option will be ignored when permissions option is used.
49    type: str
50    default: /
51  node:
52    description:
53      - erlang node name of the rabbit we wish to configure
54    type: str
55    default: rabbit
56  configure_priv:
57    description:
58      - Regular expression to restrict configure actions on a resource
59        for the specified vhost.
60      - By default all actions are restricted.
61      - This option will be ignored when permissions option is used.
62    type: str
63    default: '^$'
64  write_priv:
65    description:
66      - Regular expression to restrict configure actions on a resource
67        for the specified vhost.
68      - By default all actions are restricted.
69      - This option will be ignored when permissions option is used.
70    type: str
71    default: '^$'
72  read_priv:
73    description:
74      - Regular expression to restrict configure actions on a resource
75        for the specified vhost.
76      - By default all actions are restricted.
77      - This option will be ignored when permissions option is used.
78    type: str
79    default: '^$'
80  force:
81    description:
82      - Deletes and recreates the user.
83    type: bool
84    default: 'no'
85  state:
86    description:
87      - Specify if user is to be added or removed
88    type: str
89    default: present
90    choices: ['present', 'absent']
91  update_password:
92    description:
93      - C(on_create) will only set the password for newly created users.  C(always) will update passwords if they differ.
94    type: str
95    required: false
96    default: on_create
97    choices: ['on_create', 'always']
98'''
99
100EXAMPLES = '''
101# Add user to server and assign full access control on / vhost.
102# The user might have permission rules for other vhost but you don't care.
103- community.rabbitmq.rabbitmq_user:
104    user: joe
105    password: changeme
106    vhost: /
107    configure_priv: .*
108    read_priv: .*
109    write_priv: .*
110    state: present
111
112# Add user to server and assign full access control on / vhost.
113# The user doesn't have permission rules for other vhosts
114- community.rabbitmq.rabbitmq_user:
115    user: joe
116    password: changeme
117    permissions:
118      - vhost: /
119        configure_priv: .*
120        read_priv: .*
121        write_priv: .*
122    state: present
123'''
124
125import distutils.version
126import json
127import re
128
129from ansible.module_utils.basic import AnsibleModule
130from ansible.module_utils.common.collections import count
131
132
133def normalized_permissions(vhost_permission_list):
134    """Older versions of RabbitMQ output permissions with slightly different names.
135
136    In older versions of RabbitMQ, the names of the permissions had the `_priv` suffix, which was removed in versions
137    >= 3.7.6. For simplicity we only check the `configure` permission. If it's in the old format then all the other
138    ones will be wrong too.
139    """
140    for vhost_permission in vhost_permission_list:
141        if 'configure_priv' in vhost_permission:
142            yield {
143                'configure': vhost_permission['configure_priv'],
144                'read': vhost_permission['read_priv'],
145                'write': vhost_permission['write_priv'],
146                'vhost': vhost_permission['vhost']
147            }
148        else:
149            yield vhost_permission
150
151
152def as_permission_dict(vhost_permission_list):
153    return dict([(vhost_permission['vhost'], vhost_permission) for vhost_permission
154                 in normalized_permissions(vhost_permission_list)])
155
156
157def only(vhost, vhost_permissions):
158    return {vhost: vhost_permissions.get(vhost, {})}
159
160
161def first(iterable):
162    return next(iter(iterable))
163
164
165class RabbitMqUser(object):
166    def __init__(self, module, username, password, tags, permissions,
167                 node, bulk_permissions=False):
168        self.module = module
169        self.username = username
170        self.password = password or ''
171        self.node = node
172        self.tags = list() if not tags else tags.replace(' ', '').split(',')
173        self.permissions = as_permission_dict(permissions)
174        self.bulk_permissions = bulk_permissions
175
176        self.existing_tags = None
177        self.existing_permissions = dict()
178        self._rabbitmqctl = module.get_bin_path('rabbitmqctl', True)
179        self._version = self._check_version()
180
181    def _check_version(self):
182        """Get the version of the RabbitMQ server."""
183        version = self._rabbitmq_version_post_3_7(fail_on_error=False)
184        if not version:
185            version = self._rabbitmq_version_pre_3_7(fail_on_error=False)
186        if not version:
187            self.module.fail_json(msg="Could not determine the version of the RabbitMQ server.")
188        return version
189
190    def _fail(self, msg, stop_execution=False):
191        if stop_execution:
192            self.module.fail_json(msg=msg)
193        # This is a dummy return to prevent linters from throwing errors.
194        return None
195
196    def _rabbitmq_version_post_3_7(self, fail_on_error=False):
197        """Use the JSON formatter to get a machine readable output of the version.
198
199        At this point we do not know which RabbitMQ server version we are dealing with and which
200        version of `rabbitmqctl` we are using, so we will try to use the JSON formatter and see
201        what happens. In some versions of
202        """
203        def int_list_to_str(ints):
204            return ''.join([chr(i) for i in ints])
205
206        rc, output, err = self._exec(['status', '--formatter', 'json'], check_rc=False)
207        if rc != 0:
208            return self._fail(msg="Could not parse the version of the RabbitMQ server, "
209                                  "because `rabbitmqctl status` returned no output.",
210                              stop_execution=fail_on_error)
211        try:
212            status_json = json.loads(output)
213            if 'rabbitmq_version' in status_json:
214                return distutils.version.StrictVersion(status_json['rabbitmq_version'])
215            for application in status_json.get('running_applications', list()):
216                if application[0] == 'rabbit':
217                    if isinstance(application[1][0], int):
218                        return distutils.version.StrictVersion(int_list_to_str(application[2]))
219                    else:
220                        return distutils.version.StrictVersion(application[1])
221            return self._fail(msg="Could not find RabbitMQ version of `rabbitmqctl status` command.",
222                              stop_execution=fail_on_error)
223        except ValueError as e:
224            return self._fail(msg="Could not parse output of `rabbitmqctl status` as JSON: {exc}.".format(exc=repr(e)),
225                              stop_execution=fail_on_error)
226
227    def _rabbitmq_version_pre_3_7(self, fail_on_error=False):
228        """Get the version of the RabbitMQ Server.
229
230        Before version 3.7.6 the `rabbitmqctl` utility did not support the
231        `--formatter` flag, so the output has to be parsed using regular expressions.
232        """
233        version_reg_ex = r"{rabbit,\"RabbitMQ\",\"([0-9]+\.[0-9]+\.[0-9]+)\"}"
234        rc, output, err = self._exec(['status'], check_rc=False)
235        if rc != 0:
236            if fail_on_error:
237                self.module.fail_json(msg="Could not parse the version of the RabbitMQ server, because"
238                                          " `rabbitmqctl status` returned no output.")
239            else:
240                return None
241        reg_ex_res = re.search(version_reg_ex, output, re.IGNORECASE)
242        if not reg_ex_res:
243            return self._fail(msg="Could not parse the version of the RabbitMQ server from the output of "
244                                  "`rabbitmqctl status` command: {output}.".format(output=output),
245                              stop_execution=fail_on_error)
246        try:
247            return distutils.version.StrictVersion(reg_ex_res.group(1))
248        except ValueError as e:
249            return self._fail(msg="Could not parse the version of the RabbitMQ server: {exc}.".format(exc=repr(e)),
250                              stop_execution=fail_on_error)
251
252    def _exec(self, args, check_rc=True):
253        """Execute a command using the `rabbitmqctl` utility.
254
255        By default the _exec call will cause the module to fail, if the error code is non-zero. If the `check_rc`
256        flag is set to False, then the exit_code, stdout and stderr will be returned to the calling function to
257        perform whatever error handling it needs.
258
259        :param args: the arguments to pass to the `rabbitmqctl` utility
260        :param check_rc: when set to True, fail if the utility's exit code is non-zero
261        :return: the output of the command or all the outputs plus the error code in case of error
262        """
263        cmd = [self._rabbitmqctl, '-q']
264        if self.node:
265            cmd.extend(['-n', self.node])
266        rc, out, err = self.module.run_command(cmd + args)
267        if check_rc and rc != 0:
268            # check_rc is not passed to the `run_command` method directly to allow for more fine grained checking of
269            # error messages returned by `rabbitmqctl`.
270            user_error_msg_regex = r"(Only root or .* .* run rabbitmqctl)"
271            user_error_msg = re.search(user_error_msg_regex, out)
272            if user_error_msg:
273                self.module.fail_json(msg="Wrong user used to run the `rabbitmqctl` utility: {err}"
274                                      .format(err=user_error_msg.group(1)))
275            else:
276                self.module.fail_json(msg="rabbitmqctl exited with non-zero code: {err}".format(err=err),
277                                      rc=rc, stdout=out)
278        return out if check_rc else (rc, out, err)
279
280    def get(self):
281        """Retrieves the list of registered users from the node.
282
283        If the user is already present, the node will also be queried for the user's permissions.
284        If the version of the node is >= 3.7.6 the JSON formatter will be used, otherwise the plaintext will be
285        parsed.
286        """
287        if self._version >= distutils.version.StrictVersion('3.7.6'):
288            users = dict([(user_entry['user'], user_entry['tags'])
289                          for user_entry in json.loads(self._exec(['list_users', '--formatter', 'json']))])
290        else:
291            users = self._exec(['list_users'])
292
293            def process_tags(tags):
294                if not tags:
295                    return list()
296                return tags.replace('[', '').replace(']', '').replace(' ', '').strip('\t').split(',')
297
298            users_and_tags = [user_entry.split('\t') for user_entry in users.strip().split('\n')]
299
300            users = dict()
301            for user_parts in users_and_tags:
302                users[user_parts[0]] = process_tags(user_parts[1]) if len(user_parts) > 1 else []
303
304        self.existing_tags = users.get(self.username, list())
305        self.existing_permissions = self._get_permissions() if self.username in users else dict()
306        return self.username in users
307
308    def _get_permissions(self):
309        """Get permissions of the user from RabbitMQ."""
310        if self._version >= distutils.version.StrictVersion('3.7.6'):
311            permissions = json.loads(self._exec(['list_user_permissions', self.username, '--formatter', 'json']))
312        else:
313            output = self._exec(['list_user_permissions', self.username]).strip().split('\n')
314            perms_out = [perm.split('\t') for perm in output if perm.strip()]
315            # Filter out headers from the output of the command in case they are still present
316            perms_out = [perm for perm in perms_out if perm != ["vhost", "configure", "write", "read"]]
317
318            permissions = list()
319            for vhost, configure, write, read in perms_out:
320                permissions.append(dict(vhost=vhost, configure=configure, write=write, read=read))
321
322        if self.bulk_permissions:
323            return as_permission_dict(permissions)
324        else:
325            return only(first(self.permissions.keys()), as_permission_dict(permissions))
326
327    def check_password(self):
328        """Return `True` if the user can authenticate successfully."""
329        rc, out, err = self._exec(['authenticate_user', self.username, self.password], check_rc=False)
330        return rc == 0
331
332    def add(self):
333        self._exec(['add_user', self.username, self.password or ''])
334        if not self.password:
335            self._exec(['clear_password', self.username])
336
337    def delete(self):
338        self._exec(['delete_user', self.username])
339
340    def change_password(self):
341        if self.password:
342            self._exec(['change_password', self.username, self.password])
343        else:
344            self._exec(['clear_password', self.username])
345
346    def set_tags(self):
347        self._exec(['set_user_tags', self.username] + self.tags)
348
349    def set_permissions(self):
350        permissions_to_add = list()
351        for vhost, permission_dict in self.permissions.items():
352            if permission_dict != self.existing_permissions.get(vhost, {}):
353                permissions_to_add.append(permission_dict)
354
355        permissions_to_clear = list()
356        for vhost in self.existing_permissions.keys():
357            if vhost not in self.permissions:
358                permissions_to_clear.append(vhost)
359
360        for vhost in permissions_to_clear:
361            cmd = 'clear_permissions -p {vhost} {username}'.format(username=self.username, vhost=vhost)
362            self._exec(cmd.split(' '))
363        for permissions in permissions_to_add:
364            cmd = ('set_permissions -p {vhost} {username} {configure} {write} {read}'
365                   .format(username=self.username, **permissions))
366            self._exec(cmd.split(' '))
367        self.existing_permissions = self._get_permissions()
368
369    def has_tags_modifications(self):
370        return set(self.tags) != set(self.existing_tags)
371
372    def has_permissions_modifications(self):
373        return self.existing_permissions != self.permissions
374
375
376def main():
377    arg_spec = dict(
378        user=dict(required=True, aliases=['username', 'name']),
379        password=dict(default=None, no_log=True),
380        tags=dict(default=None),
381        permissions=dict(default=list(), type='list', elements='dict'),
382        vhost=dict(default='/'),
383        configure_priv=dict(default='^$'),
384        write_priv=dict(default='^$'),
385        read_priv=dict(default='^$'),
386        force=dict(default='no', type='bool'),
387        state=dict(default='present', choices=['present', 'absent']),
388        node=dict(default='rabbit'),
389        update_password=dict(default='on_create', choices=['on_create', 'always'], no_log=False)
390    )
391    module = AnsibleModule(
392        argument_spec=arg_spec,
393        supports_check_mode=True
394    )
395
396    username = module.params['user']
397    password = module.params['password']
398    tags = module.params['tags']
399    permissions = module.params['permissions']
400    vhost = module.params['vhost']
401    configure_priv = module.params['configure_priv']
402    write_priv = module.params['write_priv']
403    read_priv = module.params['read_priv']
404    force = module.params['force']
405    state = module.params['state']
406    node = module.params['node']
407    update_password = module.params['update_password']
408
409    if permissions:
410        vhosts = [permission.get('vhost', '/') for permission in permissions]
411        if any([vhost_count > 1 for vhost_count in count(vhosts).values()]):
412            module.fail_json(msg="Error parsing vhost permissions: You can't "
413                                 "have two permission dicts for the same vhost")
414        bulk_permissions = True
415    else:
416        perm = {
417            'vhost': vhost,
418            'configure_priv': configure_priv,
419            'write_priv': write_priv,
420            'read_priv': read_priv
421        }
422        permissions.append(perm)
423        bulk_permissions = False
424
425    for permission in permissions:
426        if not permission['vhost']:
427            module.fail_json(msg="Error parsing vhost permissions: You can't"
428                                 "have an empty vhost when setting permissions")
429
430    rabbitmq_user = RabbitMqUser(module, username, password, tags, permissions,
431                                 node, bulk_permissions=bulk_permissions)
432
433    result = dict(changed=False, user=username, state=state)
434    if rabbitmq_user.get():
435        if state == 'absent':
436            rabbitmq_user.delete()
437            result['changed'] = True
438        else:
439            if force:
440                rabbitmq_user.delete()
441                rabbitmq_user.add()
442                rabbitmq_user.get()
443                result['changed'] = True
444            elif update_password == 'always':
445                if not rabbitmq_user.check_password():
446                    rabbitmq_user.change_password()
447                    result['changed'] = True
448
449            if rabbitmq_user.has_tags_modifications():
450                rabbitmq_user.set_tags()
451                result['changed'] = True
452
453            if rabbitmq_user.has_permissions_modifications():
454                rabbitmq_user.set_permissions()
455                result['changed'] = True
456    elif state == 'present':
457        rabbitmq_user.add()
458        rabbitmq_user.set_tags()
459        rabbitmq_user.set_permissions()
460        result['changed'] = True
461
462    module.exit_json(**result)
463
464
465if __name__ == '__main__':
466    main()
467