#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright: (c) 2019, Paul Arthur # Copyright: (c) 2019, XLAB Steampunk # # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function __metaclass__ = type ANSIBLE_METADATA = { "metadata_version": "1.1", "status": ["stableinterface"], "supported_by": "certified", } DOCUMENTATION = ''' module: user author: - Paul Arthur (@flowerysong) - Aljaz Kosir (@aljazkosir) - Tadej Borovsak (@tadeboro) short_description: Manage Sensu users description: - Create, update, activate or deactivate Sensu user. - For more information, refer to the Sensu documentation at U(https://docs.sensu.io/sensu-go/latest/reference/rbac/#users). version_added: 1.0.0 extends_documentation_fragment: - sensu.sensu_go.requirements - sensu.sensu_go.auth - sensu.sensu_go.name requirements: - bcrypt (when managing Sensu Go 5.21.0 or newer) seealso: - module: sensu.sensu_go.user_info options: state: description: - Desired state of the user. - Users cannot actually be deleted, only deactivated. type: str choices: [ enabled, disabled ] default: enabled password: description: - Password for the user. - Required if user with a desired name does not exist yet on the backend and I(password_hash) is not set. - If both I(password) and I(password_hash) are set, I(password_hash) is ignored and calculated from the I(password) if required. type: str password_hash: description: - Bcrypt password hash for the user. - Use C(sensuctl user hash-password PASSWORD) to generate a hash. - Required if user with a desired name does not exist yet on the backend and I(password) is not set. - If both I(password) and I(password_hash) are set, I(password_hash) is ignored and calculated from the I(password) if required. - Sensu Go < 5.21.0 does not support creating/updating users using hashed passwords. Use I(password) parameter if you need to manage such Sensu Go installations. - At the moment, change detection does not work properly when using password hashes because the Sensu Go backend does not expose enough information via its API. type: str version_added: 1.8.0 groups: description: - List of groups user belongs to. type: list elements: str ''' EXAMPLES = ''' - name: Create a user sensu.sensu_go.user: auth: url: http://localhost:8080 name: awesome_username password: hidden_password? groups: - dev - prod - name: Use pre-hashed password sensu.sensu_go.user: auth: url: http://localhost:8080 name: awesome_username password_hash: $5f$14$.brXRviMZpbaleSq9kjoUuwm67V/s4IziOLGHjEqxJbzPsreQAyNm - name: Deactivate a user sensu.sensu_go.user: name: awesome_username state: disabled ''' RETURN = ''' object: description: Object representing Sensu user. returned: success type: dict sample: disabled: false groups: - ops - dev password: USER_PASSWORD password_hash: $5f$14$.brXRviMZpbaleSq9kjoUuwm67V/s4IziOLGHjEqxJbzPsreQAyNm username: alice ''' import traceback from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils import arguments, errors, utils try: import bcrypt HAS_BCRYPT = True BCRYPT_IMPORT_ERROR = None except ImportError: HAS_BCRYPT = False BCRYPT_IMPORT_ERROR = traceback.format_exc() def _simulate_backend_response(payload): # Backend does not return back any password-related information for now. masked_keys = ('password', 'password_hash') return dict( (k, v) for k, v in payload.items() if k not in masked_keys ) def update_password(client, path, username, password, check_mode): # Hit the auth testing API and try to validate the credentials. If the API # says they are invalid, we need to update them. if client.validate_auth_data(username, password): return False if not check_mode: if client.version < "5.21.0": utils.put(client, path + '/password', dict( username=username, password=password, )) else: # Raise exception if BCRYPT library is not present on host. if HAS_BCRYPT: hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) else: raise errors.RequirementsError(missing_required_lib('bcrypt')) utils.put(client, path + '/reset_password', dict( username=username, password_hash=hash.decode('ascii'), )) return True def update_password_hash(client, path, username, password_hash, check_mode): # Some older Sensu Go versions do not have support for password hashes. if client.version < "5.21.0": raise errors.SensuError( "Sensu Go < 5.21.0 does not support password hashes" ) # Insert change detection here once we can receive password hash from the # backend. Up until then, we always update passwords. if not check_mode: utils.put(client, path + '/reset_password', dict( username=username, password_hash=password_hash, )) return True def update_groups(client, path, old_groups, new_groups, check_mode): to_delete = set(old_groups).difference(new_groups) to_add = set(new_groups).difference(old_groups) if not check_mode: # Next few lines are far from atomic, which means that we can leave a # user in any of the intermediate states, but this is the best we can # do given the API limitations. for g in to_add: utils.put(client, path + '/groups/' + g, None) for g in to_delete: utils.delete(client, path + '/groups/' + g) return len(to_delete) + len(to_add) > 0 def update_state(client, path, old_disabled, new_disabled, check_mode): changed = old_disabled != new_disabled if not check_mode and changed: if new_disabled: # `state: disabled` input parameter utils.delete(client, path) else: # `state: enabled` input parameter utils.put(client, path + '/reinstate', None) return changed def sync(remote_object, client, path, payload, check_mode): # Create new user (either enabled or disabled) if remote_object is None: if check_mode: return True, _simulate_backend_response(payload) utils.put(client, path, payload) return True, utils.get(client, path) # Update existing user. We do this on a field-by-field basis because the # upsteam API for updating users requires a password field to be set. Of # course, we do not want to force users to specify an existing password # just for the sake of updating the group membership, so this is why we # use field-specific API endpoints to update the user data. changed = False # We only use password hash if we do not have a password. In practice, # this means that users should not set both password and password_hash. We # do not enforce this by making those two parameters mutually exclusive # because in the future (2.0.0 version of collection), we intend to move # password hashing into action plugin and supply both the password and its # hash. Why? Because installing bcrypt on control node is way friendlier # compared to installing bcrypt on every host that runs our user module. # # It is true that most of the time, control node == target node in our # cases, but not always. if 'password' in payload: changed = update_password( client, path, payload['username'], payload['password'], check_mode, ) or changed elif 'password_hash' in payload: changed = update_password_hash( client, path, payload['username'], payload['password_hash'], check_mode, ) or changed if 'groups' in payload: changed = update_groups( client, path, remote_object.get('groups') or [], payload['groups'], check_mode, ) or changed if 'disabled' in payload: changed = update_state( client, path, remote_object['disabled'], payload['disabled'], check_mode, ) or changed if check_mode: # Backend does not return back passwords, so we should follow the # example set by the backend API. return changed, dict( remote_object, **_simulate_backend_response(payload) ) return changed, utils.get(client, path) def main(): module = AnsibleModule( supports_check_mode=True, argument_spec=dict( arguments.get_spec("auth", "name"), state=dict( default='enabled', choices=['enabled', 'disabled'], ), password=dict( no_log=True ), password_hash=dict( no_log=False, # Showing hashes is perfectly OK ), groups=dict( type='list', elements='str', ) ), ) client = arguments.get_sensu_client(module.params['auth']) path = utils.build_core_v2_path(None, 'users', module.params['name']) try: remote_object = utils.get(client, path) except errors.Error as e: module.fail_json(msg=str(e)) if ( remote_object is None and module.params['password'] is None and module.params['password_hash'] is None ): module.fail_json( msg='Cannot create new user without a password or a hash' ) payload = arguments.get_spec_payload( module.params, 'password', 'password_hash', 'groups', ) payload['username'] = module.params['name'] payload['disabled'] = module.params['state'] == 'disabled' try: changed, user = sync( remote_object, client, path, payload, module.check_mode ) module.exit_json(changed=changed, object=user) except errors.Error as e: module.fail_json(msg=str(e)) if __name__ == '__main__': main()