diff --git a/changelogs/fragments/lie_mysql_info_users_info.yml b/changelogs/fragments/lie_mysql_info_users_info.yml new file mode 100644 index 000000000..5d7526fcc --- /dev/null +++ b/changelogs/fragments/lie_mysql_info_users_info.yml @@ -0,0 +1,5 @@ +--- + +minor_changes: + + - mysql_info - add filter ``users_info`` (https://github.com/ansible-collections/community.mysql/pull/580). diff --git a/plugins/module_utils/user.py b/plugins/module_utils/user.py index e1d80ab27..a88b32e72 100644 --- a/plugins/module_utils/user.py +++ b/plugins/module_utils/user.py @@ -112,23 +112,40 @@ def get_grants(cursor, user, host): return grants.split(", ") -def get_existing_authentication(cursor, user): +def get_existing_authentication(cursor, user, host): # Return the plugin and auth_string if there is exactly one distinct existing plugin and auth_string. cursor.execute("SELECT VERSION()") - if 'mariadb' in cursor.fetchone()[0].lower(): + srv_type = cursor.fetchone() + # Mysql_info use a DictCursor so we must convert back to a list + # otherwise we get KeyError 0 + if isinstance(srv_type, dict): + srv_type = list(srv_type.values()) + + if 'mariadb' in srv_type[0].lower(): # before MariaDB 10.2.19 and 10.3.11, "password" and "authentication_string" can differ # when using mysql_native_password cursor.execute("""select plugin, auth from ( select plugin, password as auth from mysql.user where user=%(user)s + and host=%(host)s union select plugin, authentication_string as auth from mysql.user where user=%(user)s - ) x group by plugin, auth limit 2 - """, {'user': user}) + and host=%(host)s) x group by plugin, auth limit 2 + """, {'user': user, 'host': host}) else: - cursor.execute("""select plugin, authentication_string as auth from mysql.user where user=%(user)s - group by plugin, authentication_string limit 2""", {'user': user}) + cursor.execute("""select plugin, authentication_string as auth + from mysql.user where user=%(user)s and host=%(host)s + group by plugin, authentication_string limit 2""", {'user': user, 'host': host}) rows = cursor.fetchall() - if len(rows) == 1: - return {'plugin': rows[0][0], 'auth_string': rows[0][1]} + + # Mysql_info use a DictCursor so we must convert back to a list + # otherwise we get KeyError 0 + if isinstance(rows, dict): + rows = list(rows.values()) + + if isinstance(rows[0], tuple): + return {'plugin': rows[0][0], 'plugin_auth_string': rows[0][1]} + + if isinstance(rows[0], dict): + return {'plugin': rows[0].get('plugin'), 'plugin_auth_string': rows[0].get('auth')} return None @@ -149,7 +166,7 @@ def user_add(cursor, user, host, host_all, password, encrypted, used_existing_password = False if reuse_existing_password: - existing_auth = get_existing_authentication(cursor, user) + existing_auth = get_existing_authentication(cursor, user, host) if existing_auth: plugin = existing_auth['plugin'] plugin_hash_string = existing_auth['auth_string'] @@ -478,6 +495,12 @@ def pick(x): return x for grant in grants: + + # Mysql_info use a DictCursor so we must convert back to a list + # otherwise we get KeyError 0 + if isinstance(grant, dict): + grant = list(grant.values()) + if not maria_role: res = re.match("""GRANT (.+) ON (.+) TO (['`"]).*\\3@(['`"]).*\\4( IDENTIFIED BY PASSWORD (['`"]).+\\6)? ?(.*)""", grant[0]) else: @@ -777,6 +800,11 @@ def get_resource_limits(cursor, user, host): cursor.execute(query, (user, host)) res = cursor.fetchone() + # Mysql_info use a DictCursor so we must convert back to a list + # otherwise we get KeyError 0 + if isinstance(res, dict): + res = list(res.values()) + if not res: return None @@ -788,11 +816,22 @@ def get_resource_limits(cursor, user, host): } cursor.execute("SELECT VERSION()") - if 'mariadb' in cursor.fetchone()[0].lower(): + srv_type = cursor.fetchone() + # Mysql_info use a DictCursor so we must convert back to a list + # otherwise we get KeyError 0 + if isinstance(srv_type, dict): + srv_type = list(srv_type.values()) + + if 'mariadb' in srv_type[0].lower(): query = ('SELECT max_statement_time AS MAX_STATEMENT_TIME ' 'FROM mysql.user WHERE User = %s AND Host = %s') cursor.execute(query, (user, host)) res_max_statement_time = cursor.fetchone() + + # Mysql_info use a DictCursor so we must convert back to a list + # otherwise we get KeyError 0 + if isinstance(res_max_statement_time, dict): + res_max_statement_time = list(res_max_statement_time.values()) current_limits['MAX_STATEMENT_TIME'] = res_max_statement_time[0] return current_limits diff --git a/plugins/modules/mysql_info.py b/plugins/modules/mysql_info.py index cb9f029bc..73e403a22 100644 --- a/plugins/modules/mysql_info.py +++ b/plugins/modules/mysql_info.py @@ -19,7 +19,7 @@ description: - Limit the collected information by comma separated string or YAML list. - Allowable values are C(version), C(databases), C(settings), C(global_status), - C(users), C(engines), C(master_status), C(slave_status), C(slave_hosts). + C(users), C(users_info), C(engines), C(master_status), C(slave_status), C(slave_hosts). - By default, collects all subsets. - You can use '!' before value (for example, C(!settings)) to exclude it from the information. - If you pass including and excluding values to the filter, for example, I(filter=!settings,version), @@ -74,6 +74,9 @@ # Display only databases and users info: # ansible mysql-hosts -m mysql_info -a 'filter=databases,users' +# Display all users privileges: +# ansible mysql-hosts -m mysql_info -a 'filter=users_info' + # Display only slave status: # ansible standby -m mysql_info -a 'filter=slave_status' @@ -122,6 +125,38 @@ - databases exclude_fields: db_size return_empty_dbs: true + +- name: Clone users from one server to another + block: + # Step 1 + - name: Fetch information from a source server + delegate_to: server_source + community.mysql.mysql_info: + filter: + - users_info + register: result + + # Step 2 + # Don't work with sha256_password and cache_sha2_password + - name: Clone users fetched in a previous task to a target server + community.mysql.mysql_user: + name: "{{ item.name }}" + host: "{{ item.host }}" + plugin: "{{ item.plugin | default(omit) }}" + plugin_auth_string: "{{ item.plugin_auth_string | default(omit) }}" + plugin_hash_string: "{{ item.plugin_hash_string | default(omit) }}" + tls_require: "{{ item.tls_require | default(omit) }}" + priv: "{{ item.priv | default(omit) }}" + resource_limits: "{{ item.resource_limits | default(omit) }}" + column_case_sensitive: true + state: present + loop: "{{ result.users_info }}" + loop_control: + label: "{{ item.name }}@{{ item.host }}" + when: + - item.name != 'root' # In case you don't want to import admin accounts + - item.name != 'mariadb.sys' + - item.name != 'mysql' ''' RETURN = r''' @@ -181,11 +216,31 @@ sample: - { "Innodb_buffer_pool_read_requests": 123, "Innodb_buffer_pool_reads": 32 } users: - description: Users information. + description: Return a dictionnary of users grouped by host and with global privileges only. returned: if not excluded by filter type: dict sample: - { "localhost": { "root": { "Alter_priv": "Y", "Alter_routine_priv": "Y" } } } +users_info: + description: + - Information about users accounts. + - The output can be used as an input of the M(community.mysql.mysql_user) plugin. + - Useful when migrating accounts to another server or to create an inventory. + - Does not support proxy privileges. If an account has proxy privileges, they won't appear in the output. + - Causes issues with authentications plugins C(sha256_password) and C(caching_sha2_password). + If the output is fed to M(community.mysql.mysql_user), the + ``plugin_auth_string`` will most likely be unreadable due to non-binary + characters. + returned: if not excluded by filter + type: dict + sample: + - { "plugin_auth_string": '*1234567', + "name": "user1", + "host": "host.com", + "plugin": "mysql_native_password", + "priv": "db1.*:SELECT/db2.*:SELECT", + "resource_limits": { "MAX_USER_CONNECTIONS": 100 } } + version_added: '3.8.0' engines: description: Information about the server's storage engines. returned: if not excluded by filter @@ -238,6 +293,12 @@ get_connector_name, get_connector_version, ) + +from ansible_collections.community.mysql.plugins.module_utils.user import ( + privileges_get, + get_resource_limits, + get_existing_authentication, +) from ansible.module_utils.six import iteritems from ansible.module_utils._text import to_native @@ -274,6 +335,7 @@ def __init__(self, module, cursor): 'global_status': {}, 'engines': {}, 'users': {}, + 'users_info': {}, 'master_status': {}, 'slave_hosts': {}, 'slave_status': {}, @@ -342,6 +404,9 @@ def __collect(self, exclude_fields, return_empty_dbs, wanted): if 'users' in wanted: self.__get_users() + if 'users_info' in wanted: + self.__get_users_info() + if 'master_status' in wanted: self.__get_master_status() @@ -480,6 +545,86 @@ def __get_users(self): if vname not in ('Host', 'User'): self.info['users'][host][user][vname] = self.__convert(val) + def __get_users_info(self): + """Get user privileges, passwords, resources_limits, ... + + Query the server to get all the users and return a string + of privileges that can be used by the mysql_user plugin. + For instance: + + "users_info": [ + { + "host": "users_info.com", + "priv": "*.*: ALL,GRANT", + "name": "users_info_adm" + }, + { + "host": "users_info.com", + "priv": "`mysql`.*: SELECT/`users_info_db`.*: SELECT", + "name": "users_info_multi" + } + ] + """ + res = self.__exec_sql('SELECT * FROM mysql.user') + if not res: + return None + + output = list() + for line in res: + user = line['User'] + host = line['Host'] + + user_priv = privileges_get(self.cursor, user, host) + + if not user_priv: + self.module.warn("No privileges found for %s on host %s" % (user, host)) + continue + + priv_string = list() + for db_table, priv in user_priv.items(): + # Proxy privileges are hard to work with because of different quotes or + # backticks like ''@'', ''@'%' or even ``@``. In addition, MySQL will + # forbid you to grant a proxy privileges through TCP. + if set(priv) == {'PROXY', 'GRANT'} or set(priv) == {'PROXY'}: + continue + + unquote_db_table = db_table.replace('`', '').replace("'", '') + priv_string.append('%s:%s' % (unquote_db_table, ','.join(priv))) + + # Only keep *.* USAGE if it's the only user privilege given + if len(priv_string) > 1 and '*.*:USAGE' in priv_string: + priv_string.remove('*.*:USAGE') + + resource_limits = get_resource_limits(self.cursor, user, host) + + copy_ressource_limits = dict.copy(resource_limits) + output_dict = { + 'name': user, + 'host': host, + 'priv': '/'.join(priv_string), + 'resource_limits': copy_ressource_limits, + } + + # Prevent returning a resource limit if empty + if resource_limits: + for key, value in resource_limits.items(): + if value == 0: + del output_dict['resource_limits'][key] + if len(output_dict['resource_limits']) == 0: + del output_dict['resource_limits'] + + authentications = get_existing_authentication(self.cursor, user, host) + if authentications: + output_dict.update(authentications) + + # TODO password_option + # TODO lock_option + # but both are not supported by mysql_user atm. So no point yet. + + output.append(output_dict) + + self.info['users_info'] = output + def __get_databases(self, exclude_fields, return_empty_dbs): """Get info about databases.""" if not exclude_fields: diff --git a/tests/integration/targets/test_mysql_info/files/users_info_create_procedure.sql b/tests/integration/targets/test_mysql_info/files/users_info_create_procedure.sql new file mode 100644 index 000000000..5a358f0aa --- /dev/null +++ b/tests/integration/targets/test_mysql_info/files/users_info_create_procedure.sql @@ -0,0 +1,7 @@ +DELIMITER // +DROP PROCEDURE IF EXISTS users_info_db.get_all_items; +CREATE PROCEDURE users_info_db.get_all_items() +BEGIN +SELECT * from users_info_db.t1; +END // +DELIMITER ; diff --git a/tests/integration/targets/test_mysql_info/tasks/filter_users_info.yml b/tests/integration/targets/test_mysql_info/tasks/filter_users_info.yml new file mode 100644 index 000000000..2c126c12f --- /dev/null +++ b/tests/integration/targets/test_mysql_info/tasks/filter_users_info.yml @@ -0,0 +1,280 @@ +--- + +- module_defaults: + community.mysql.mysql_db: &mysql_defaults + login_user: "{{ mysql_user }}" + login_password: "{{ mysql_password }}" + login_host: "{{ mysql_host }}" + login_port: "{{ mysql_primary_port }}" + community.mysql.mysql_query: *mysql_defaults + community.mysql.mysql_info: *mysql_defaults + community.mysql.mysql_user: *mysql_defaults + + block: + + # ================================ Prepare ============================== + - name: Mysql_info users_info | Create databases + community.mysql.mysql_db: + name: + - users_info_db + - users_info_db2 + - users_info_db3 + state: present + + - name: Mysql_info users_info | Create tables + community.mysql.mysql_query: + query: + - >- + CREATE TABLE IF NOT EXISTS users_info_db.t1 + (id int, name varchar(9)) + - >- + CREATE TABLE IF NOT EXISTS users_info_db.T_UPPER + (id int, name1 varchar(9), NAME2 varchar(9), Name3 varchar(9)) + + # I failed to create a procedure using community.mysql.mysql_query. + # Maybe it's because we must changed the delimiter. + - name: Mysql_info users_info | Create procedure SQL file + ansible.builtin.template: + src: files/users_info_create_procedure.sql + dest: /root/create_procedure.sql + owner: root + group: root + mode: '0700' + + - name: Mysql_info users_info | Create a procedure + community.mysql.mysql_db: + name: all + state: import + target: /root/create_procedure.sql + + # Use a query instead of mysql_user, because we want to caches differences + # at the end and a bug in mysql_user would be invisible to this tests + - name: Mysql_info users_info | Prepare common tests users + community.mysql.mysql_query: + query: + - >- + CREATE USER users_info_adm@'users_info.com' IDENTIFIED WITH + mysql_native_password AS '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + - > + GRANT ALL ON *.* to users_info_adm@'users_info.com' WITH GRANT + OPTION + + - >- + CREATE USER users_info_schema@'users_info.com' IDENTIFIED WITH + mysql_native_password AS '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + - >- + GRANT SELECT, INSERT, UPDATE, DELETE ON users_info_db.* TO + users_info_schema@'users_info.com' + + - >- + CREATE USER users_info_table@'users_info.com' IDENTIFIED WITH + mysql_native_password AS '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + - >- + GRANT SELECT, INSERT, UPDATE ON users_info_db.t1 TO + users_info_table@'users_info.com' + + - >- + CREATE USER users_info_col@'users_info.com' IDENTIFIED WITH + mysql_native_password AS '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + WITH MAX_USER_CONNECTIONS 100 + - >- + GRANT SELECT (id) ON users_info_db.t1 TO + users_info_col@'users_info.com' + + - >- + CREATE USER users_info_proc@'users_info.com' IDENTIFIED WITH + mysql_native_password AS '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + WITH MAX_USER_CONNECTIONS 2 MAX_CONNECTIONS_PER_HOUR 60 + - >- + GRANT EXECUTE ON PROCEDURE users_info_db.get_all_items TO + users_info_proc@'users_info.com' + + - >- + CREATE USER users_info_multi@'users_info.com' IDENTIFIED WITH + mysql_native_password AS '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + - >- + GRANT SELECT ON mysql.* TO + users_info_multi@'users_info.com' + - >- + GRANT ALL ON users_info_db.* TO + users_info_multi@'users_info.com' + - >- + GRANT ALL ON users_info_db2.* TO + users_info_multi@'users_info.com' + - >- + GRANT ALL ON users_info_db3.* TO + users_info_multi@'users_info.com' + + - >- + CREATE USER users_info_usage_only@'users_info.com' IDENTIFIED WITH + mysql_native_password AS '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + - >- + GRANT USAGE ON *.* TO + users_info_usage_only@'users_info.com' + + - >- + CREATE USER users_info_columns_uppercase@'users_info.com' + IDENTIFIED WITH mysql_native_password AS + '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + - >- + GRANT SELECT,UPDATE(name1,NAME2,Name3) ON users_info_db.T_UPPER TO + users_info_columns_uppercase@'users_info.com' + + - >- + CREATE USER users_info_multi_hosts@'%' + IDENTIFIED WITH mysql_native_password AS + '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + - GRANT SELECT ON users_info_db.* TO users_info_multi_hosts@'%' + + - >- + CREATE USER users_info_multi_hosts@'localhost' + IDENTIFIED WITH mysql_native_password AS + '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + - >- + GRANT SELECT ON users_info_db.* TO + users_info_multi_hosts@'localhost' + + - >- + CREATE USER users_info_multi_hosts@'host1' + IDENTIFIED WITH mysql_native_password AS + '*6C387FC3893DBA1E3BA155E74754DA6682D04747' + - GRANT SELECT ON users_info_db.* TO users_info_multi_hosts@'host1' + + # Different password than the others users_info_multi_hosts + - >- + CREATE USER users_info_multi_hosts@'host2' + IDENTIFIED WITH mysql_native_password AS + '*CB3326D5279DE7915FE5D743232165EE887883CA' + - GRANT SELECT ON users_info_db.* TO users_info_multi_hosts@'host2' + + - name: Mysql_info users_info | Prepare tests users for MariaDB + community.mysql.mysql_user: + name: "{{ item.name }}" + host: "users_info.com" + plugin: "{{ item.plugin | default(omit) }}" + plugin_auth_string: "{{ item.plugin_auth_string | default(omit) }}" + plugin_hash_string: "{{ item.plugin_hash_string | default(omit) }}" + tls_require: "{{ item.tls_require | default(omit) }}" + priv: "{{ item.priv }}" + resource_limits: "{{ item.resource_limits | default(omit) }}" + column_case_sensitive: true + state: present + loop: + - name: users_info_socket # Only for MariaDB + priv: + '*.*': 'ALL' + plugin: 'unix_socket' + when: + - db_engine == 'mariadb' + + - name: Mysql_info users_info | Prepare tests users for MySQL + community.mysql.mysql_user: + name: "{{ item.name }}" + host: "users_info.com" + plugin: "{{ item.plugin | default(omit) }}" + plugin_auth_string: "{{ item.plugin_auth_string | default(omit) }}" + plugin_hash_string: "{{ item.plugin_hash_string | default(omit) }}" + tls_require: "{{ item.tls_require | default(omit) }}" + priv: "{{ item.priv }}" + resource_limits: "{{ item.resource_limits | default(omit) }}" + column_case_sensitive: true + state: present + loop: + - name: users_info_sha256 # Only for MySQL + priv: + '*.*': 'ALL' + plugin_auth_string: + '$5$/=') + + # ================================== Tests ============================== + + - name: Mysql_info users_info | Collect users_info + community.mysql.mysql_info: + filter: + - users_info + register: result + + - name: Recreate users from mysql_info users_info result + community.mysql.mysql_user: + name: "{{ item.name }}" + host: "{{ item.host }}" + plugin: "{{ item.plugin | default(omit) }}" + plugin_auth_string: "{{ item.plugin_auth_string | default(omit) }}" + plugin_hash_string: "{{ item.plugin_hash_string | default(omit) }}" + tls_require: "{{ item.tls_require | default(omit) }}" + priv: "{{ item.priv | default(omit) }}" + resource_limits: "{{ item.resource_limits | default(omit) }}" + column_case_sensitive: true + state: present + loop: "{{ result.users_info }}" + loop_control: + label: "{{ item.name }}@{{ item.host }}" + register: recreate_users_result + failed_when: + - recreate_users_result is changed + when: + - item.name != 'root' + - item.name != 'mysql' + - item.name != 'mariadb.sys' + - item.name != 'mysql.sys' + - item.name != 'mysql.infoschema' + + + # ================================== Cleanup ============================ + + - name: Mysql_info users_info | Cleanup users_info + community.mysql.mysql_user: + name: "{{ item }}" + host_all: true + column_case_sensitive: true + state: absent + loop: + - users_info_adm + - users_info_schema + - users_info_table + - users_info_col + - users_info_proc + - users_info_multi + - users_info_db + - users_info_usage_only + - users_info_columns_uppercase + - users_info_multi_hosts + + - name: Mysql_info users_info | Cleanup databases + community.mysql.mysql_db: + name: + - users_info_db + - users_info_db2 + - users_info_db3 + state: absent + + - name: Mysql_info users_info | Cleanup sql file for the procedure + ansible.builtin.file: + path: /root/create_procedure.sql + state: absent diff --git a/tests/integration/targets/test_mysql_info/tasks/main.yml b/tests/integration/targets/test_mysql_info/tasks/main.yml index be367f068..5d34da9a0 100644 --- a/tests/integration/targets/test_mysql_info/tasks/main.yml +++ b/tests/integration/targets/test_mysql_info/tasks/main.yml @@ -219,3 +219,7 @@ assert: that: - result.databases.allviews.size == 0 + + - name: Import tasks file to tests users_info filter + ansible.builtin.import_tasks: + file: filter_users_info.yml