From b062f5fae66df84b0a7970af6a7808ab53b1626e Mon Sep 17 00:00:00 2001 From: Jorge Rodriguez Date: Tue, 19 May 2020 17:16:19 +0300 Subject: [PATCH 01/12] Add TLS connection parameters --- .../369-mysql_user_add_tls_requires.yml | 4 + plugins/modules/mysql_user.py | 651 +++++++++++++----- .../targets/test_mysql_user/tasks/main.yml | 109 +++ 3 files changed, 586 insertions(+), 178 deletions(-) create mode 100644 changelogs/fragments/369-mysql_user_add_tls_requires.yml diff --git a/changelogs/fragments/369-mysql_user_add_tls_requires.yml b/changelogs/fragments/369-mysql_user_add_tls_requires.yml new file mode 100644 index 00000000..6a479ec4 --- /dev/null +++ b/changelogs/fragments/369-mysql_user_add_tls_requires.yml @@ -0,0 +1,4 @@ +minor_changes: + - mysql_user - add TLS REQUIRES parameters (https://github.com/ansible-collections/community.general/pull/369). +deprecated_features: + - mysql_user - using ``REQUIRESSL`` in ``priv`` is deprecated in favor of ``tls_requires`` (https://github.com/ansible-collections/community.general/pull/369). diff --git a/plugins/modules/mysql_user.py b/plugins/modules/mysql_user.py index b7193bba..5fe22a3c 100644 --- a/plugins/modules/mysql_user.py +++ b/plugins/modules/mysql_user.py @@ -6,10 +6,11 @@ # 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 -DOCUMENTATION = r''' +DOCUMENTATION = r""" --- module: mysql_user short_description: Adds or removes a user from a MySQL database @@ -60,6 +61,14 @@ user instead of overwriting existing ones. type: bool default: no + tls_requires: + description: + - Set requirement for secure transport as a dictionary of requirements (see the examples). + - Valid requirements are SSL, X509, SUBJECT, ISSUER, CIPHER. + - SUBJECT, ISSUER and CIPHER are complementary, and mutually exclusive with SSL and X509. + - U(https://mariadb.com/kb/en/securing-connections-for-client-and-server/#requiring-tls). + type: dict + version_added: 1.0.0 sql_log_bin: description: - Whether binary logging should be enabled or disabled for the connection. @@ -133,9 +142,9 @@ extends_documentation_fragment: - community.mysql.mysql -''' +""" -EXAMPLES = r''' +EXAMPLES = r""" - name: Removes anonymous user account for localhost community.mysql.mysql_user: name: '' @@ -180,6 +189,7 @@ 'db2.*': 'ALL,GRANT' # Note that REQUIRESSL is a special privilege that should only apply to *.* by itself. +# Setting this privilege in this manner is supported for backwards compatibility only. Use 'tls_requires' instead. - name: Modify user to require SSL connections. community.mysql.mysql_user: name: bob @@ -187,6 +197,20 @@ priv: '*.*:REQUIRESSL' state: present +- name: Modify user to require TLS connection with a valid client certificate + community.mysql.mysql_user: + name: bob + tls_requires: + x509: + state: present + +- name: Modify user to require TLS connection with a specific client certificate and cipher + community.mysql.mysql_user: + name: bob + tls_requires: + subject: '/CN=alice/O=MyDom, Inc./C=US/ST=Oregon/L=Portland' + cipher: 'ECDHE-ECDSA-AES256-SHA384' + - name: Ensure no user named 'sally'@'localhost' exists, also passing in the auth credentials. community.mysql.mysql_user: login_user: root @@ -262,55 +286,109 @@ # [client] # user=root # password=n<_665{vS43y -''' +""" import re import string from ansible.module_utils.basic import AnsibleModule from ansible_collections.community.mysql.plugins.module_utils.database import SQLParseError -from ansible_collections.community.mysql.plugins.module_utils.mysql import mysql_connect, mysql_driver, mysql_driver_fail_msg +from ansible_collections.community.mysql.plugins.module_utils.mysql import ( + mysql_connect, + mysql_driver, + mysql_driver_fail_msg, +) from ansible.module_utils.six import iteritems from ansible.module_utils._text import to_native -VALID_PRIVS = frozenset(('CREATE', 'DROP', 'GRANT', 'GRANT OPTION', - 'LOCK TABLES', 'REFERENCES', 'EVENT', 'ALTER', - 'DELETE', 'INDEX', 'INSERT', 'SELECT', 'UPDATE', - 'CREATE TEMPORARY TABLES', 'TRIGGER', 'CREATE VIEW', - 'SHOW VIEW', 'ALTER ROUTINE', 'CREATE ROUTINE', - 'EXECUTE', 'FILE', 'CREATE TABLESPACE', 'CREATE USER', - 'PROCESS', 'PROXY', 'RELOAD', 'REPLICATION CLIENT', - 'REPLICATION SLAVE', 'SHOW DATABASES', 'SHUTDOWN', - 'SUPER', 'ALL', 'ALL PRIVILEGES', 'USAGE', 'REQUIRESSL', - 'CREATE ROLE', 'DROP ROLE', 'APPLICATION_PASSWORD_ADMIN', - 'AUDIT_ADMIN', 'BACKUP_ADMIN', 'BINLOG_ADMIN', - 'BINLOG_ENCRYPTION_ADMIN', 'CLONE_ADMIN', 'CONNECTION_ADMIN', - 'ENCRYPTION_KEY_ADMIN', 'FIREWALL_ADMIN', 'FIREWALL_USER', - 'GROUP_REPLICATION_ADMIN', 'INNODB_REDO_LOG_ARCHIVE', - 'NDB_STORED_USER', 'PERSIST_RO_VARIABLES_ADMIN', - 'REPLICATION_APPLIER', 'REPLICATION_SLAVE_ADMIN', - 'RESOURCE_GROUP_ADMIN', 'RESOURCE_GROUP_USER', - 'ROLE_ADMIN', 'SESSION_VARIABLES_ADMIN', 'SET_USER_ID', - 'SYSTEM_USER', 'SYSTEM_VARIABLES_ADMIN', 'SYSTEM_USER', - 'TABLE_ENCRYPTION_ADMIN', 'VERSION_TOKEN_ADMIN', - 'XA_RECOVER_ADMIN', 'LOAD FROM S3', 'SELECT INTO S3', - 'INVOKE LAMBDA', - 'ALTER ROUTINE', - 'BINLOG ADMIN', - 'BINLOG MONITOR', - 'BINLOG REPLAY', - 'CONNECTION ADMIN', - 'READ_ONLY ADMIN', - 'REPLICATION MASTER ADMIN', - 'REPLICATION SLAVE', - 'REPLICATION SLAVE ADMIN', - 'SET USER',)) +VALID_PRIVS = frozenset( + ( + "CREATE", + "DROP", + "GRANT", + "GRANT OPTION", + "LOCK TABLES", + "REFERENCES", + "EVENT", + "ALTER", + "DELETE", + "INDEX", + "INSERT", + "SELECT", + "UPDATE", + "CREATE TEMPORARY TABLES", + "TRIGGER", + "CREATE VIEW", + "SHOW VIEW", + "ALTER ROUTINE", + "CREATE ROUTINE", + "EXECUTE", + "FILE", + "CREATE TABLESPACE", + "CREATE USER", + "PROCESS", + "PROXY", + "RELOAD", + "REPLICATION CLIENT", + "REPLICATION SLAVE", + "SHOW DATABASES", + "SHUTDOWN", + "SUPER", + "ALL", + "ALL PRIVILEGES", + "USAGE", + "REQUIRESSL", + "CREATE ROLE", + "DROP ROLE", + "APPLICATION_PASSWORD_ADMIN", + "AUDIT_ADMIN", + "BACKUP_ADMIN", + "BINLOG_ADMIN", + "BINLOG_ENCRYPTION_ADMIN", + "CLONE_ADMIN", + "CONNECTION_ADMIN", + "ENCRYPTION_KEY_ADMIN", + "FIREWALL_ADMIN", + "FIREWALL_USER", + "GROUP_REPLICATION_ADMIN", + "INNODB_REDO_LOG_ARCHIVE", + "NDB_STORED_USER", + "PERSIST_RO_VARIABLES_ADMIN", + "REPLICATION_APPLIER", + "REPLICATION_SLAVE_ADMIN", + "RESOURCE_GROUP_ADMIN", + "RESOURCE_GROUP_USER", + "ROLE_ADMIN", + "SESSION_VARIABLES_ADMIN", + "SET_USER_ID", + "SYSTEM_USER", + "SYSTEM_VARIABLES_ADMIN", + "SYSTEM_USER", + "TABLE_ENCRYPTION_ADMIN", + "VERSION_TOKEN_ADMIN", + "XA_RECOVER_ADMIN", + "LOAD FROM S3", + "SELECT INTO S3", + "INVOKE LAMBDA", + "ALTER ROUTINE", + "BINLOG ADMIN", + "BINLOG MONITOR", + "BINLOG REPLAY", + "CONNECTION ADMIN", + "READ_ONLY ADMIN", + "REPLICATION MASTER ADMIN", + "REPLICATION SLAVE", + "REPLICATION SLAVE ADMIN", + "SET USER", + ) +) class InvalidPrivsError(Exception): pass + # =========================================== # MySQL module specific support methods. # @@ -321,9 +399,9 @@ def use_old_user_mgmt(cursor): cursor.execute("SELECT VERSION()") result = cursor.fetchone() version_str = result[0] - version = version_str.split('.') + version = version_str.split(".") - if 'mariadb' in version_str.lower(): + if "mariadb" in version_str.lower(): # Prior to MariaDB 10.2 if int(version[0]) * 1000 + int(version[1]) < 10002: return True @@ -338,13 +416,13 @@ def use_old_user_mgmt(cursor): def get_mode(cursor): - cursor.execute('SELECT @@GLOBAL.sql_mode') + cursor.execute("SELECT @@GLOBAL.sql_mode") result = cursor.fetchone() mode_str = result[0] - if 'ANSI' in mode_str: - mode = 'ANSI' + if "ANSI" in mode_str: + mode = "ANSI" else: - mode = 'NOTANSI' + mode = "NOTANSI" return mode @@ -358,8 +436,84 @@ def user_exists(cursor, user, host, host_all): return count[0] > 0 -def user_add(cursor, user, host, host_all, password, encrypted, - plugin, plugin_hash_string, plugin_auth_string, new_priv, check_mode): +def sanitize_requires(tls_requires): + sanitized_requires = {} + if tls_requires: + for key in tls_requires.keys(): + sanitized_requires[key.upper()] = tls_requires[key] + if any([key in ["CIPHER", "ISSUER", "SUBJECT"] for key in sanitized_requires.keys()]): + sanitized_requires.pop("SSL", None) + sanitized_requires.pop("X509", None) + return sanitized_requires + + if "X509" in sanitized_requires.keys(): + sanitized_requires = "X509" + else: + sanitized_requires = "SSL" + + return sanitized_requires + return None + + +def mogrify_requires(query, params, tls_requires): + if tls_requires: + if isinstance(tls_requires, dict): + k, v = zip(*tls_requires.items()) + requires_query = " AND ".join(("%s %%s" % key for key in k)) + params += v + else: + requires_query = tls_requires + query = " REQUIRE ".join((query, requires_query)) + return query, params + + +def do_not_mogrify_requires(query, params, tls_requires): + return query, params + + +def get_tls_requires(cursor, user, host): + if user: + if server_suports_requires_create(cursor): + query = "SHOW CREATE USER '%s'@'%s'" % (user, host) + else: + query = "SHOW GRANTS for '%s'@'%s'" % (user, host) + + cursor.execute(query) + require_list = list(filter(lambda x: "REQUIRE" in x, cursor.fetchall())) + require_line = require_list[0] if require_list else "" + pattern = r"(?<=\bREQUIRE\b)(.*?)(?=(?:\bPASSWORD\b|$))" + requires_match = re.search(pattern, require_line) + requires = requires_match.group().strip() if requires_match else "" + if len(requires.split()) > 1: + import shlex + + items = iter(shlex.split(requires)) + requires = dict(zip(items, items)) + return requires or None + + +def get_grants(cursor, user, host): + cursor.execute("SHOW GRANTS FOR %s@%s", (user, host)) + grants_line = list(filter(lambda x: "ON *.*" in x[0], cursor.fetchall()))[0] + pattern = r"(?<=\bGRANT\b)(.*?)(?=(?:\bON\b))" + grants = re.search(pattern, grants_line[0]).group().strip() + return grants.split(", ") + + +def user_add( + cursor, + user, + host, + host_all, + password, + encrypted, + plugin, + plugin_hash_string, + plugin_auth_string, + new_priv, + tls_requires, + check_mode, +): # we cannot create users without a proper hostname if host_all: return False @@ -367,43 +521,59 @@ def user_add(cursor, user, host, host_all, password, encrypted, if check_mode: return True - # Determine what user management method server uses - old_user_mgmt = use_old_user_mgmt(cursor) + mogrify = mogrify_requires if server_suports_requires_create(cursor) else do_not_mogrify_requires if password and encrypted: - cursor.execute("CREATE USER %s@%s IDENTIFIED BY PASSWORD %s", (user, host, password)) + cursor.execute(*mogrify("CREATE USER %s@%s IDENTIFIED BY PASSWORD %s", (user, host, password), tls_requires)) elif password and not encrypted: - if old_user_mgmt: - cursor.execute("CREATE USER %s@%s IDENTIFIED BY %s", (user, host, password)) - else: - cursor.execute("SELECT CONCAT('*', UCASE(SHA1(UNHEX(SHA1(%s)))))", (password,)) - encrypted_password = cursor.fetchone()[0] - cursor.execute("CREATE USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", (user, host, encrypted_password)) - + cursor.execute(*mogrify("CREATE USER %s@%s IDENTIFIED BY %s", (user, host, password), tls_requires)) elif plugin and plugin_hash_string: - cursor.execute("CREATE USER %s@%s IDENTIFIED WITH %s AS %s", (user, host, plugin, plugin_hash_string)) + cursor.execute( + *mogrify( + "CREATE USER %s@%s IDENTIFIED WITH %s AS %s", (user, host, plugin, plugin_hash_string), tls_requires + ) + ) elif plugin and plugin_auth_string: - cursor.execute("CREATE USER %s@%s IDENTIFIED WITH %s BY %s", (user, host, plugin, plugin_auth_string)) + cursor.execute( + *mogrify( + "CREATE USER %s@%s IDENTIFIED WITH %s BY %s", (user, host, plugin, plugin_auth_string), tls_requires + ) + ) elif plugin: - cursor.execute("CREATE USER %s@%s IDENTIFIED WITH %s", (user, host, plugin)) + cursor.execute(*mogrify("CREATE USER %s@%s IDENTIFIED WITH %s", (user, host, plugin), tls_requires)) else: - cursor.execute("CREATE USER %s@%s", (user, host)) + cursor.execute(*mogrify("CREATE USER %s@%s", (user, host), tls_requires)) if new_priv is not None: for db_table, priv in iteritems(new_priv): - privileges_grant(cursor, user, host, db_table, priv) + privileges_grant(cursor, user, host, db_table, priv, tls_requires) + if tls_requires is not None: + privileges_grant(cursor, user, host, "*.*", get_grants(cursor, user, host), tls_requires) return True def is_hash(password): ishash = False - if len(password) == 41 and password[0] == '*': + if len(password) == 41 and password[0] == "*": if frozenset(password[1:]).issubset(string.hexdigits): ishash = True return ishash -def user_mod(cursor, user, host, host_all, password, encrypted, - plugin, plugin_hash_string, plugin_auth_string, new_priv, append_privs, module): +def user_mod( + cursor, + user, + host, + host_all, + password, + encrypted, + plugin, + plugin_hash_string, + plugin_auth_string, + new_priv, + append_privs, + tls_requires, + module, +): changed = False msg = "User unchanged" grant_option = False @@ -420,36 +590,46 @@ def user_mod(cursor, user, host, host_all, password, encrypted, old_user_mgmt = use_old_user_mgmt(cursor) # Get a list of valid columns in mysql.user table to check if Password and/or authentication_string exist - cursor.execute(""" + cursor.execute( + """ SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'user' AND COLUMN_NAME IN ('Password', 'authentication_string') ORDER BY COLUMN_NAME DESC LIMIT 1 - """) + """ + ) colA = cursor.fetchone() - cursor.execute(""" + cursor.execute( + """ SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'user' AND COLUMN_NAME IN ('Password', 'authentication_string') ORDER BY COLUMN_NAME ASC LIMIT 1 - """) + """ + ) colB = cursor.fetchone() # Select hash from either Password or authentication_string, depending which one exists and/or is filled - cursor.execute(""" + cursor.execute( + """ SELECT COALESCE( CASE WHEN %s = '' THEN NULL ELSE %s END, CASE WHEN %s = '' THEN NULL ELSE %s END ) FROM mysql.user WHERE user = %%s AND host = %%s - """ % (colA[0], colA[0], colB[0], colB[0]), (user, host)) + """ + % (colA[0], colA[0], colB[0], colB[0]), + (user, host), + ) current_pass_hash = cursor.fetchone()[0] if isinstance(current_pass_hash, bytes): - current_pass_hash = current_pass_hash.decode('ascii') + current_pass_hash = current_pass_hash.decode("ascii") if encrypted: encrypted_password = password if not is_hash(encrypted_password): - module.fail_json(msg="encrypted was specified however it does not appear to be a valid hash expecting: *SHA1(SHA1(your_password))") + module.fail_json( + msg="encrypted was specified however it does not appear to be a valid hash expecting: *SHA1(SHA1(your_password))" + ) else: if old_user_mgmt: cursor.execute("SELECT PASSWORD(%s)", (password,)) @@ -466,7 +646,10 @@ def user_mod(cursor, user, host, host_all, password, encrypted, msg = "Password updated (old style)" else: try: - cursor.execute("ALTER USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", (user, host, encrypted_password)) + cursor.execute( + "ALTER USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", + (user, host, encrypted_password), + ) msg = "Password updated (new style)" except (mysql_driver.Error) as e: # https://stackoverflow.com/questions/51600000/authentication-string-of-root-user-on-mysql @@ -474,8 +657,9 @@ def user_mod(cursor, user, host, host_all, password, encrypted, if e.args[0] == 1396: cursor.execute( "UPDATE mysql.user SET plugin = %s, authentication_string = %s, Password = '' WHERE User = %s AND Host = %s", - ('mysql_native_password', encrypted_password, user, host) + ("mysql_native_password", encrypted_password, user, host), ) + cursor.execute("GRANT USAGE on *.* to '%s'@'%s'", (user, host)) cursor.execute("FLUSH PRIVILEGES") msg = "Password forced update" else: @@ -484,8 +668,9 @@ def user_mod(cursor, user, host, host_all, password, encrypted, # Handle plugin authentication if plugin: - cursor.execute("SELECT plugin, authentication_string FROM mysql.user " - "WHERE user = %s AND host = %s", (user, host)) + cursor.execute( + "SELECT plugin, authentication_string FROM mysql.user " "WHERE user = %s AND host = %s", (user, host) + ) current_plugin = cursor.fetchone() update = False @@ -505,9 +690,13 @@ def user_mod(cursor, user, host, host_all, password, encrypted, if update: if plugin_hash_string: - cursor.execute("ALTER USER %s@%s IDENTIFIED WITH %s AS %s", (user, host, plugin, plugin_hash_string)) + cursor.execute( + "ALTER USER %s@%s IDENTIFIED WITH %s AS %s", (user, host, plugin, plugin_hash_string) + ) elif plugin_auth_string: - cursor.execute("ALTER USER %s@%s IDENTIFIED WITH %s BY %s", (user, host, plugin, plugin_auth_string)) + cursor.execute( + "ALTER USER %s@%s IDENTIFIED WITH %s BY %s", (user, host, plugin, plugin_auth_string) + ) else: cursor.execute("ALTER USER %s@%s IDENTIFIED WITH %s", (user, host, plugin)) changed = True @@ -537,7 +726,7 @@ def user_mod(cursor, user, host, host_all, password, encrypted, msg = "New privileges granted" if module.check_mode: return (True, msg) - privileges_grant(cursor, user, host, db_table, priv) + privileges_grant(cursor, user, host, db_table, priv, tls_requires) changed = True # If the db.table specification exists in both the user's current privileges @@ -551,9 +740,28 @@ def user_mod(cursor, user, host, host_all, password, encrypted, return (True, msg) if not append_privs: privileges_revoke(cursor, user, host, db_table, curr_priv[db_table], grant_option) - privileges_grant(cursor, user, host, db_table, new_priv[db_table]) + privileges_grant(cursor, user, host, db_table, new_priv[db_table], tls_requires) changed = True + # Handle TLS requirements + current_requires = get_tls_requires(cursor, user, host) + if current_requires != tls_requires: + msg = "TLS requires updated" + if module.check_mode: + return (True, msg) + if server_suports_requires_create(cursor): + pre_query = "ALTER USER" + else: + pre_query = "GRANT %s ON *.* TO" % ",".join(get_grants(cursor, user, host)) + + if tls_requires is not None: + query = " ".join((pre_query, "%s@%s")) + cursor.execute(*mogrify_requires(query, (user, host), tls_requires)) + else: + query = " ".join(pre_query, "%s@%s REQUIRE NONE") + cursor.execute(query, (user, host)) + changed = True + return (changed, msg) @@ -598,21 +806,23 @@ def privileges_get(cursor, user, host): grants = cursor.fetchall() def pick(x): - if x == 'ALL PRIVILEGES': - return 'ALL' + if x == "ALL PRIVILEGES": + return "ALL" else: return x for grant in grants: - res = re.match("""GRANT (.+) ON (.+) TO (['`"]).*\\3@(['`"]).*\\4( IDENTIFIED BY PASSWORD (['`"]).+\\6)? ?(.*)""", grant[0]) + res = re.match( + """GRANT (.+) ON (.+) TO (['`"]).*\\3@(['`"]).*\\4( IDENTIFIED BY PASSWORD (['`"]).+\\6)? ?(.*)""", grant[0] + ) if res is None: - raise InvalidPrivsError('unable to parse the MySQL grant string: %s' % grant[0]) + raise InvalidPrivsError("unable to parse the MySQL grant string: %s" % grant[0]) privileges = res.group(1).split(",") privileges = [pick(x.strip()) for x in privileges] if "WITH GRANT OPTION" in res.group(7): - privileges.append('GRANT') + privileges.append("GRANT") if "REQUIRE SSL" in res.group(7): - privileges.append('REQUIRESSL') + privileges.append("REQUIRESSL") db = res.group(2) output.setdefault(db, []).extend(privileges) return output @@ -629,80 +839,84 @@ def privileges_unpack(priv, mode): The privilege USAGE stands for no privileges, so we add that in on *.* if it's not specified in the string, as MySQL will always provide this by default. """ - if mode == 'ANSI': + if mode == "ANSI": quote = '"' else: - quote = '`' + quote = "`" output = {} privs = [] - for item in priv.strip().split('/'): - pieces = item.strip().rsplit(':', 1) + for item in priv.strip().split("/"): + pieces = item.strip().rsplit(":", 1) dbpriv = pieces[0].rsplit(".", 1) # Check for FUNCTION or PROCEDURE object types parts = dbpriv[0].split(" ", 1) - object_type = '' - if len(parts) > 1 and (parts[0] == 'FUNCTION' or parts[0] == 'PROCEDURE'): - object_type = parts[0] + ' ' + object_type = "" + if len(parts) > 1 and (parts[0] == "FUNCTION" or parts[0] == "PROCEDURE"): + object_type = parts[0] + " " dbpriv[0] = parts[1] # Do not escape if privilege is for database or table, i.e. # neither quote *. nor .* for i, side in enumerate(dbpriv): - if side.strip('`') != '*': - dbpriv[i] = '%s%s%s' % (quote, side.strip('`'), quote) - pieces[0] = object_type + '.'.join(dbpriv) + if side.strip("`") != "*": + dbpriv[i] = "%s%s%s" % (quote, side.strip("`"), quote) + pieces[0] = object_type + ".".join(dbpriv) - if '(' in pieces[1]: - output[pieces[0]] = re.split(r',\s*(?=[^)]*(?:\(|$))', pieces[1].upper()) + if "(" in pieces[1]: + output[pieces[0]] = re.split(r",\s*(?=[^)]*(?:\(|$))", pieces[1].upper()) for i in output[pieces[0]]: - privs.append(re.sub(r'\s*\(.*\)', '', i)) + privs.append(re.sub(r"\s*\(.*\)", "", i)) else: - output[pieces[0]] = pieces[1].upper().split(',') + output[pieces[0]] = pieces[1].upper().split(",") privs = output[pieces[0]] new_privs = frozenset(privs) if not new_privs.issubset(VALID_PRIVS): - raise InvalidPrivsError('Invalid privileges specified: %s' % new_privs.difference(VALID_PRIVS)) + raise InvalidPrivsError("Invalid privileges specified: %s" % new_privs.difference(VALID_PRIVS)) - if '*.*' not in output: - output['*.*'] = ['USAGE'] + if "*.*" not in output: + output["*.*"] = ["USAGE"] # if we are only specifying something like REQUIRESSL and/or GRANT (=WITH GRANT OPTION) in *.* # we still need to add USAGE as a privilege to avoid syntax errors - if 'REQUIRESSL' in priv and not set(output['*.*']).difference(set(['GRANT', 'REQUIRESSL'])): - output['*.*'].append('USAGE') + if "REQUIRESSL" in priv and not set(output["*.*"]).difference(set(["GRANT", "REQUIRESSL"])): + output["*.*"].append("USAGE") return output def privileges_revoke(cursor, user, host, db_table, priv, grant_option): # Escape '%' since mysql db.execute() uses a format string - db_table = db_table.replace('%', '%%') + db_table = db_table.replace("%", "%%") if grant_option: query = ["REVOKE GRANT OPTION ON %s" % db_table] query.append("FROM %s@%s") - query = ' '.join(query) + query = " ".join(query) cursor.execute(query, (user, host)) - priv_string = ",".join([p for p in priv if p not in ('GRANT', 'REQUIRESSL')]) + priv_string = ",".join([p for p in priv if p not in ("GRANT", "REQUIRESSL")]) query = ["REVOKE %s ON %s" % (priv_string, db_table)] query.append("FROM %s@%s") - query = ' '.join(query) + query = " ".join(query) cursor.execute(query, (user, host)) -def privileges_grant(cursor, user, host, db_table, priv): +def privileges_grant(cursor, user, host, db_table, priv, tls_requires): # Escape '%' since mysql db.execute uses a format string and the # specification of db and table often use a % (SQL wildcard) - db_table = db_table.replace('%', '%%') - priv_string = ",".join([p for p in priv if p not in ('GRANT', 'REQUIRESSL')]) + db_table = db_table.replace("%", "%%") + priv_string = ",".join([p for p in priv if p not in ("GRANT", "REQUIRESSL")]) query = ["GRANT %s ON %s" % (priv_string, db_table)] query.append("TO %s@%s") - if 'REQUIRESSL' in priv: + params = (user, host) + if tls_requires and not server_suports_requires_create(cursor): + query, params = mogrify_requires(" ".join(query), params, tls_requires) + query = [query] + if "REQUIRESSL" in priv and not tls_requires: query.append("REQUIRE SSL") - if 'GRANT' in priv: + if "GRANT" in priv: query.append("WITH GRANT OPTION") - query = ' '.join(query) - cursor.execute(query, (user, host)) + query = " ".join(query) + cursor.execute(query, params) def convert_priv_dict_to_str(priv): @@ -714,9 +928,36 @@ def convert_priv_dict_to_str(priv): Returns: priv (str): String representation of input argument. """ - priv_list = ['%s:%s' % (key, val) for key, val in iteritems(priv)] + priv_list = ["%s:%s" % (key, val) for key, val in iteritems(priv)] + + return "/".join(priv_list) + - return '/'.join(priv_list) +# TLS requires on user create statement is supported since MySQL 5.7 and MariaDB 10.2 +def server_suports_requires_create(cursor): + """Check if the server supports REQUIRES on the CREATE USER statement or doesn't. + + Args: + cursor (cursor): DB driver cursor object. + + Returns: True if supports, False otherwise. + """ + cursor.execute("SELECT VERSION()") + version_str = cursor.fetchone()[0] + version = version_str.split(".") + + if "mariadb" in version_str.lower(): + # MariaDB 10.2 and later + if int(version[0]) * 1000 + int(version[1]) >= 10002: + return True + else: + return False + else: + # MySQL 5.6 and later + if int(version[0]) * 1000 + int(version[1]) >= 5007: + return True + else: + return False # Alter user is supported since MySQL 5.6 and MariaDB 10.2.0 @@ -730,9 +971,9 @@ def server_supports_alter_user(cursor): """ cursor.execute("SELECT VERSION()") version_str = cursor.fetchone()[0] - version = version_str.split('.') + version = version_str.split(".") - if 'mariadb' in version_str.lower(): + if "mariadb" in version_str.lower(): # MariaDB 10.2 and later if int(version[0]) * 1000 + int(version[1]) >= 10002: return True @@ -757,11 +998,13 @@ def get_resource_limits(cursor, user, host): Returns: Dictionary containing current resource limits. """ - query = ('SELECT max_questions AS MAX_QUERIES_PER_HOUR, ' - 'max_updates AS MAX_UPDATES_PER_HOUR, ' - 'max_connections AS MAX_CONNECTIONS_PER_HOUR, ' - 'max_user_connections AS MAX_USER_CONNECTIONS ' - 'FROM mysql.user WHERE User = %s AND Host = %s') + query = ( + "SELECT max_questions AS MAX_QUERIES_PER_HOUR, " + "max_updates AS MAX_UPDATES_PER_HOUR, " + "max_connections AS MAX_CONNECTIONS_PER_HOUR, " + "max_user_connections AS MAX_USER_CONNECTIONS " + "FROM mysql.user WHERE User = %s AND Host = %s" + ) cursor.execute(query, (user, host)) res = cursor.fetchone() @@ -769,10 +1012,10 @@ def get_resource_limits(cursor, user, host): return None current_limits = { - 'MAX_QUERIES_PER_HOUR': res[0], - 'MAX_UPDATES_PER_HOUR': res[1], - 'MAX_CONNECTIONS_PER_HOUR': res[2], - 'MAX_USER_CONNECTIONS': res[3], + "MAX_QUERIES_PER_HOUR": res[0], + "MAX_UPDATES_PER_HOUR": res[1], + "MAX_CONNECTIONS_PER_HOUR": res[2], + "MAX_USER_CONNECTIONS": res[3], } return current_limits @@ -827,8 +1070,10 @@ def limit_resources(module, cursor, user, host, resource_limits, check_mode): Returns: True, if changed, False otherwise. """ if not server_supports_alter_user(cursor): - module.fail_json(msg="The server version does not match the requirements " - "for resource_limits parameter. See module's documentation.") + module.fail_json( + msg="The server version does not match the requirements " + "for resource_limits parameter. See module's documentation." + ) current_limits = get_resource_limits(cursor, user, host) @@ -843,13 +1088,14 @@ def limit_resources(module, cursor, user, host, resource_limits, check_mode): # If not check_mode tmp = [] for key, val in iteritems(needs_to_change): - tmp.append('%s %s' % (key, val)) + tmp.append("%s %s" % (key, val)) query = "ALTER USER %s@%s" - query += ' WITH %s' % ' '.join(tmp) + query += " WITH %s" % " ".join(tmp) cursor.execute(query, (user, host)) return True + # =========================================== # Module execution. # @@ -858,31 +1104,32 @@ def limit_resources(module, cursor, user, host, resource_limits, check_mode): def main(): module = AnsibleModule( argument_spec=dict( - login_user=dict(type='str'), - login_password=dict(type='str', no_log=True), - login_host=dict(type='str', default='localhost'), - login_port=dict(type='int', default=3306), - login_unix_socket=dict(type='str'), - user=dict(type='str', required=True, aliases=['name']), - password=dict(type='str', no_log=True), - encrypted=dict(type='bool', default=False), - host=dict(type='str', default='localhost'), + login_user=dict(type="str"), + login_password=dict(type="str", no_log=True), + login_host=dict(type="str", default="localhost"), + login_port=dict(type="int", default=3306), + login_unix_socket=dict(type="str"), + user=dict(type="str", required=True, aliases=["name"]), + password=dict(type="str", no_log=True), + encrypted=dict(type="bool", default=False), + host=dict(type="str", default="localhost"), host_all=dict(type="bool", default=False), - state=dict(type='str', default='present', choices=['absent', 'present']), - priv=dict(type='raw'), - append_privs=dict(type='bool', default=False), - check_implicit_admin=dict(type='bool', default=False), - update_password=dict(type='str', default='always', choices=['always', 'on_create'], no_log=False), - connect_timeout=dict(type='int', default=30), - config_file=dict(type='path', default='~/.my.cnf'), - sql_log_bin=dict(type='bool', default=True), - client_cert=dict(type='path', aliases=['ssl_cert']), - client_key=dict(type='path', aliases=['ssl_key']), - ca_cert=dict(type='path', aliases=['ssl_ca']), - plugin=dict(default=None, type='str'), - plugin_hash_string=dict(default=None, type='str'), - plugin_auth_string=dict(default=None, type='str'), - resource_limits=dict(type='dict'), + state=dict(type="str", default="present", choices=["absent", "present"]), + priv=dict(type="raw"), + tls_requires=dict(type="dict"), + append_privs=dict(type="bool", default=False), + check_implicit_admin=dict(type="bool", default=False), + update_password=dict(type="str", default="always", choices=["always", "on_create"], no_log=False), + connect_timeout=dict(type="int", default=30), + config_file=dict(type="path", default="~/.my.cnf"), + sql_log_bin=dict(type="bool", default=True), + client_cert=dict(type="path", aliases=["ssl_cert"]), + client_key=dict(type="path", aliases=["ssl_key"]), + ca_cert=dict(type="path", aliases=["ssl_ca"]), + plugin=dict(default=None, type="str"), + plugin_hash_string=dict(default=None, type="str"), + plugin_auth_string=dict(default=None, type="str"), + resource_limits=dict(type="dict"), ), supports_check_mode=True, ) @@ -895,15 +1142,16 @@ def main(): host_all = module.params["host_all"] state = module.params["state"] priv = module.params["priv"] - check_implicit_admin = module.params['check_implicit_admin'] - connect_timeout = module.params['connect_timeout'] - config_file = module.params['config_file'] + tls_requires = sanitize_requires(module.params["tls_requires"]) + check_implicit_admin = module.params["check_implicit_admin"] + connect_timeout = module.params["connect_timeout"] + config_file = module.params["config_file"] append_privs = module.boolean(module.params["append_privs"]) - update_password = module.params['update_password'] + update_password = module.params["update_password"] ssl_cert = module.params["client_cert"] ssl_key = module.params["client_key"] ssl_ca = module.params["ca_cert"] - db = '' + db = "" sql_log_bin = module.params["sql_log_bin"] plugin = module.params["plugin"] plugin_hash_string = module.params["plugin_hash_string"] @@ -922,17 +1170,29 @@ def main(): try: if check_implicit_admin: try: - cursor, db_conn = mysql_connect(module, 'root', '', config_file, ssl_cert, ssl_key, ssl_ca, db, - connect_timeout=connect_timeout) + cursor, db_conn = mysql_connect( + module, "root", "", config_file, ssl_cert, ssl_key, ssl_ca, db, connect_timeout=connect_timeout + ) except Exception: pass if not cursor: - cursor, db_conn = mysql_connect(module, login_user, login_password, config_file, ssl_cert, ssl_key, ssl_ca, db, - connect_timeout=connect_timeout) + cursor, db_conn = mysql_connect( + module, + login_user, + login_password, + config_file, + ssl_cert, + ssl_key, + ssl_ca, + db, + connect_timeout=connect_timeout, + ) except Exception as e: - module.fail_json(msg="unable to connect to database, check login_user and login_password are correct or %s has the credentials. " - "Exception message: %s" % (config_file, to_native(e))) + module.fail_json( + msg="unable to connect to database, check login_user and login_password are correct or %s has the credentials. " + "Exception message: %s" % (config_file, to_native(e)) + ) if not sql_log_bin: cursor.execute("SET SQL_LOG_BIN=0;") @@ -950,14 +1210,38 @@ def main(): if state == "present": if user_exists(cursor, user, host, host_all): try: - if update_password == 'always': - changed, msg = user_mod(cursor, user, host, host_all, password, encrypted, - plugin, plugin_hash_string, plugin_auth_string, - priv, append_privs, module) + if update_password == "always": + changed, msg = user_mod( + cursor, + user, + host, + host_all, + password, + encrypted, + plugin, + plugin_hash_string, + plugin_auth_string, + priv, + append_privs, + tls_requires, + module, + ) else: - changed, msg = user_mod(cursor, user, host, host_all, None, encrypted, - plugin, plugin_hash_string, plugin_auth_string, - priv, append_privs, module) + changed, msg = user_mod( + cursor, + user, + host, + host_all, + None, + encrypted, + plugin, + plugin_hash_string, + plugin_auth_string, + priv, + append_privs, + tls_requires, + module, + ) except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e: module.fail_json(msg=to_native(e)) @@ -965,9 +1249,20 @@ def main(): if host_all: module.fail_json(msg="host_all parameter cannot be used when adding a user") try: - changed = user_add(cursor, user, host, host_all, password, encrypted, - plugin, plugin_hash_string, plugin_auth_string, - priv, module.check_mode) + changed = user_add( + cursor, + user, + host, + host_all, + password, + encrypted, + plugin, + plugin_hash_string, + plugin_auth_string, + priv, + tls_requires, + module.check_mode, + ) if changed: msg = "User added" @@ -987,5 +1282,5 @@ def main(): module.exit_json(changed=changed, user=user, msg=msg) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/integration/targets/test_mysql_user/tasks/main.yml b/tests/integration/targets/test_mysql_user/tasks/main.yml index fcfa3b59..ad28a350 100644 --- a/tests/integration/targets/test_mysql_user/tasks/main.yml +++ b/tests/integration/targets/test_mysql_user/tasks/main.yml @@ -129,6 +129,115 @@ - include: assert_no_user.yml user_name={{user_name_2}} + # ============================================================ + # Create users with TLS requirements and verify requirements are assigned + # + - name: find out the database version + mysql_info: + <<: *mysql_params + filter: version + register: db_version + + - name: create user with TLS requirements state=present (expect changed=true) + mysql_user: + <<: *mysql_params + name: '{{ item[0] }}' + password: '{{ user_password_1 }}' + tls_requires: '{{ item[1] }}' + with_together: + - [ '{{ user_name_1 }}', '{{ user_name_2 }}', '{{ user_name_3 }}'] + - + - SSL: + - X509: + - subject: '/CN=alice/O=MyDom, Inc./C=US/ST=Oregon/L=Portland' + cipher: 'ECDHE-ECDSA-AES256-SHA384' + issuer: '/CN=org/O=MyDom, Inc./C=US/ST=Oregon/L=Portland' + + - block: + - name: retrieve TLS requiremets for users in old database version + command: "{{ mysql_command }} -L -N -s -e \"SHOW GRANTS for '{{ item }}'@'localhost'\"" + register: old_result + with_items: ['{{ user_name_1 }}', '{{ user_name_2 }}', '{{ user_name_3 }}'] + + - name: set old database separator + set_fact: + separator: '\n' + when: db_version.version.major <= 5 and db_version.version.minor <= 6 or db_version.version.major == 10 and db_version.version.minor < 2 + + - block: + - name: retrieve TLS requiremets for users in new database version + command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ item }}'@'localhost'\"" + register: new_result + with_items: ['{{ user_name_1 }}', '{{ user_name_2 }}', '{{ user_name_3 }}'] + + - name: set new database separator + set_fact: + separator: 'PASSWORD' + when: db_version.version.major >= 5 and db_version.version.major < 10 and db_version.version.minor > 6 or db_version.version.major == 10 and db_version.version.minor >= 2 + + - block: + - name: assert user1 TLS requirements + assert: + that: + - "'SSL' in reqs" + vars: + - reqs: "{{((old_result.results[0] is skipped | ternary(new_result, old_result)).results | selectattr('item', 'contains', user_name_1) | first).stdout.split('REQUIRE')[1].split(separator)[0].strip()}}" + + - name: assert user2 TLS requirements + assert: + that: + - "'X509' in reqs" + vars: + - reqs: "{{((old_result.results[0] is skipped | ternary(new_result, old_result)).results | selectattr('item', 'contains', user_name_2) | first).stdout.split('REQUIRE')[1].split(separator)[0].strip()}}" + + - name: assert user3 TLS requirements + assert: + that: + - "'/CN=alice/O=MyDom, Inc./C=US/ST=Oregon/L=Portland' in (reqs | select('contains', 'SUBJECT') | first)" + - "'/CN=org/O=MyDom, Inc./C=US/ST=Oregon/L=Portland' in (reqs | select('contains', 'ISSUER') | first)" + - "'ECDHE-ECDSA-AES256-SHA384' in (reqs | select('contains', 'CIPHER') | first)" + vars: + - reqs: "{{((old_result.results[0] is skipped | ternary(new_result, old_result)).results | selectattr('item', 'contains', user_name_3) | first).stdout.split('REQUIRE')[1].split(separator)[0].replace(\"' \", \"':\").split(\":\")}}" + # CentOS 6 uses an older version of jinja that does not provide the selectattr filter. + when: ansible_distribution != 'CentOS' or ansible_distribution_major_version != '6' + + - name: modify user with TLS requirements state=present (expect changed=true) + mysql_user: + name: '{{ user_name_1 }}' + password: '{{ user_password_1 }}' + tls_requires: + X509: + login_unix_socket: '{{ mysql_socket }}' + + - name: retrieve TLS requiremets for users in old database version + command: mysql -L -N -s -e "SHOW GRANTS for '{{ user_name_1 }}'@'localhost'" + register: old_result + when: db_version.version.major <= 5 and db_version.version.minor <= 6 or db_version.version.major == 10 and db_version.version.minor < 2 + + - name: retrieve TLS requiremets for users in new database version + command: mysql -L -N -s -e "SHOW CREATE USER '{{ user_name_1 }}'@'localhost'" + register: new_result + when: db_version.version.major >= 5 and db_version.version.major < 10 and db_version.version.minor > 6 or db_version.version.major == 10 and db_version.version.minor >= 2 + + - name: assert user1 TLS requirements + assert: + that: "'X509' in reqs" + vars: + - reqs: "{{(old_result is skipped | ternary(new_result, old_result)).stdout.split('REQUIRE')[1].split(separator)[0].strip()}}" + + + - include: remove_user.yml user_name={{user_name_1}} user_password={{ user_password_1 }} + + - include: remove_user.yml user_name={{user_name_2}} user_password={{ user_password_1 }} + + - include: remove_user.yml user_name={{user_name_3}} user_password={{ user_password_1 }} + + - include: assert_no_user.yml user_name={{user_name_1}} + + - include: assert_no_user.yml user_name={{user_name_2}} + + - include: assert_no_user.yml user_name={{user_name_3}} + # ============================================================ # Assert user has access to multiple databases # From cde29d2e2b46596e4154d78c264ed391f7850a23 Mon Sep 17 00:00:00 2001 From: Jorge-Rodriguez Date: Fri, 24 Jul 2020 09:49:28 +0300 Subject: [PATCH 02/12] Add check mode tests --- .../targets/test_mysql_user/tasks/main.yml | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/tests/integration/targets/test_mysql_user/tasks/main.yml b/tests/integration/targets/test_mysql_user/tasks/main.yml index ad28a350..0bbe5180 100644 --- a/tests/integration/targets/test_mysql_user/tasks/main.yml +++ b/tests/integration/targets/test_mysql_user/tasks/main.yml @@ -138,6 +138,23 @@ filter: version register: db_version + - name: create user with TLS requirements in check mode (expect changed=true) + mysql_user: + <<: *mysql_params + name: "{{ user_name_1 }}" + password: "{{ user_password_1 }}" + tls_requires: + SSL: + check_mode: yes + register: result + + - name: Assert check mode user create reports changed state + assert: + that: + - result is changed + + - include: assert_no_user.yml user_name={{user_name_1}} + - name: create user with TLS requirements state=present (expect changed=true) mysql_user: <<: *mysql_params @@ -201,13 +218,44 @@ # CentOS 6 uses an older version of jinja that does not provide the selectattr filter. when: ansible_distribution != 'CentOS' or ansible_distribution_major_version != '6' + - name: modify user with TLS requirements state=present in check mode (expect changed=true) + mysql_user: + <<: *mysql_params + name: '{{ user_name_1 }}' + password: '{{ user_password_1 }}' + tls_requires: + X509: + check_mode: yes + register: result + + - name: Assert check mode user update reports changed state + assert: + that: + - result is changed + + - name: retrieve TLS requiremets for users in old database version + command: mysql -L -N -s -e "SHOW GRANTS for '{{ user_name_1 }}'@'localhost'" + register: old_result + when: db_version.version.major <= 5 and db_version.version.minor <= 6 or db_version.version.major == 10 and db_version.version.minor < 2 + + - name: retrieve TLS requiremets for users in new database version + command: mysql -L -N -s -e "SHOW CREATE USER '{{ user_name_1 }}'@'localhost'" + register: new_result + when: db_version.version.major >= 5 and db_version.version.major < 10 and db_version.version.minor > 6 or db_version.version.major == 10 and db_version.version.minor >= 2 + + - name: assert user1 TLS requirements was not changed + assert: + that: "'SSL' in reqs" + vars: + - reqs: "{{(old_result is skipped | ternary(new_result, old_result)).stdout.split('REQUIRE')[1].split(separator)[0].strip()}}" + - name: modify user with TLS requirements state=present (expect changed=true) mysql_user: + <<: *mysql_params name: '{{ user_name_1 }}' password: '{{ user_password_1 }}' tls_requires: X509: - login_unix_socket: '{{ mysql_socket }}' - name: retrieve TLS requiremets for users in old database version command: mysql -L -N -s -e "SHOW GRANTS for '{{ user_name_1 }}'@'localhost'" From 7643a37970298c6592c0d26878395b54c9287319 Mon Sep 17 00:00:00 2001 From: "Jorge Rodriguez (A.K.A. Tiriel)" Date: Fri, 24 Jul 2020 19:12:55 +0300 Subject: [PATCH 03/12] Fix check mode indentation --- tests/integration/targets/test_mysql_user/tasks/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/targets/test_mysql_user/tasks/main.yml b/tests/integration/targets/test_mysql_user/tasks/main.yml index 0bbe5180..d1421352 100644 --- a/tests/integration/targets/test_mysql_user/tasks/main.yml +++ b/tests/integration/targets/test_mysql_user/tasks/main.yml @@ -145,7 +145,7 @@ password: "{{ user_password_1 }}" tls_requires: SSL: - check_mode: yes + check_mode: yes register: result - name: Assert check mode user create reports changed state @@ -225,7 +225,7 @@ password: '{{ user_password_1 }}' tls_requires: X509: - check_mode: yes + check_mode: yes register: result - name: Assert check mode user update reports changed state From e7e0354ae6f43126539e5937c10180fbbb0e9ffc Mon Sep 17 00:00:00 2001 From: "Jorge Rodriguez (A.K.A. Tiriel)" Date: Fri, 24 Jul 2020 19:32:48 +0300 Subject: [PATCH 04/12] Run MySQL commands with the mysql_command variable --- tests/integration/targets/test_mysql_user/tasks/main.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/targets/test_mysql_user/tasks/main.yml b/tests/integration/targets/test_mysql_user/tasks/main.yml index d1421352..1aa78c2f 100644 --- a/tests/integration/targets/test_mysql_user/tasks/main.yml +++ b/tests/integration/targets/test_mysql_user/tasks/main.yml @@ -234,12 +234,12 @@ - result is changed - name: retrieve TLS requiremets for users in old database version - command: mysql -L -N -s -e "SHOW GRANTS for '{{ user_name_1 }}'@'localhost'" + command: "{{ mysql_comnand }} -L -N -s -e \"SHOW GRANTS for '{{ user_name_1 }}'@'localhost'\"" register: old_result when: db_version.version.major <= 5 and db_version.version.minor <= 6 or db_version.version.major == 10 and db_version.version.minor < 2 - name: retrieve TLS requiremets for users in new database version - command: mysql -L -N -s -e "SHOW CREATE USER '{{ user_name_1 }}'@'localhost'" + command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\"" register: new_result when: db_version.version.major >= 5 and db_version.version.major < 10 and db_version.version.minor > 6 or db_version.version.major == 10 and db_version.version.minor >= 2 @@ -258,12 +258,12 @@ X509: - name: retrieve TLS requiremets for users in old database version - command: mysql -L -N -s -e "SHOW GRANTS for '{{ user_name_1 }}'@'localhost'" + command: "{{ mysql_command }} -L -N -s -e \"SHOW GRANTS for '{{ user_name_1 }}'@'localhost'\"" register: old_result when: db_version.version.major <= 5 and db_version.version.minor <= 6 or db_version.version.major == 10 and db_version.version.minor < 2 - name: retrieve TLS requiremets for users in new database version - command: mysql -L -N -s -e "SHOW CREATE USER '{{ user_name_1 }}'@'localhost'" + command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\"" register: new_result when: db_version.version.major >= 5 and db_version.version.major < 10 and db_version.version.minor > 6 or db_version.version.major == 10 and db_version.version.minor >= 2 From 226158a74a947e2897a52828bf5495a9a360c634 Mon Sep 17 00:00:00 2001 From: Jorge-Rodriguez Date: Sat, 25 Jul 2020 13:01:59 +0300 Subject: [PATCH 05/12] Fix typo --- tests/integration/targets/test_mysql_user/tasks/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/targets/test_mysql_user/tasks/main.yml b/tests/integration/targets/test_mysql_user/tasks/main.yml index 1aa78c2f..2d72c7f4 100644 --- a/tests/integration/targets/test_mysql_user/tasks/main.yml +++ b/tests/integration/targets/test_mysql_user/tasks/main.yml @@ -234,7 +234,7 @@ - result is changed - name: retrieve TLS requiremets for users in old database version - command: "{{ mysql_comnand }} -L -N -s -e \"SHOW GRANTS for '{{ user_name_1 }}'@'localhost'\"" + command: "{{ mysql_command }} -L -N -s -e \"SHOW GRANTS for '{{ user_name_1 }}'@'localhost'\"" register: old_result when: db_version.version.major <= 5 and db_version.version.minor <= 6 or db_version.version.major == 10 and db_version.version.minor < 2 From 5b5fe8e628699adc478a8de0732a6e0e15b466f0 Mon Sep 17 00:00:00 2001 From: Jorge-Rodriguez Date: Sat, 25 Jul 2020 13:31:58 +0300 Subject: [PATCH 06/12] Restore code lost during cherry pick --- plugins/modules/mysql_user.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/plugins/modules/mysql_user.py b/plugins/modules/mysql_user.py index 5fe22a3c..63eabcca 100644 --- a/plugins/modules/mysql_user.py +++ b/plugins/modules/mysql_user.py @@ -521,12 +521,26 @@ def user_add( if check_mode: return True + # Determine what user management method server uses + old_user_mgmt = use_old_user_mgmt(cursor) + mogrify = mogrify_requires if server_suports_requires_create(cursor) else do_not_mogrify_requires if password and encrypted: cursor.execute(*mogrify("CREATE USER %s@%s IDENTIFIED BY PASSWORD %s", (user, host, password), tls_requires)) elif password and not encrypted: - cursor.execute(*mogrify("CREATE USER %s@%s IDENTIFIED BY %s", (user, host, password), tls_requires)) + if old_user_mgmt: + cursor.execute(*mogrify("CREATE USER %s@%s IDENTIFIED BY %s", (user, host, password), tls_requires)) + else: + cursor.execute("SELECT CONCAT('*', UCASE(SHA1(UNHEX(SHA1(%s)))))", (password,)) + encrypted_password = cursor.fetchone()[0] + cursor.execute( + *mogrify( + "CREATE USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", + (user, host, encrypted_password), + tls_requires, + ) + ) elif plugin and plugin_hash_string: cursor.execute( *mogrify( @@ -659,7 +673,6 @@ def user_mod( "UPDATE mysql.user SET plugin = %s, authentication_string = %s, Password = '' WHERE User = %s AND Host = %s", ("mysql_native_password", encrypted_password, user, host), ) - cursor.execute("GRANT USAGE on *.* to '%s'@'%s'", (user, host)) cursor.execute("FLUSH PRIVILEGES") msg = "Password forced update" else: From 4b4c06b4f77411782e5a62d68b8d04cfac7d6fcc Mon Sep 17 00:00:00 2001 From: Jorge-Rodriguez Date: Sat, 25 Jul 2020 18:34:10 +0300 Subject: [PATCH 07/12] Fix conditionals to accomodate for MySQL v8 --- tests/integration/targets/test_mysql_user/tasks/main.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/integration/targets/test_mysql_user/tasks/main.yml b/tests/integration/targets/test_mysql_user/tasks/main.yml index 2d72c7f4..d452c8cc 100644 --- a/tests/integration/targets/test_mysql_user/tasks/main.yml +++ b/tests/integration/targets/test_mysql_user/tasks/main.yml @@ -179,6 +179,7 @@ - name: set old database separator set_fact: separator: '\n' + # Semantically: when mysql version <= 5.6 or MariaDB version <= 10.1 when: db_version.version.major <= 5 and db_version.version.minor <= 6 or db_version.version.major == 10 and db_version.version.minor < 2 - block: @@ -190,7 +191,8 @@ - name: set new database separator set_fact: separator: 'PASSWORD' - when: db_version.version.major >= 5 and db_version.version.major < 10 and db_version.version.minor > 6 or db_version.version.major == 10 and db_version.version.minor >= 2 + # Semantically: when mysql version >= 5.7 or MariaDB version >= 10.2 + when: db_version.version.major = 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 - block: - name: assert user1 TLS requirements @@ -241,7 +243,7 @@ - name: retrieve TLS requiremets for users in new database version command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\"" register: new_result - when: db_version.version.major >= 5 and db_version.version.major < 10 and db_version.version.minor > 6 or db_version.version.major == 10 and db_version.version.minor >= 2 + when: db_version.version.major = 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 - name: assert user1 TLS requirements was not changed assert: @@ -265,7 +267,7 @@ - name: retrieve TLS requiremets for users in new database version command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\"" register: new_result - when: db_version.version.major >= 5 and db_version.version.major < 10 and db_version.version.minor > 6 or db_version.version.major == 10 and db_version.version.minor >= 2 + when: db_version.version.major = 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 - name: assert user1 TLS requirements assert: From 69d3f8b3c2ebeb4bd52c4e493a5e656ea3cfaad8 Mon Sep 17 00:00:00 2001 From: "Jorge Rodriguez (A.K.A. Tiriel)" Date: Sat, 25 Jul 2020 19:16:48 +0300 Subject: [PATCH 08/12] Fix equal operators --- tests/integration/targets/test_mysql_user/tasks/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/targets/test_mysql_user/tasks/main.yml b/tests/integration/targets/test_mysql_user/tasks/main.yml index d452c8cc..0b06ace4 100644 --- a/tests/integration/targets/test_mysql_user/tasks/main.yml +++ b/tests/integration/targets/test_mysql_user/tasks/main.yml @@ -192,7 +192,7 @@ set_fact: separator: 'PASSWORD' # Semantically: when mysql version >= 5.7 or MariaDB version >= 10.2 - when: db_version.version.major = 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 + when: db_version.version.major == 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 - block: - name: assert user1 TLS requirements @@ -243,7 +243,7 @@ - name: retrieve TLS requiremets for users in new database version command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\"" register: new_result - when: db_version.version.major = 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 + when: db_version.version.major == 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 - name: assert user1 TLS requirements was not changed assert: @@ -267,7 +267,7 @@ - name: retrieve TLS requiremets for users in new database version command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\"" register: new_result - when: db_version.version.major = 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 + when: db_version.version.major == 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 - name: assert user1 TLS requirements assert: From 25441dfaaf6b8af1cada73512c9706fa28dee4e4 Mon Sep 17 00:00:00 2001 From: Jorge-Rodriguez Date: Sun, 26 Jul 2020 10:52:37 +0300 Subject: [PATCH 09/12] Remove Black formatting in an attempt to make codecov happy --- plugins/modules/mysql_user.py | 466 ++++++++++++---------------------- 1 file changed, 159 insertions(+), 307 deletions(-) diff --git a/plugins/modules/mysql_user.py b/plugins/modules/mysql_user.py index 63eabcca..810b3e3d 100644 --- a/plugins/modules/mysql_user.py +++ b/plugins/modules/mysql_user.py @@ -6,11 +6,10 @@ # 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 -DOCUMENTATION = r""" +DOCUMENTATION = r''' --- module: mysql_user short_description: Adds or removes a user from a MySQL database @@ -142,9 +141,9 @@ extends_documentation_fragment: - community.mysql.mysql -""" +''' -EXAMPLES = r""" +EXAMPLES = r''' - name: Removes anonymous user account for localhost community.mysql.mysql_user: name: '' @@ -286,109 +285,55 @@ # [client] # user=root # password=n<_665{vS43y -""" +''' import re import string from ansible.module_utils.basic import AnsibleModule from ansible_collections.community.mysql.plugins.module_utils.database import SQLParseError -from ansible_collections.community.mysql.plugins.module_utils.mysql import ( - mysql_connect, - mysql_driver, - mysql_driver_fail_msg, -) +from ansible_collections.community.mysql.plugins.module_utils.mysql import mysql_connect, mysql_driver, mysql_driver_fail_msg from ansible.module_utils.six import iteritems from ansible.module_utils._text import to_native -VALID_PRIVS = frozenset( - ( - "CREATE", - "DROP", - "GRANT", - "GRANT OPTION", - "LOCK TABLES", - "REFERENCES", - "EVENT", - "ALTER", - "DELETE", - "INDEX", - "INSERT", - "SELECT", - "UPDATE", - "CREATE TEMPORARY TABLES", - "TRIGGER", - "CREATE VIEW", - "SHOW VIEW", - "ALTER ROUTINE", - "CREATE ROUTINE", - "EXECUTE", - "FILE", - "CREATE TABLESPACE", - "CREATE USER", - "PROCESS", - "PROXY", - "RELOAD", - "REPLICATION CLIENT", - "REPLICATION SLAVE", - "SHOW DATABASES", - "SHUTDOWN", - "SUPER", - "ALL", - "ALL PRIVILEGES", - "USAGE", - "REQUIRESSL", - "CREATE ROLE", - "DROP ROLE", - "APPLICATION_PASSWORD_ADMIN", - "AUDIT_ADMIN", - "BACKUP_ADMIN", - "BINLOG_ADMIN", - "BINLOG_ENCRYPTION_ADMIN", - "CLONE_ADMIN", - "CONNECTION_ADMIN", - "ENCRYPTION_KEY_ADMIN", - "FIREWALL_ADMIN", - "FIREWALL_USER", - "GROUP_REPLICATION_ADMIN", - "INNODB_REDO_LOG_ARCHIVE", - "NDB_STORED_USER", - "PERSIST_RO_VARIABLES_ADMIN", - "REPLICATION_APPLIER", - "REPLICATION_SLAVE_ADMIN", - "RESOURCE_GROUP_ADMIN", - "RESOURCE_GROUP_USER", - "ROLE_ADMIN", - "SESSION_VARIABLES_ADMIN", - "SET_USER_ID", - "SYSTEM_USER", - "SYSTEM_VARIABLES_ADMIN", - "SYSTEM_USER", - "TABLE_ENCRYPTION_ADMIN", - "VERSION_TOKEN_ADMIN", - "XA_RECOVER_ADMIN", - "LOAD FROM S3", - "SELECT INTO S3", - "INVOKE LAMBDA", - "ALTER ROUTINE", - "BINLOG ADMIN", - "BINLOG MONITOR", - "BINLOG REPLAY", - "CONNECTION ADMIN", - "READ_ONLY ADMIN", - "REPLICATION MASTER ADMIN", - "REPLICATION SLAVE", - "REPLICATION SLAVE ADMIN", - "SET USER", - ) -) +VALID_PRIVS = frozenset(('CREATE', 'DROP', 'GRANT', 'GRANT OPTION', + 'LOCK TABLES', 'REFERENCES', 'EVENT', 'ALTER', + 'DELETE', 'INDEX', 'INSERT', 'SELECT', 'UPDATE', + 'CREATE TEMPORARY TABLES', 'TRIGGER', 'CREATE VIEW', + 'SHOW VIEW', 'ALTER ROUTINE', 'CREATE ROUTINE', + 'EXECUTE', 'FILE', 'CREATE TABLESPACE', 'CREATE USER', + 'PROCESS', 'PROXY', 'RELOAD', 'REPLICATION CLIENT', + 'REPLICATION SLAVE', 'SHOW DATABASES', 'SHUTDOWN', + 'SUPER', 'ALL', 'ALL PRIVILEGES', 'USAGE', 'REQUIRESSL', + 'CREATE ROLE', 'DROP ROLE', 'APPLICATION_PASSWORD_ADMIN', + 'AUDIT_ADMIN', 'BACKUP_ADMIN', 'BINLOG_ADMIN', + 'BINLOG_ENCRYPTION_ADMIN', 'CLONE_ADMIN', 'CONNECTION_ADMIN', + 'ENCRYPTION_KEY_ADMIN', 'FIREWALL_ADMIN', 'FIREWALL_USER', + 'GROUP_REPLICATION_ADMIN', 'INNODB_REDO_LOG_ARCHIVE', + 'NDB_STORED_USER', 'PERSIST_RO_VARIABLES_ADMIN', + 'REPLICATION_APPLIER', 'REPLICATION_SLAVE_ADMIN', + 'RESOURCE_GROUP_ADMIN', 'RESOURCE_GROUP_USER', + 'ROLE_ADMIN', 'SESSION_VARIABLES_ADMIN', 'SET_USER_ID', + 'SYSTEM_USER', 'SYSTEM_VARIABLES_ADMIN', 'SYSTEM_USER', + 'TABLE_ENCRYPTION_ADMIN', 'VERSION_TOKEN_ADMIN', + 'XA_RECOVER_ADMIN', 'LOAD FROM S3', 'SELECT INTO S3', + 'INVOKE LAMBDA', + 'ALTER ROUTINE', + 'BINLOG ADMIN', + 'BINLOG MONITOR', + 'BINLOG REPLAY', + 'CONNECTION ADMIN', + 'READ_ONLY ADMIN', + 'REPLICATION MASTER ADMIN', + 'REPLICATION SLAVE', + 'REPLICATION SLAVE ADMIN', + 'SET USER',)) class InvalidPrivsError(Exception): pass - # =========================================== # MySQL module specific support methods. # @@ -399,9 +344,9 @@ def use_old_user_mgmt(cursor): cursor.execute("SELECT VERSION()") result = cursor.fetchone() version_str = result[0] - version = version_str.split(".") + version = version_str.split('.') - if "mariadb" in version_str.lower(): + if 'mariadb' in version_str.lower(): # Prior to MariaDB 10.2 if int(version[0]) * 1000 + int(version[1]) < 10002: return True @@ -416,13 +361,13 @@ def use_old_user_mgmt(cursor): def get_mode(cursor): - cursor.execute("SELECT @@GLOBAL.sql_mode") + cursor.execute('SELECT @@GLOBAL.sql_mode') result = cursor.fetchone() mode_str = result[0] - if "ANSI" in mode_str: - mode = "ANSI" + if 'ANSI' in mode_str: + mode = 'ANSI' else: - mode = "NOTANSI" + mode = 'NOTANSI' return mode @@ -500,20 +445,9 @@ def get_grants(cursor, user, host): return grants.split(", ") -def user_add( - cursor, - user, - host, - host_all, - password, - encrypted, - plugin, - plugin_hash_string, - plugin_auth_string, - new_priv, - tls_requires, - check_mode, -): +def user_add(cursor, user, host, host_all, password, encrypted, + plugin, plugin_hash_string, plugin_auth_string, new_priv, + tls_requires, check_mode): # we cannot create users without a proper hostname if host_all: return False @@ -567,27 +501,15 @@ def user_add( def is_hash(password): ishash = False - if len(password) == 41 and password[0] == "*": + if len(password) == 41 and password[0] == '*': if frozenset(password[1:]).issubset(string.hexdigits): ishash = True return ishash -def user_mod( - cursor, - user, - host, - host_all, - password, - encrypted, - plugin, - plugin_hash_string, - plugin_auth_string, - new_priv, - append_privs, - tls_requires, - module, -): +def user_mod(cursor, user, host, host_all, password, encrypted, + plugin, plugin_hash_string, plugin_auth_string, new_priv, + append_privs, tls_requires, module): changed = False msg = "User unchanged" grant_option = False @@ -604,46 +526,36 @@ def user_mod( old_user_mgmt = use_old_user_mgmt(cursor) # Get a list of valid columns in mysql.user table to check if Password and/or authentication_string exist - cursor.execute( - """ + cursor.execute(""" SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'user' AND COLUMN_NAME IN ('Password', 'authentication_string') ORDER BY COLUMN_NAME DESC LIMIT 1 - """ - ) + """) colA = cursor.fetchone() - cursor.execute( - """ + cursor.execute(""" SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'user' AND COLUMN_NAME IN ('Password', 'authentication_string') ORDER BY COLUMN_NAME ASC LIMIT 1 - """ - ) + """) colB = cursor.fetchone() # Select hash from either Password or authentication_string, depending which one exists and/or is filled - cursor.execute( - """ + cursor.execute(""" SELECT COALESCE( CASE WHEN %s = '' THEN NULL ELSE %s END, CASE WHEN %s = '' THEN NULL ELSE %s END ) FROM mysql.user WHERE user = %%s AND host = %%s - """ - % (colA[0], colA[0], colB[0], colB[0]), - (user, host), - ) + """ % (colA[0], colA[0], colB[0], colB[0]), (user, host)) current_pass_hash = cursor.fetchone()[0] if isinstance(current_pass_hash, bytes): - current_pass_hash = current_pass_hash.decode("ascii") + current_pass_hash = current_pass_hash.decode('ascii') if encrypted: encrypted_password = password if not is_hash(encrypted_password): - module.fail_json( - msg="encrypted was specified however it does not appear to be a valid hash expecting: *SHA1(SHA1(your_password))" - ) + module.fail_json(msg="encrypted was specified however it does not appear to be a valid hash expecting: *SHA1(SHA1(your_password))") else: if old_user_mgmt: cursor.execute("SELECT PASSWORD(%s)", (password,)) @@ -660,10 +572,7 @@ def user_mod( msg = "Password updated (old style)" else: try: - cursor.execute( - "ALTER USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", - (user, host, encrypted_password), - ) + cursor.execute("ALTER USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", (user, host, encrypted_password)) msg = "Password updated (new style)" except (mysql_driver.Error) as e: # https://stackoverflow.com/questions/51600000/authentication-string-of-root-user-on-mysql @@ -671,7 +580,7 @@ def user_mod( if e.args[0] == 1396: cursor.execute( "UPDATE mysql.user SET plugin = %s, authentication_string = %s, Password = '' WHERE User = %s AND Host = %s", - ("mysql_native_password", encrypted_password, user, host), + ('mysql_native_password', encrypted_password, user, host) ) cursor.execute("FLUSH PRIVILEGES") msg = "Password forced update" @@ -681,9 +590,8 @@ def user_mod( # Handle plugin authentication if plugin: - cursor.execute( - "SELECT plugin, authentication_string FROM mysql.user " "WHERE user = %s AND host = %s", (user, host) - ) + cursor.execute("SELECT plugin, authentication_string FROM mysql.user " + "WHERE user = %s AND host = %s", (user, host)) current_plugin = cursor.fetchone() update = False @@ -703,13 +611,9 @@ def user_mod( if update: if plugin_hash_string: - cursor.execute( - "ALTER USER %s@%s IDENTIFIED WITH %s AS %s", (user, host, plugin, plugin_hash_string) - ) + cursor.execute("ALTER USER %s@%s IDENTIFIED WITH %s AS %s", (user, host, plugin, plugin_hash_string)) elif plugin_auth_string: - cursor.execute( - "ALTER USER %s@%s IDENTIFIED WITH %s BY %s", (user, host, plugin, plugin_auth_string) - ) + cursor.execute("ALTER USER %s@%s IDENTIFIED WITH %s BY %s", (user, host, plugin, plugin_auth_string)) else: cursor.execute("ALTER USER %s@%s IDENTIFIED WITH %s", (user, host, plugin)) changed = True @@ -819,23 +723,21 @@ def privileges_get(cursor, user, host): grants = cursor.fetchall() def pick(x): - if x == "ALL PRIVILEGES": - return "ALL" + if x == 'ALL PRIVILEGES': + return 'ALL' else: return x for grant in grants: - res = re.match( - """GRANT (.+) ON (.+) TO (['`"]).*\\3@(['`"]).*\\4( IDENTIFIED BY PASSWORD (['`"]).+\\6)? ?(.*)""", grant[0] - ) + res = re.match("""GRANT (.+) ON (.+) TO (['`"]).*\\3@(['`"]).*\\4( IDENTIFIED BY PASSWORD (['`"]).+\\6)? ?(.*)""", grant[0]) if res is None: - raise InvalidPrivsError("unable to parse the MySQL grant string: %s" % grant[0]) + raise InvalidPrivsError('unable to parse the MySQL grant string: %s' % grant[0]) privileges = res.group(1).split(",") privileges = [pick(x.strip()) for x in privileges] if "WITH GRANT OPTION" in res.group(7): - privileges.append("GRANT") - if "REQUIRE SSL" in res.group(7): - privileges.append("REQUIRESSL") + privileges.append('GRANT') + if 'REQUIRE SSL' in res.group(7): + privileges.append('REQUIRESSL') db = res.group(2) output.setdefault(db, []).extend(privileges) return output @@ -852,83 +754,83 @@ def privileges_unpack(priv, mode): The privilege USAGE stands for no privileges, so we add that in on *.* if it's not specified in the string, as MySQL will always provide this by default. """ - if mode == "ANSI": + if mode == 'ANSI': quote = '"' else: - quote = "`" + quote = '`' output = {} privs = [] - for item in priv.strip().split("/"): - pieces = item.strip().rsplit(":", 1) + for item in priv.strip().split('/'): + pieces = item.strip().rsplit(':', 1) dbpriv = pieces[0].rsplit(".", 1) # Check for FUNCTION or PROCEDURE object types parts = dbpriv[0].split(" ", 1) - object_type = "" - if len(parts) > 1 and (parts[0] == "FUNCTION" or parts[0] == "PROCEDURE"): - object_type = parts[0] + " " + object_type = '' + if len(parts) > 1 and (parts[0] == 'FUNCTION' or parts[0] == 'PROCEDURE'): + object_type = parts[0] + ' ' dbpriv[0] = parts[1] # Do not escape if privilege is for database or table, i.e. # neither quote *. nor .* for i, side in enumerate(dbpriv): - if side.strip("`") != "*": - dbpriv[i] = "%s%s%s" % (quote, side.strip("`"), quote) - pieces[0] = object_type + ".".join(dbpriv) + if side.strip('`') != '*': + dbpriv[i] = '%s%s%s' % (quote, side.strip('`'), quote) + pieces[0] = object_type + '.'.join(dbpriv) - if "(" in pieces[1]: - output[pieces[0]] = re.split(r",\s*(?=[^)]*(?:\(|$))", pieces[1].upper()) + if '(' in pieces[1]: + output[pieces[0]] = re.split(r',\s*(?=[^)]*(?:\(|$))', pieces[1].upper()) for i in output[pieces[0]]: - privs.append(re.sub(r"\s*\(.*\)", "", i)) + privs.append(re.sub(r'\s*\(.*\)', '', i)) else: - output[pieces[0]] = pieces[1].upper().split(",") + output[pieces[0]] = pieces[1].upper().split(',') privs = output[pieces[0]] new_privs = frozenset(privs) if not new_privs.issubset(VALID_PRIVS): - raise InvalidPrivsError("Invalid privileges specified: %s" % new_privs.difference(VALID_PRIVS)) + raise InvalidPrivsError('Invalid privileges specified: %s' % new_privs.difference(VALID_PRIVS)) - if "*.*" not in output: - output["*.*"] = ["USAGE"] + if '*.*' not in output: + output['*.*'] = ['USAGE'] # if we are only specifying something like REQUIRESSL and/or GRANT (=WITH GRANT OPTION) in *.* # we still need to add USAGE as a privilege to avoid syntax errors - if "REQUIRESSL" in priv and not set(output["*.*"]).difference(set(["GRANT", "REQUIRESSL"])): - output["*.*"].append("USAGE") + if 'REQUIRESSL' in priv and not set(output['*.*']).difference(set(['GRANT', 'REQUIRESSL'])): + output['*.*'].append('USAGE') return output def privileges_revoke(cursor, user, host, db_table, priv, grant_option): # Escape '%' since mysql db.execute() uses a format string - db_table = db_table.replace("%", "%%") + db_table = db_table.replace('%', '%%') if grant_option: query = ["REVOKE GRANT OPTION ON %s" % db_table] query.append("FROM %s@%s") - query = " ".join(query) + query = ' '.join(query) cursor.execute(query, (user, host)) - priv_string = ",".join([p for p in priv if p not in ("GRANT", "REQUIRESSL")]) + priv_string = ",".join([p for p in priv if p not in ('GRANT', 'REQUIRESSL')]) query = ["REVOKE %s ON %s" % (priv_string, db_table)] query.append("FROM %s@%s") - query = " ".join(query) + query = ' '.join(query) cursor.execute(query, (user, host)) def privileges_grant(cursor, user, host, db_table, priv, tls_requires): # Escape '%' since mysql db.execute uses a format string and the # specification of db and table often use a % (SQL wildcard) - db_table = db_table.replace("%", "%%") - priv_string = ",".join([p for p in priv if p not in ("GRANT", "REQUIRESSL")]) + db_table = db_table.replace('%', '%%') + priv_string = ",".join([p for p in priv if p not in ('GRANT', 'REQUIRESSL')]) query = ["GRANT %s ON %s" % (priv_string, db_table)] query.append("TO %s@%s") params = (user, host) if tls_requires and not server_suports_requires_create(cursor): query, params = mogrify_requires(" ".join(query), params, tls_requires) query = [query] - if "REQUIRESSL" in priv and not tls_requires: + if 'REQUIRESSL' in priv and not tls_requires: query.append("REQUIRE SSL") - if "GRANT" in priv: + if 'GRANT' in priv: query.append("WITH GRANT OPTION") - query = " ".join(query) + query = ' '.join(query) cursor.execute(query, params) @@ -941,9 +843,9 @@ def convert_priv_dict_to_str(priv): Returns: priv (str): String representation of input argument. """ - priv_list = ["%s:%s" % (key, val) for key, val in iteritems(priv)] + priv_list = ['%s:%s' % (key, val) for key, val in iteritems(priv)] - return "/".join(priv_list) + return '/'.join(priv_list) # TLS requires on user create statement is supported since MySQL 5.7 and MariaDB 10.2 @@ -984,9 +886,9 @@ def server_supports_alter_user(cursor): """ cursor.execute("SELECT VERSION()") version_str = cursor.fetchone()[0] - version = version_str.split(".") + version = version_str.split('.') - if "mariadb" in version_str.lower(): + if 'mariadb' in version_str.lower(): # MariaDB 10.2 and later if int(version[0]) * 1000 + int(version[1]) >= 10002: return True @@ -1011,13 +913,11 @@ def get_resource_limits(cursor, user, host): Returns: Dictionary containing current resource limits. """ - query = ( - "SELECT max_questions AS MAX_QUERIES_PER_HOUR, " - "max_updates AS MAX_UPDATES_PER_HOUR, " - "max_connections AS MAX_CONNECTIONS_PER_HOUR, " - "max_user_connections AS MAX_USER_CONNECTIONS " - "FROM mysql.user WHERE User = %s AND Host = %s" - ) + query = ('SELECT max_questions AS MAX_QUERIES_PER_HOUR, ' + 'max_updates AS MAX_UPDATES_PER_HOUR, ' + 'max_connections AS MAX_CONNECTIONS_PER_HOUR, ' + 'max_user_connections AS MAX_USER_CONNECTIONS ' + 'FROM mysql.user WHERE User = %s AND Host = %s') cursor.execute(query, (user, host)) res = cursor.fetchone() @@ -1025,10 +925,10 @@ def get_resource_limits(cursor, user, host): return None current_limits = { - "MAX_QUERIES_PER_HOUR": res[0], - "MAX_UPDATES_PER_HOUR": res[1], - "MAX_CONNECTIONS_PER_HOUR": res[2], - "MAX_USER_CONNECTIONS": res[3], + 'MAX_QUERIES_PER_HOUR': res[0], + 'MAX_UPDATES_PER_HOUR': res[1], + 'MAX_CONNECTIONS_PER_HOUR': res[2], + 'MAX_USER_CONNECTIONS': res[3], } return current_limits @@ -1083,10 +983,8 @@ def limit_resources(module, cursor, user, host, resource_limits, check_mode): Returns: True, if changed, False otherwise. """ if not server_supports_alter_user(cursor): - module.fail_json( - msg="The server version does not match the requirements " - "for resource_limits parameter. See module's documentation." - ) + module.fail_json(msg="The server version does not match the requirements " + "for resource_limits parameter. See module's documentation.") current_limits = get_resource_limits(cursor, user, host) @@ -1101,10 +999,10 @@ def limit_resources(module, cursor, user, host, resource_limits, check_mode): # If not check_mode tmp = [] for key, val in iteritems(needs_to_change): - tmp.append("%s %s" % (key, val)) + tmp.append('%s %s' % (key, val)) query = "ALTER USER %s@%s" - query += " WITH %s" % " ".join(tmp) + query += ' WITH %s' % ' '.join(tmp) cursor.execute(query, (user, host)) return True @@ -1117,32 +1015,32 @@ def limit_resources(module, cursor, user, host, resource_limits, check_mode): def main(): module = AnsibleModule( argument_spec=dict( - login_user=dict(type="str"), - login_password=dict(type="str", no_log=True), - login_host=dict(type="str", default="localhost"), - login_port=dict(type="int", default=3306), - login_unix_socket=dict(type="str"), - user=dict(type="str", required=True, aliases=["name"]), - password=dict(type="str", no_log=True), - encrypted=dict(type="bool", default=False), - host=dict(type="str", default="localhost"), + login_user=dict(type='str'), + login_password=dict(type='str', no_log=True), + login_host=dict(type='str', default='localhost'), + login_port=dict(type='int', default=3306), + login_unix_socket=dict(type='str'), + user=dict(type='str', required=True, aliases=['name']), + password=dict(type='str', no_log=True), + encrypted=dict(type='bool', default=False), + host=dict(type='str', default='localhost'), host_all=dict(type="bool", default=False), - state=dict(type="str", default="present", choices=["absent", "present"]), - priv=dict(type="raw"), - tls_requires=dict(type="dict"), - append_privs=dict(type="bool", default=False), - check_implicit_admin=dict(type="bool", default=False), - update_password=dict(type="str", default="always", choices=["always", "on_create"], no_log=False), - connect_timeout=dict(type="int", default=30), - config_file=dict(type="path", default="~/.my.cnf"), - sql_log_bin=dict(type="bool", default=True), - client_cert=dict(type="path", aliases=["ssl_cert"]), - client_key=dict(type="path", aliases=["ssl_key"]), - ca_cert=dict(type="path", aliases=["ssl_ca"]), - plugin=dict(default=None, type="str"), - plugin_hash_string=dict(default=None, type="str"), - plugin_auth_string=dict(default=None, type="str"), - resource_limits=dict(type="dict"), + state=dict(type='str', default='present', choices=['absent', 'present']), + priv=dict(type='raw'), + tls_requires=dict(type='dict'), + append_privs=dict(type='bool', default=False), + check_implicit_admin=dict(type='bool', default=False), + update_password=dict(type='str', default='always', choices=['always', 'on_create'], no_log=False), + connect_timeout=dict(type='int', default=30), + config_file=dict(type='path', default='~/.my.cnf'), + sql_log_bin=dict(type='bool', default=True), + client_cert=dict(type='path', aliases=['ssl_cert']), + client_key=dict(type='path', aliases=['ssl_key']), + ca_cert=dict(type='path', aliases=['ssl_ca']), + plugin=dict(default=None, type='str'), + plugin_hash_string=dict(default=None, type='str'), + plugin_auth_string=dict(default=None, type='str'), + resource_limits=dict(type='dict'), ), supports_check_mode=True, ) @@ -1160,11 +1058,11 @@ def main(): connect_timeout = module.params["connect_timeout"] config_file = module.params["config_file"] append_privs = module.boolean(module.params["append_privs"]) - update_password = module.params["update_password"] + update_password = module.params['update_password'] ssl_cert = module.params["client_cert"] ssl_key = module.params["client_key"] ssl_ca = module.params["ca_cert"] - db = "" + db = '' sql_log_bin = module.params["sql_log_bin"] plugin = module.params["plugin"] plugin_hash_string = module.params["plugin_hash_string"] @@ -1183,29 +1081,18 @@ def main(): try: if check_implicit_admin: try: - cursor, db_conn = mysql_connect( - module, "root", "", config_file, ssl_cert, ssl_key, ssl_ca, db, connect_timeout=connect_timeout - ) + cursor, db_conn = mysql_connect(module, "root", "", config_file, ssl_cert, ssl_key, ssl_ca, db, + connect_timeout=connect_timeout) except Exception: pass if not cursor: - cursor, db_conn = mysql_connect( - module, - login_user, - login_password, - config_file, - ssl_cert, - ssl_key, - ssl_ca, - db, - connect_timeout=connect_timeout, + cursor, db_conn = mysql_connect(module, login_user, login_password, config_file, ssl_cert, ssl_key, ssl_ca, db, + connect_timeout=connect_timeout, ) except Exception as e: - module.fail_json( - msg="unable to connect to database, check login_user and login_password are correct or %s has the credentials. " - "Exception message: %s" % (config_file, to_native(e)) - ) + module.fail_json(msg="unable to connect to database, check login_user and login_password are correct or %s has the credentials. " + "Exception message: %s" % (config_file, to_native(e))) if not sql_log_bin: cursor.execute("SET SQL_LOG_BIN=0;") @@ -1224,37 +1111,13 @@ def main(): if user_exists(cursor, user, host, host_all): try: if update_password == "always": - changed, msg = user_mod( - cursor, - user, - host, - host_all, - password, - encrypted, - plugin, - plugin_hash_string, - plugin_auth_string, - priv, - append_privs, - tls_requires, - module, - ) + changed, msg = user_mod(cursor, user, host, host_all, password, encrypted, + plugin, plugin_hash_string, plugin_auth_string, + priv, append_privs, tls_requires, module) else: - changed, msg = user_mod( - cursor, - user, - host, - host_all, - None, - encrypted, - plugin, - plugin_hash_string, - plugin_auth_string, - priv, - append_privs, - tls_requires, - module, - ) + changed, msg = user_mod(cursor, user, host, host_all, None, encrypted, + plugin, plugin_hash_string, plugin_auth_string, + priv, append_privs, tls_requires, module) except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e: module.fail_json(msg=to_native(e)) @@ -1262,20 +1125,9 @@ def main(): if host_all: module.fail_json(msg="host_all parameter cannot be used when adding a user") try: - changed = user_add( - cursor, - user, - host, - host_all, - password, - encrypted, - plugin, - plugin_hash_string, - plugin_auth_string, - priv, - tls_requires, - module.check_mode, - ) + changed = user_add(cursor, user, host, host_all, password, encrypted, + plugin, plugin_hash_string, plugin_auth_string, + priv, tls_requires, module.check_mode) if changed: msg = "User added" @@ -1295,5 +1147,5 @@ def main(): module.exit_json(changed=changed, user=user, msg=msg) -if __name__ == "__main__": +if __name__ == '__main__': main() From 91fe5b4cb442f6bd5dfa9a45f84121553d6f0030 Mon Sep 17 00:00:00 2001 From: Jorge-Rodriguez Date: Sun, 26 Jul 2020 11:05:04 +0300 Subject: [PATCH 10/12] Remove deprecation notice --- changelogs/fragments/369-mysql_user_add_tls_requires.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/changelogs/fragments/369-mysql_user_add_tls_requires.yml b/changelogs/fragments/369-mysql_user_add_tls_requires.yml index 6a479ec4..10d39775 100644 --- a/changelogs/fragments/369-mysql_user_add_tls_requires.yml +++ b/changelogs/fragments/369-mysql_user_add_tls_requires.yml @@ -1,4 +1,2 @@ minor_changes: - - mysql_user - add TLS REQUIRES parameters (https://github.com/ansible-collections/community.general/pull/369). -deprecated_features: - - mysql_user - using ``REQUIRESSL`` in ``priv`` is deprecated in favor of ``tls_requires`` (https://github.com/ansible-collections/community.general/pull/369). + - mysql_user - add TLS REQUIRES parameters (https://github.com/ansible-collections/community.mysql/pull/9). From b6e42168d50ccdfe848b902953f5096eb5da4e5e Mon Sep 17 00:00:00 2001 From: Jorge-Rodriguez Date: Sun, 26 Jul 2020 11:07:21 +0300 Subject: [PATCH 11/12] Fix closing bracket --- plugins/modules/mysql_user.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/modules/mysql_user.py b/plugins/modules/mysql_user.py index 810b3e3d..c55d9f99 100644 --- a/plugins/modules/mysql_user.py +++ b/plugins/modules/mysql_user.py @@ -1088,8 +1088,7 @@ def main(): if not cursor: cursor, db_conn = mysql_connect(module, login_user, login_password, config_file, ssl_cert, ssl_key, ssl_ca, db, - connect_timeout=connect_timeout, - ) + connect_timeout=connect_timeout) except Exception as e: module.fail_json(msg="unable to connect to database, check login_user and login_password are correct or %s has the credentials. " "Exception message: %s" % (config_file, to_native(e))) From c62b4306f332fbcbb771f39733b1c280b818403d Mon Sep 17 00:00:00 2001 From: Jorge-Rodriguez Date: Mon, 27 Jul 2020 20:36:24 +0300 Subject: [PATCH 12/12] Remove code duplication --- plugins/modules/mysql_user.py | 35 ++++------------------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/plugins/modules/mysql_user.py b/plugins/modules/mysql_user.py index c55d9f99..9b1aca86 100644 --- a/plugins/modules/mysql_user.py +++ b/plugins/modules/mysql_user.py @@ -418,7 +418,7 @@ def do_not_mogrify_requires(query, params, tls_requires): def get_tls_requires(cursor, user, host): if user: - if server_suports_requires_create(cursor): + if not use_old_user_mgmt(cursor): query = "SHOW CREATE USER '%s'@'%s'" % (user, host) else: query = "SHOW GRANTS for '%s'@'%s'" % (user, host) @@ -458,7 +458,7 @@ def user_add(cursor, user, host, host_all, password, encrypted, # Determine what user management method server uses old_user_mgmt = use_old_user_mgmt(cursor) - mogrify = mogrify_requires if server_suports_requires_create(cursor) else do_not_mogrify_requires + mogrify = do_not_mogrify_requires if old_user_mgmt else mogrify_requires if password and encrypted: cursor.execute(*mogrify("CREATE USER %s@%s IDENTIFIED BY PASSWORD %s", (user, host, password), tls_requires)) @@ -666,7 +666,7 @@ def user_mod(cursor, user, host, host_all, password, encrypted, msg = "TLS requires updated" if module.check_mode: return (True, msg) - if server_suports_requires_create(cursor): + if not old_user_mgmt: pre_query = "ALTER USER" else: pre_query = "GRANT %s ON *.* TO" % ",".join(get_grants(cursor, user, host)) @@ -823,7 +823,7 @@ def privileges_grant(cursor, user, host, db_table, priv, tls_requires): query = ["GRANT %s ON %s" % (priv_string, db_table)] query.append("TO %s@%s") params = (user, host) - if tls_requires and not server_suports_requires_create(cursor): + if tls_requires and use_old_user_mgmt(cursor): query, params = mogrify_requires(" ".join(query), params, tls_requires) query = [query] if 'REQUIRESSL' in priv and not tls_requires: @@ -848,33 +848,6 @@ def convert_priv_dict_to_str(priv): return '/'.join(priv_list) -# TLS requires on user create statement is supported since MySQL 5.7 and MariaDB 10.2 -def server_suports_requires_create(cursor): - """Check if the server supports REQUIRES on the CREATE USER statement or doesn't. - - Args: - cursor (cursor): DB driver cursor object. - - Returns: True if supports, False otherwise. - """ - cursor.execute("SELECT VERSION()") - version_str = cursor.fetchone()[0] - version = version_str.split(".") - - if "mariadb" in version_str.lower(): - # MariaDB 10.2 and later - if int(version[0]) * 1000 + int(version[1]) >= 10002: - return True - else: - return False - else: - # MySQL 5.6 and later - if int(version[0]) * 1000 + int(version[1]) >= 5007: - return True - else: - return False - - # Alter user is supported since MySQL 5.6 and MariaDB 10.2.0 def server_supports_alter_user(cursor): """Check if the server supports ALTER USER statement or doesn't.