mysql_user: fix compatibility issues with various MySQL/MariaDB versions (#45355)

* mysql_user: fix MySQL/MariaDB version check

To handle properly user management, version check needed refacto, as well as the query used to get existing password hash

* mysql_user: break long query in multiple lines

* mysql_user: fix query fetch existing password hash

* mysql_user: MariaDB version check 100.2 != 10.2

* mysql_user: fix existing password fetch

In some cases, both columns (Password and authentication_string) may exist and be populated.
In other cases one exist, but not the second.
This fix should handle properly all situations

* mysql_user: break long queries

* mysql_user: refactor duplicated code

* mysql_user: handle updates from root with empty passwd to new passwd

* mysql_user: GC debug statement and readd trailing new line

* mysql_user: fix pep8 under indentation

* mysql_user: fix privileges management
https://github.com/ansible/ansible/pull/45355#issuecomment-428200244

* mysql_user: raise exception if exception caught doesn't match the one that is managed

* mysql_user: improve plugins output (add msg field with explicit informations)

* mysql_user: fix old / new password hash comparison

* mysql_user: fix reference to old MySQLdb lib

* mysql_user: fix cursor when root password is left empty (mysql DB invisible)

* mysql_user: add changelog

* ALL privileges comparison

* fixed blank line

* added mysql 8 fixes

* fixed version compatibility

* mysql_user: fix MySQL/MariaDB version check

To handle properly user management, version check needed refacto, as well as the query used to get existing password hash

* mysql_user: break long query in multiple lines

* mysql_user: fix query fetch existing password hash

* mysql_user: MariaDB version check 100.2 != 10.2

* mysql_user: fix existing password fetch

In some cases, both columns (Password and authentication_string) may exist and be populated.
In other cases one exist, but not the second.
This fix should handle properly all situations

* mysql_user: break long queries

* mysql_user: refactor duplicated code

* mysql_user: handle updates from root with empty passwd to new passwd

* mysql_user: GC debug statement and readd trailing new line

* mysql_user: fix pep8 under indentation

* mysql_user: fix privileges management
https://github.com/ansible/ansible/pull/45355#issuecomment-428200244

* mysql_user: raise exception if exception caught doesn't match the one that is managed

* mysql_user: improve plugins output (add msg field with explicit informations)

* mysql_user: fix old / new password hash comparison

* mysql_user: fix reference to old MySQLdb lib

* mysql_user: fix cursor when root password is left empty (mysql DB invisible)

* mysql_user: add contrib

* Rename changelogs/fragments/45355-mysql_user-fix-versions-compatibilities to add YML extension
This commit is contained in:
Benjamin MALYNOVYTCH 2019-04-09 17:26:45 +02:00 committed by John R Barker
parent fbf2d5d2f4
commit 9c5275092f
2 changed files with 88 additions and 46 deletions

View file

@ -0,0 +1,2 @@
bugfixes:
- "mysql_user: fix compatibility issues with various MySQL/MariaDB versions"

View file

@ -105,6 +105,7 @@ notes:
author:
- Jonathan Mainguy (@Jmainguy)
- Benjamin Malynovytch (@bmalynovytch)
extends_documentation_fragment: mysql
'''
@ -239,19 +240,22 @@ class InvalidPrivsError(Exception):
#
# User Authentication Management was change in MySQL 5.7
# This is a generic check for if the server version is less than version 5.7
def server_version_check(cursor):
# User Authentication Management changed in MySQL 5.7 and MariaDB 10.2.0
def use_old_user_mgmt(cursor):
cursor.execute("SELECT VERSION()")
result = cursor.fetchone()
version_str = result[0]
version = version_str.split('.')
# Currently we have no facility to handle new-style password update on
# mariadb and the old-style update continues to work
if 'mariadb' in version_str.lower():
# Prior to MariaDB 10.2
if int(version[0]) * 1000 + int(version[1]) < 10002:
return True
if int(version[0]) <= 5 and int(version[1]) < 7:
else:
return False
else:
# Prior to MySQL 5.7
if int(version[0]) * 1000 + int(version[1]) < 5007:
return True
else:
return False
@ -270,9 +274,9 @@ def get_mode(cursor):
def user_exists(cursor, user, host, host_all):
if host_all:
cursor.execute("SELECT count(*) FROM user WHERE user = %s", ([user]))
cursor.execute("SELECT count(*) FROM mysql.user WHERE user = %s", ([user]))
else:
cursor.execute("SELECT count(*) FROM user WHERE user = %s AND host = %s", (user, host))
cursor.execute("SELECT count(*) FROM mysql.user WHERE user = %s AND host = %s", (user, host))
count = cursor.fetchone()
return count[0] > 0
@ -308,6 +312,7 @@ def is_hash(password):
def user_mod(cursor, user, host, host_all, password, encrypted, new_priv, append_privs, module):
changed = False
msg = "User unchanged"
grant_option = False
if host_all:
@ -319,40 +324,67 @@ def user_mod(cursor, user, host, host_all, password, encrypted, new_priv, append
# Handle clear text and hashed passwords.
if bool(password):
# Determine what user management method server uses
old_user_mgmt = server_version_check(cursor)
old_user_mgmt = use_old_user_mgmt(cursor)
if old_user_mgmt:
cursor.execute("SELECT password FROM user WHERE user = %s AND host = %s", (user, host))
else:
cursor.execute("SELECT authentication_string FROM user WHERE user = %s AND host = %s", (user, host))
current_pass_hash = cursor.fetchone()
# Get a list of valid columns in mysql.user table to check if Password and/or authentication_string exist
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("""
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("""
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))
current_pass_hash = cursor.fetchone()[0]
if encrypted:
encrypted_string = (password)
if is_hash(password):
if current_pass_hash[0] != encrypted_string:
if module.check_mode:
return True
if old_user_mgmt:
cursor.execute("SET PASSWORD FOR %s@%s = %s", (user, host, password))
else:
cursor.execute("ALTER USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", (user, host, password))
changed = True
else:
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))")
else:
if old_user_mgmt:
cursor.execute("SELECT PASSWORD(%s)", (password,))
else:
cursor.execute("SELECT CONCAT('*', UCASE(SHA1(UNHEX(SHA1(%s)))))", (password,))
new_pass_hash = cursor.fetchone()
if current_pass_hash[0] != new_pass_hash[0]:
encrypted_password = cursor.fetchone()[0]
if current_pass_hash != encrypted_password:
msg = "Password updated"
if module.check_mode:
return True
return (True, msg)
if old_user_mgmt:
cursor.execute("SET PASSWORD FOR %s@%s = PASSWORD(%s)", (user, host, password))
cursor.execute("SET PASSWORD FOR %s@%s = %s", (user, host, encrypted_password))
msg = "Password updated (old style)"
else:
cursor.execute("ALTER USER %s@%s IDENTIFIED WITH mysql_native_password BY %s", (user, host, password))
try:
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
# Replacing empty root password with new authentication mechanisms fails with error 1396
if e.args[0] == 1396:
cursor.execute(
"UPDATE user SET plugin = %s, authentication_string = %s, Password = '' WHERE User = %s AND Host = %s",
('mysql_native_password', encrypted_password, user, host)
)
cursor.execute("FLUSH PRIVILEGES")
msg = "Password forced update"
else:
raise e
changed = True
# Handle privileges
@ -367,8 +399,9 @@ def user_mod(cursor, user, host, host_all, password, encrypted, new_priv, append
grant_option = True
if db_table not in new_priv:
if user != "root" and "PROXY" not in priv and not append_privs:
msg = "Privileges updated"
if module.check_mode:
return True
return (True, msg)
privileges_revoke(cursor, user, host, db_table, priv, grant_option)
changed = True
@ -376,8 +409,9 @@ def user_mod(cursor, user, host, host_all, password, encrypted, new_priv, append
# we can perform a straight grant operation.
for db_table, priv in iteritems(new_priv):
if db_table not in curr_priv:
msg = "New privileges granted"
if module.check_mode:
return True
return (True, msg)
privileges_grant(cursor, user, host, db_table, priv)
changed = True
@ -387,14 +421,15 @@ def user_mod(cursor, user, host, host_all, password, encrypted, new_priv, append
for db_table in db_table_intersect:
priv_diff = set(new_priv[db_table]) ^ set(curr_priv[db_table])
if len(priv_diff) > 0:
msg = "Privileges updated"
if module.check_mode:
return True
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])
changed = True
return changed
return (changed, msg)
def user_delete(cursor, user, host, host_all, check_mode):
@ -444,7 +479,7 @@ def privileges_get(cursor, user, host):
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 (['`"]).+\5)? ?(.*)""", grant[0])
if res is None:
raise InvalidPrivsError('unable to parse the MySQL grant string: %s' % grant[0])
privileges = res.group(1).split(", ")
@ -593,7 +628,7 @@ def main():
ssl_cert = module.params["client_cert"]
ssl_key = module.params["client_key"]
ssl_ca = module.params["ca_cert"]
db = 'mysql'
db = ''
sql_log_bin = module.params["sql_log_bin"]
if mysql_driver is None:
@ -632,9 +667,9 @@ def main():
if user_exists(cursor, user, host, host_all):
try:
if update_password == 'always':
changed = user_mod(cursor, user, host, host_all, password, encrypted, priv, append_privs, module)
changed, msg = user_mod(cursor, user, host, host_all, password, encrypted, priv, append_privs, module)
else:
changed = user_mod(cursor, user, host, host_all, None, encrypted, priv, append_privs, module)
changed, msg = user_mod(cursor, user, host, host_all, None, encrypted, priv, append_privs, module)
except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e:
module.fail_json(msg=to_native(e))
@ -643,14 +678,19 @@ def main():
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, priv, module.check_mode)
if changed:
msg = "User added"
except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e:
module.fail_json(msg=to_native(e))
elif state == "absent":
if user_exists(cursor, user, host, host_all):
changed = user_delete(cursor, user, host, host_all, module.check_mode)
msg = "User deleted"
else:
changed = False
module.exit_json(changed=changed, user=user)
msg = "User doesn't exist"
module.exit_json(changed=changed, user=user, msg=msg)
if __name__ == '__main__':