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